feat(P03): live surfaces — read-models, Reading Room fidelity, standalone client (#19)
Squash-merge of phase/03-live-surfaces into milestone/v0.3-hosted. REQ-75..82 covered: disk read-models, report.json/evidence.json artifacts with marker normalization, enriched report endpoint, refine (per-user chain, honest 503), profile routes, standalone live client (D-112 delegation ends), G-109 honest inbox, Reading Room parity, P-12 fabrication dead. Verify PASS-WITH-FIXES. 314 backend + 164 frontend tests. Ships as v0.3.3.
This commit was merged in pull request #19.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
@@ -13,15 +14,16 @@ from researchx.adapters.capability_probe import probe_model_adapter, supports_pr
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.types import AdapterError, ChatResult
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
from researchx.api.read_models import connections, graph, library, list_runs, suggestions, tasks
|
||||
from researchx.auth.admin_routes import build_admin_router
|
||||
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
|
||||
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.models import _EMAIL_RE, UserPublic
|
||||
from researchx.auth.routes import build_auth_router
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings
|
||||
from researchx.auth.users import UserStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
from researchx.config.paths import data_root
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
@@ -657,27 +659,240 @@ def create_app(
|
||||
|
||||
@protected.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
"""Disk-read, registry-independent (P-8 full closure, T-3.3):
|
||||
the run directory under THIS owner is the existence check — no
|
||||
RunRegistry involvement, correct after a restart with zero
|
||||
in-memory actors. Pre-artifact DONE runs (and any run whose
|
||||
report.json was not yet written) fall back to markdown-only
|
||||
with null report/evidence — never a 500 (D-106: old artifacts
|
||||
disposable)."""
|
||||
storage = _owner_storage(user)
|
||||
if not (storage.run_dir(run_id)).is_dir():
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
|
||||
markdown = 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}
|
||||
report: dict[str, Any] | None = None
|
||||
evidence: dict[str, Any] | None = None
|
||||
try:
|
||||
parsed = json.loads(await storage.read_run_artifact(run_id, "report.json"))
|
||||
if isinstance(parsed, dict):
|
||||
report = parsed
|
||||
except (AdapterError, json.JSONDecodeError):
|
||||
pass
|
||||
try:
|
||||
parsed = json.loads(await storage.read_run_artifact(run_id, "evidence.json"))
|
||||
if isinstance(parsed, dict):
|
||||
evidence = parsed
|
||||
except (AdapterError, json.JSONDecodeError):
|
||||
pass
|
||||
return {"run_id": run_id, "markdown": markdown, "report": report, "evidence": evidence}
|
||||
|
||||
@protected.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
"""Disk-read, registry-independent (T-3.3): same owner-dir
|
||||
existence check as the report route."""
|
||||
storage = _owner_storage(user)
|
||||
if not (storage.run_dir(run_id)).is_dir():
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------- T-3.2/T-3.3
|
||||
# Live surfaces (REQ-76..79): read-model routes — disk reads only,
|
||||
# owner-scoped via the guard, NEVER the in-memory RunRegistry
|
||||
# (D-115). Every route below is correct after a restart with zero
|
||||
# in-process actors (P-8 full closure, pinned by test).
|
||||
|
||||
@protected.get("/api/runs")
|
||||
async def list_runs_route(user: Annotated[UserPublic, Depends(guard)]) -> list[RunSummary]:
|
||||
"""All of this owner's runs from disk state files, most recent
|
||||
first — the registry-independent run list (post-sweep
|
||||
CANCELLED runs surface cancelled, NOT open)."""
|
||||
records = list_runs(storage_base, user.user_id)
|
||||
summaries: list[RunSummary] = []
|
||||
for record in records:
|
||||
plan = record.state.get("plan")
|
||||
summaries.append(
|
||||
RunSummary(
|
||||
id=record.run_id,
|
||||
status=_STATUS_MAP.get(RunStateName(record.status), "failed"),
|
||||
question=record.question,
|
||||
methodology=record.methodology_slug,
|
||||
plan=plan if isinstance(plan, dict) else None,
|
||||
started_at=record.created_at,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
@protected.get("/api/library")
|
||||
async def library_route(
|
||||
user: Annotated[UserPublic, Depends(guard)], sort: str = "recent"
|
||||
) -> dict[str, Any]:
|
||||
return library(storage_base, user.user_id, sort=sort)
|
||||
|
||||
@protected.get("/api/library/connections")
|
||||
async def connections_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, Any]]:
|
||||
return connections(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/graph")
|
||||
async def graph_route(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
return graph(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/tasks")
|
||||
async def tasks_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, str]]:
|
||||
return tasks(storage_base, user.user_id)
|
||||
|
||||
@protected.get("/api/suggestions")
|
||||
async def suggestions_route(
|
||||
user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> list[dict[str, str]]:
|
||||
return suggestions(storage_base, user.user_id)
|
||||
|
||||
# ------------------------------------------------------------- T-3.6
|
||||
# Refine + profile (REQ-80/81). The refine planner calls ride the
|
||||
# SAME per-user credential chain as runs (P2 factory); an
|
||||
# unavailable model is an honest 503, never a hang.
|
||||
|
||||
class RefineDraftBody(BaseModel):
|
||||
draft: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
class RefineComposeBody(BaseModel):
|
||||
draft: str = Field(min_length=1, max_length=2000)
|
||||
answers: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
_REFINE_QUESTIONS_PROMPT = (
|
||||
"You are a research question refiner. The user drafted a research "
|
||||
"question. Reply with a JSON object {{\"questions\": [...]}} holding "
|
||||
"exactly three short clarifying questions that would sharpen the "
|
||||
"draft. No commentary, JSON only.\nDraft: {draft}"
|
||||
)
|
||||
_REFINE_COMPOSE_PROMPT = (
|
||||
"You are a research question refiner. The user drafted a research "
|
||||
"question and answered clarifying questions. Compose the refined "
|
||||
"research question and a short suggested-context paragraph. Reply "
|
||||
"with a JSON object {{\"question\": \"...\", \"context\": \"...\"}}, "
|
||||
"JSON only.\nDraft: {draft}\nAnswers: {answers}"
|
||||
)
|
||||
|
||||
def _parse_refine_json(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AdapterError("refine planner returned non-JSON") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise AdapterError("refine planner returned non-JSON")
|
||||
return parsed
|
||||
|
||||
async def _refine_model(user: UserPublic) -> tuple[Any, str]:
|
||||
"""Resolve the per-user adapter for a refine call — the same
|
||||
chain as create_run (user -> server -> keyless), one fresh
|
||||
adapter instance per call (P-2)."""
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
run_model, tier = await model_factory.resolve(user, settings)
|
||||
return run_model, tier
|
||||
if model is None: # factory wired but absent — impossible via create_app
|
||||
raise AdapterError("no model adapter available")
|
||||
return model, "keyless"
|
||||
|
||||
@protected.post("/api/refine/questions")
|
||||
async def refine_questions(
|
||||
body: RefineDraftBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, Any]:
|
||||
draft = body.draft.strip()
|
||||
if not draft:
|
||||
raise HTTPException(status_code=422, detail="draft must not be empty")
|
||||
try:
|
||||
adapter, _tier = await _refine_model(user)
|
||||
result = await adapter.chat(
|
||||
[ModelMessage(role="user", content=_REFINE_QUESTIONS_PROMPT.format(draft=draft))],
|
||||
model=adapter.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
except AdapterError as exc:
|
||||
# Honest 503 (T-3.6): the resolved tier is unavailable —
|
||||
# never a hang, never a silent success.
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
parsed = _parse_refine_json(result.content)
|
||||
raw = parsed.get("questions", [])
|
||||
questions = [str(q).strip() for q in raw if str(q).strip()] if isinstance(raw, list) else []
|
||||
if not questions:
|
||||
raise HTTPException(status_code=503, detail="refine planner returned no questions")
|
||||
return {"questions": questions[:3]}
|
||||
|
||||
@protected.post("/api/refine/compose")
|
||||
async def refine_compose(
|
||||
body: RefineComposeBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, str]:
|
||||
draft = body.draft.strip()
|
||||
if not draft:
|
||||
raise HTTPException(status_code=422, detail="draft must not be empty")
|
||||
answers = [str(a).strip() for a in body.answers if str(a).strip()]
|
||||
try:
|
||||
adapter, _tier = await _refine_model(user)
|
||||
result = await adapter.chat(
|
||||
[
|
||||
ModelMessage(
|
||||
role="user",
|
||||
content=_REFINE_COMPOSE_PROMPT.format(
|
||||
draft=draft, answers=json.dumps(answers)
|
||||
),
|
||||
)
|
||||
],
|
||||
model=adapter.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
parsed = _parse_refine_json(result.content)
|
||||
question = str(parsed.get("question", "")).strip()
|
||||
if not question:
|
||||
raise HTTPException(status_code=503, detail="refine planner returned no question")
|
||||
return {"question": question, "context": str(parsed.get("context", "")).strip()}
|
||||
|
||||
# ------------------------------------------------------------- profile
|
||||
|
||||
class ProfileBody(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
email: str | None = Field(default=None, pattern=_EMAIL_RE, max_length=254)
|
||||
|
||||
@protected.get("/api/profile")
|
||||
async def get_profile(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, str]:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
@protected.put("/api/profile")
|
||||
async def put_profile(
|
||||
body: ProfileBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> dict[str, str]:
|
||||
"""Update display name and/or email (no deletion, REQ-81).
|
||||
The email stays unique (casefolded) across other users."""
|
||||
stored = users.get(user.user_id)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=401, detail="account disabled")
|
||||
try:
|
||||
updated = await users.update_profile(
|
||||
user.user_id,
|
||||
display_name=body.display_name,
|
||||
email=body.email,
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"user_id": updated.user_id,
|
||||
"display_name": updated.display_name,
|
||||
"email": updated.email,
|
||||
}
|
||||
|
||||
app.include_router(public)
|
||||
app.include_router(protected)
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Disk read-models (T-3.2, D-114, D-115 — REQ-76..79).
|
||||
|
||||
Pure functions over ``runs/<owner_id>/<run_id>/`` disk artifacts —
|
||||
NEVER the in-memory RunRegistry (D-115): every read endpoint stays
|
||||
correct after a restart with zero in-process actors (P-8 full
|
||||
closure). Payloads are shaped EXACTLY to the existing frontend types
|
||||
(``frontend/src/lib/types.ts``): ``ReportLibrary``/``ReportSummary``,
|
||||
``ReportConnection``, ``ProjectGraph`` nodes/edges, ``InboxItem``,
|
||||
``SuggestedQuery`` — camelCase, field-for-field, so T-3.9's surface
|
||||
swaps need zero component rewrites (REQ-54).
|
||||
|
||||
Categorization is deterministic + dumb by design: a methodology
|
||||
folder (``deep-report``/``rapid-answer``) plus topic tokens from plan
|
||||
sub-questions. No model calls, no synthesis — the same disk tree
|
||||
always categorizes identically.
|
||||
|
||||
Open-question semantics (D-114): inbox items are plan sub-questions
|
||||
of the user's runs, ``done`` iff the run reached DONE — anything
|
||||
else (post-sweep CANCELLED included) stays ``open``; suggestions are
|
||||
those open items joined with source run titles — never synthetic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------- wire shapes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportFolder:
|
||||
"""matches frontend ReportFolder — id == ReportSummary.category."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {"id": self.id, "name": self.name, "description": self.description}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportSummary:
|
||||
"""matches frontend ReportSummary — the library card."""
|
||||
|
||||
id: str
|
||||
runId: str
|
||||
title: str
|
||||
question: str
|
||||
methodologySlug: str
|
||||
category: str
|
||||
generatedAt: str
|
||||
citationCount: int
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"runId": self.runId,
|
||||
"title": self.title,
|
||||
"question": self.question,
|
||||
"methodologySlug": self.methodologySlug,
|
||||
"category": self.category,
|
||||
"generatedAt": self.generatedAt,
|
||||
"citationCount": self.citationCount,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportConnection:
|
||||
"""matches frontend ReportConnection — a shared-source/topic edge."""
|
||||
|
||||
fromReportId: str
|
||||
toReportId: str
|
||||
kind: str # "shared-sources" | "related-topic"
|
||||
label: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"fromReportId": self.fromReportId,
|
||||
"toReportId": self.toReportId,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphNode:
|
||||
id: str
|
||||
topic: str
|
||||
linkedSources: list[str]
|
||||
findings: list[str]
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"topic": self.topic,
|
||||
"linkedSources": self.linkedSources,
|
||||
"findings": self.findings,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphEdge:
|
||||
fromId: str
|
||||
toId: str
|
||||
sharedSourceCount: int
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"fromId": self.fromId,
|
||||
"toId": self.toId,
|
||||
"sharedSourceCount": self.sharedSourceCount,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboxItem:
|
||||
"""matches frontend InboxItem — "<run_id>#<idx>" ids (D-114)."""
|
||||
|
||||
id: str
|
||||
question: str
|
||||
sourceRunId: str
|
||||
status: str # "open" | "done" (in_progress is mock-only vocabulary)
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"question": self.question,
|
||||
"sourceRunId": self.sourceRunId,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuggestedQuery:
|
||||
"""matches frontend SuggestedQuery — an open item + run title."""
|
||||
|
||||
id: str
|
||||
question: str
|
||||
sourceRunId: str
|
||||
sourceReportTitle: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"question": self.question,
|
||||
"sourceRunId": self.sourceRunId,
|
||||
"sourceReportTitle": self.sourceReportTitle,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- disk scanning
|
||||
|
||||
_METHOD_NAMES = {"rapid-answer": "Rapid Answer", "deep-report": "Deep Report"}
|
||||
_QUOTE_MAX = 200
|
||||
|
||||
|
||||
def _is_scannable(run_dir: Path) -> bool:
|
||||
"""Defensive scan filter (D-115): dotfiles/tmp leftovers skipped."""
|
||||
name = run_dir.name
|
||||
return run_dir.is_dir() and not name.startswith(".") and ".tmp" not in name
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunDiskRecord:
|
||||
"""One run as it sits on disk — the read-model's only input."""
|
||||
|
||||
run_id: str
|
||||
state: dict[str, Any]
|
||||
report: dict[str, Any] | None = None
|
||||
ledger: dict[str, Any] | None = None
|
||||
# derived, filled by load:
|
||||
status: str = field(default="", init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.status = str(self.state.get("state", ""))
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return self.status == "DONE"
|
||||
|
||||
@property
|
||||
def question(self) -> str:
|
||||
return str(self.state.get("question", ""))
|
||||
|
||||
@property
|
||||
def methodology_slug(self) -> str:
|
||||
return str(self.state.get("methodology_slug", ""))
|
||||
|
||||
@property
|
||||
def created_at(self) -> str:
|
||||
return str(self.state.get("created_at", ""))
|
||||
|
||||
@property
|
||||
def sub_questions(self) -> list[str]:
|
||||
plan = self.state.get("plan")
|
||||
if not isinstance(plan, dict):
|
||||
return []
|
||||
raw = plan.get("sub_questions", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(q) for q in raw]
|
||||
|
||||
def report_title(self) -> str:
|
||||
"""The report.json title, else methodology-name — question."""
|
||||
if self.report is not None and self.report.get("title"):
|
||||
return str(self.report["title"])
|
||||
name = _METHOD_NAMES.get(self.methodology_slug, "Research")
|
||||
return f"{name} — {self.question}"
|
||||
|
||||
def citation_count(self) -> int:
|
||||
if self.report is not None:
|
||||
citations = self.report.get("citations")
|
||||
if isinstance(citations, list):
|
||||
return len(citations)
|
||||
return 0
|
||||
|
||||
def ledger_urls(self) -> list[str]:
|
||||
if self.ledger is None:
|
||||
return []
|
||||
entries = self.ledger.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [str(e.get("url")) for e in entries if isinstance(e, dict) and e.get("url")]
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw: object = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return raw if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
def list_runs(base_dir: Path, owner_id: str) -> list[RunDiskRecord]:
|
||||
"""Scan this owner's partition: state.json (+ report.json,
|
||||
ledger.json where present). Dotfiles/tmp skipped defensively
|
||||
(D-115); corrupt or stateless directories are skipped, never
|
||||
fatal. Records are ordered most-recent first (created_at desc,
|
||||
run_id as the stable tiebreaker)."""
|
||||
runs_dir = base_dir / "runs" / owner_id
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
records: list[RunDiskRecord] = []
|
||||
for run_dir in sorted(runs_dir.iterdir()):
|
||||
if not _is_scannable(run_dir):
|
||||
continue
|
||||
state = _read_json(run_dir / "state.json")
|
||||
if state is None:
|
||||
continue
|
||||
records.append(
|
||||
RunDiskRecord(
|
||||
run_id=run_dir.name,
|
||||
state=state,
|
||||
report=_read_json(run_dir / "report.json"),
|
||||
ledger=_read_json(run_dir / "ledger.json"),
|
||||
)
|
||||
)
|
||||
records.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
# ------------------------------------------------------- categorization (dumb)
|
||||
|
||||
_STOPWORDS = {
|
||||
"the", "a", "an", "of", "in", "on", "and", "or", "to", "for", "is",
|
||||
"are", "what", "which", "how", "do", "does", "why", "when", "with",
|
||||
"that", "this", "these", "those", "it", "its", "by", "at", "be",
|
||||
"can", "could", "should", "will", "into", "from", "about", "across",
|
||||
}
|
||||
|
||||
_TOPIC_TOKEN_RE = re.compile(r"[a-z][a-z0-9-]*")
|
||||
|
||||
|
||||
def topic_tokens(record: RunDiskRecord) -> list[str]:
|
||||
"""Deterministic topic tokens: salient words of the plan
|
||||
sub-questions (lowercased, deduped, order-preserving)."""
|
||||
tokens: list[str] = []
|
||||
for question in record.sub_questions:
|
||||
for token in _TOPIC_TOKEN_RE.findall(question.lower()):
|
||||
if token not in _STOPWORDS and token not in tokens:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def categorize(record: RunDiskRecord) -> str:
|
||||
"""The folder id a report files under: methodology + topic token.
|
||||
|
||||
Deterministic + dumb (REQ-76): the methodology forms the folder
|
||||
base and the first topic token of the plan names the sub-folder —
|
||||
the same disk tree always categorizes identically. Runs without a
|
||||
plan (pre-approval) fall back to the bare methodology slug; a run
|
||||
with neither resolves to "research".
|
||||
"""
|
||||
slug = record.methodology_slug or "research"
|
||||
tokens = topic_tokens(record)
|
||||
if not tokens:
|
||||
return slug
|
||||
return f"{slug}-{tokens[0]}"
|
||||
|
||||
|
||||
def _category_name(category: str) -> str:
|
||||
"""Humanize a category id: 'deep-report-battery' -> 'Deep Report:
|
||||
Battery'. The methodology slug (multi-hyphen, e.g.
|
||||
'deep-report') is stripped as a unit before the topic token is
|
||||
titled."""
|
||||
for slug, name in _METHOD_NAMES.items():
|
||||
prefix = f"{slug}-"
|
||||
if category.startswith(prefix):
|
||||
tail = category[len(prefix) :]
|
||||
return f"{name}: {tail.replace('-', ' ').title()}" if tail else name
|
||||
return category.replace("-", " ").title()
|
||||
|
||||
|
||||
def _category_description(category: str) -> str:
|
||||
return f"Auto-filed from {category.replace('-', ' ')} research."
|
||||
|
||||
|
||||
def _summary(record: RunDiskRecord) -> ReportSummary:
|
||||
generated_at = (
|
||||
str(record.report.get("generated_at")) if record.report is not None else record.created_at
|
||||
)
|
||||
return ReportSummary(
|
||||
id=f"rep-{record.run_id}",
|
||||
runId=record.run_id,
|
||||
title=record.report_title(),
|
||||
question=record.question,
|
||||
methodologySlug=record.methodology_slug,
|
||||
category=categorize(record),
|
||||
generatedAt=generated_at or datetime.now(UTC).isoformat(),
|
||||
citationCount=record.citation_count(),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- library
|
||||
|
||||
|
||||
def library(base_dir: Path, owner_id: str, sort: str = "recent") -> dict[str, Any]:
|
||||
"""GET /api/library payload: folders + ReportSummary[] (REQ-76).
|
||||
|
||||
Only DONE runs with a report are "stored reports" — a cancelled or
|
||||
failed run produced nothing worth filing. ``sort`` is "recent"
|
||||
(default, created_at desc — the scan order) or "title" (A-Z).
|
||||
"""
|
||||
if sort not in ("recent", "title"):
|
||||
sort = "recent"
|
||||
done = [r for r in list_runs(base_dir, owner_id) if r.is_done]
|
||||
reports = [_summary(r) for r in done]
|
||||
if sort == "title":
|
||||
reports.sort(key=lambda summary: (summary.title.casefold(), summary.runId))
|
||||
seen: dict[str, int] = {}
|
||||
for summary in reports:
|
||||
seen[summary.category] = seen.get(summary.category, 0) + 1
|
||||
folders = [
|
||||
ReportFolder(
|
||||
id=category,
|
||||
name=_category_name(category),
|
||||
description=_category_description(category),
|
||||
).payload()
|
||||
for category in sorted(seen)
|
||||
]
|
||||
return {
|
||||
"folders": folders,
|
||||
"reports": [summary.payload() for summary in reports],
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ connections
|
||||
|
||||
|
||||
def _shared_urls(first: RunDiskRecord, second: RunDiskRecord) -> set[str]:
|
||||
return set(first.ledger_urls()) & set(second.ledger_urls())
|
||||
|
||||
|
||||
def connections(base_dir: Path, owner_id: str) -> list[dict[str, Any]]:
|
||||
"""GET /api/library/connections payload (REQ-77): shared-source
|
||||
edges (>=1 ledger URL overlap) + related-topic edges (same
|
||||
methodology + shared category). Between the user's DONE runs
|
||||
only; edges point older -> newer (the fixture convention).
|
||||
"""
|
||||
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
|
||||
edges: list[ReportConnection] = []
|
||||
for i, first in enumerate(done):
|
||||
for second in done[i + 1 :]:
|
||||
shared = _shared_urls(first, second)
|
||||
if shared:
|
||||
edges.append(
|
||||
ReportConnection(
|
||||
fromReportId=f"rep-{first.run_id}",
|
||||
toReportId=f"rep-{second.run_id}",
|
||||
kind="shared-sources",
|
||||
label=f"{len(shared)} shared source{'s' if len(shared) != 1 else ''}",
|
||||
)
|
||||
)
|
||||
elif categorize(first) == categorize(second):
|
||||
edges.append(
|
||||
ReportConnection(
|
||||
fromReportId=f"rep-{first.run_id}",
|
||||
toReportId=f"rep-{second.run_id}",
|
||||
kind="related-topic",
|
||||
label="Adjacent topic",
|
||||
)
|
||||
)
|
||||
return [edge.payload() for edge in edges]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- graph
|
||||
|
||||
|
||||
def _report_finding(record: RunDiskRecord) -> str:
|
||||
"""The node's first 'finding': the report's title (a real artifact
|
||||
value — never fabricated prose)."""
|
||||
return record.report_title()
|
||||
|
||||
|
||||
def graph(base_dir: Path, owner_id: str) -> dict[str, Any]:
|
||||
"""GET /api/graph payload (REQ-77): DONE runs as GraphNodes, the
|
||||
same edges as connections() as GraphEdges."""
|
||||
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
|
||||
nodes = [
|
||||
GraphNode(
|
||||
id=record.run_id,
|
||||
topic=record.report_title(),
|
||||
linkedSources=sorted(set(record.ledger_urls())),
|
||||
findings=[_report_finding(record)],
|
||||
).payload()
|
||||
for record in done
|
||||
]
|
||||
edges: list[GraphEdge] = []
|
||||
for i, first in enumerate(done):
|
||||
for second in done[i + 1 :]:
|
||||
shared = _shared_urls(first, second)
|
||||
if shared:
|
||||
edges.append(GraphEdge(first.run_id, second.run_id, len(shared)))
|
||||
elif categorize(first) == categorize(second):
|
||||
edges.append(GraphEdge(first.run_id, second.run_id, 0))
|
||||
return {"nodes": nodes, "edges": [edge.payload() for edge in edges]}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- tasks
|
||||
|
||||
|
||||
def tasks(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
|
||||
"""GET /api/tasks payload (REQ-78, D-114): plan sub-questions as
|
||||
InboxItems, ``done`` iff the source run reached DONE — post-sweep
|
||||
CANCELLED runs surface ``open``, never answered."""
|
||||
items: list[InboxItem] = []
|
||||
for record in list_runs(base_dir, owner_id):
|
||||
status = "done" if record.is_done else "open"
|
||||
for idx, question in enumerate(record.sub_questions):
|
||||
items.append(
|
||||
InboxItem(
|
||||
id=f"{record.run_id}#{idx}",
|
||||
question=question,
|
||||
sourceRunId=record.run_id,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
return [item.payload() for item in items]
|
||||
|
||||
|
||||
# -------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
def suggestions(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
|
||||
"""GET /api/suggestions payload (REQ-79): the OPEN inbox items
|
||||
joined with their source run titles — derived from the user's own
|
||||
runs only, never synthetic; empty for a fresh user."""
|
||||
suggested: list[SuggestedQuery] = []
|
||||
for record in list_runs(base_dir, owner_id):
|
||||
if record.is_done:
|
||||
continue
|
||||
for idx, question in enumerate(record.sub_questions):
|
||||
suggested.append(
|
||||
SuggestedQuery(
|
||||
id=f"sug-{record.run_id}#{idx}",
|
||||
question=question,
|
||||
sourceRunId=record.run_id,
|
||||
sourceReportTitle=record.report_title(),
|
||||
)
|
||||
)
|
||||
return [item.payload() for item in suggested]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GraphEdge",
|
||||
"GraphNode",
|
||||
"InboxItem",
|
||||
"ReportConnection",
|
||||
"ReportFolder",
|
||||
"ReportSummary",
|
||||
"RunDiskRecord",
|
||||
"SuggestedQuery",
|
||||
"categorize",
|
||||
"connections",
|
||||
"graph",
|
||||
"library",
|
||||
"list_runs",
|
||||
"suggestions",
|
||||
"tasks",
|
||||
"topic_tokens",
|
||||
]
|
||||
@@ -162,6 +162,40 @@ class UserStore:
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
|
||||
async def update_profile(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
email: str | None = None,
|
||||
) -> User:
|
||||
"""Update display name and/or email (T-3.6 — GET/PUT /api/profile).
|
||||
|
||||
The email must remain unique across OTHER users (a taken
|
||||
address raises UserError); the email index is maintained
|
||||
atomically under the store lock. No deletion exists.
|
||||
"""
|
||||
with self._lock:
|
||||
user = self._by_id.get(user_id)
|
||||
if user is None:
|
||||
raise UserError("unknown user")
|
||||
new_email = user.email
|
||||
if email is not None:
|
||||
new_email = email.casefold()
|
||||
other = self._by_email.get(new_email)
|
||||
if other is not None and other.user_id != user_id:
|
||||
raise UserError("email already registered")
|
||||
new_display = display_name if display_name is not None else user.display_name
|
||||
updated = user.model_copy(update={"email": new_email, "display_name": new_display})
|
||||
if new_email != user.email:
|
||||
old = self._by_email.get(user.email)
|
||||
if old is not None and old.user_id == user_id:
|
||||
del self._by_email[user.email]
|
||||
self._by_id[user_id] = updated
|
||||
self._by_email[new_email] = updated
|
||||
self._persist_locked()
|
||||
return updated
|
||||
|
||||
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
|
||||
"""Atomically create the first user, who is admin by construction.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.artifacts import build_evidence_artifact, build_report_artifact
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
@@ -328,6 +329,33 @@ class RunActor:
|
||||
)
|
||||
checklist = export_checklist(ledger)
|
||||
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
|
||||
# Structured artifacts (T-3.1, D-113): report.json +
|
||||
# evidence.json, written HERE — the only point where the
|
||||
# per-actor EvidenceStore still exists (it dies with the actor;
|
||||
# the disk read-models of D-114/D-115 can never rebuild it).
|
||||
# Marker normalization [p-N] -> [^pN] happens at this
|
||||
# persistence boundary ONLY (C3-10): report.md keeps the raw
|
||||
# form the trust-loop regexes depend on.
|
||||
evidence_artifact = build_evidence_artifact(
|
||||
run_id=self._config.run_id, evidence=self._store
|
||||
)
|
||||
report_artifact = build_report_artifact(
|
||||
run_id=self._config.run_id,
|
||||
owner_id=self.state.owner_id,
|
||||
question=self._config.question,
|
||||
methodology_name=self._methodology.name,
|
||||
methodology_slug=self._config.methodology_slug,
|
||||
report_markdown=report,
|
||||
ledger=ledger,
|
||||
evidence=self._store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
await self._storage.write_run_artifact(
|
||||
self._config.run_id, "evidence.json", evidence_artifact.model_dump_json(indent=2)
|
||||
)
|
||||
await self._storage.write_run_artifact(
|
||||
self._config.run_id, "report.json", report_artifact.model_dump_json(indent=2)
|
||||
)
|
||||
await self._checkpoint(RunStateName.DONE)
|
||||
await self._publish("run_done", {"run_id": self._config.run_id})
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Structured run artifacts (T-3.1, D-113): report.json + evidence.json.
|
||||
|
||||
Built at DONE inside ``RunActor._verify_phase`` — the ONLY point where
|
||||
the per-actor ``EvidenceStore`` is still alive (it is GC'd with the
|
||||
actor; D-115 disk read-models come later and can never see it).
|
||||
|
||||
**Marker normalization happens HERE ONLY (C3-10).** The writer emits
|
||||
``[p-N]`` — the trust-loop regexes (``anchoring.py``,
|
||||
``ledger.py``) and the actor's own ``_cited_urls`` depend on that raw
|
||||
form, so ``report.md`` is never touched. The Reading Room parses
|
||||
``[^pN]`` (``report-body.ts`` matches ``[A-Za-z0-9]+`` only —
|
||||
``[^p-1]`` does NOT match), therefore report.json section bodies and
|
||||
the id fields of both artifacts carry the normalized ``pN`` id form.
|
||||
|
||||
Both artifacts are versioned (``v: 1``) so later schema changes stay
|
||||
detectable per file (D-106 old artifacts stay disposable/readable).
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.trust.ledger import CitationLedger
|
||||
|
||||
#: The writer's raw marker form — the ONLY pattern normalized here.
|
||||
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
|
||||
|
||||
#: ATX headings (H1..H6) — section delimiters for report.json.
|
||||
_HEADING_RE = re.compile(r"^#{1,6}[ \t]+(.*)$", re.MULTILINE)
|
||||
|
||||
#: A lone H1 — the report title source.
|
||||
_H1_RE = re.compile(r"^#[ \t]+(.*)$", re.MULTILINE)
|
||||
|
||||
#: Quote display cap — matches EvidenceStore's ``quote`` convention.
|
||||
_QUOTE_MAX_CHARS = 200
|
||||
|
||||
|
||||
def normalize_passage_id(passage_id: str) -> str:
|
||||
"""``p-N`` -> ``pN`` (the id form report.json/evidence.json carry)."""
|
||||
match = re.fullmatch(r"p-(\d+)", passage_id)
|
||||
return f"p{match.group(1)}" if match else passage_id
|
||||
|
||||
|
||||
def normalize_markers(text: str) -> str:
|
||||
"""``[p-N]`` -> ``[^pN]`` — report.json section bodies ONLY."""
|
||||
return _MARKER_RE.sub(lambda match: f"[^p{match.group(1)}]", text)
|
||||
|
||||
|
||||
def extract_title(markdown: str, fallback: str) -> str:
|
||||
"""First H1 of the report, else the methodology/question fallback."""
|
||||
match = _H1_RE.search(markdown)
|
||||
if match is not None:
|
||||
return match.group(1).strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def split_sections(markdown: str) -> list[tuple[str, str]]:
|
||||
"""Split report.md into (heading, body) chunks on ATX headings.
|
||||
|
||||
Every ``^#{1,6}\\s`` heading starts a section and is included as
|
||||
that section's heading (the H1 chunk included — the title is a
|
||||
separate field and the frontend binding decides presentation).
|
||||
Content before the first heading becomes a ``("", body)`` section
|
||||
rather than being dropped — no content loss, no fabrication.
|
||||
"""
|
||||
sections: list[tuple[str, str]] = []
|
||||
matches = list(_HEADING_RE.finditer(markdown))
|
||||
if not matches:
|
||||
body = markdown.strip()
|
||||
if body:
|
||||
sections.append(("", body))
|
||||
return sections
|
||||
preamble = markdown[: matches[0].start()].strip()
|
||||
if preamble:
|
||||
sections.append(("", preamble))
|
||||
for index, match in enumerate(matches):
|
||||
heading = match.group(1).strip()
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
|
||||
sections.append((heading, markdown[start:end].strip()))
|
||||
return sections
|
||||
|
||||
|
||||
class EvidencePassage(BaseModel):
|
||||
"""One stored passage — id in the normalized ``pN`` form."""
|
||||
|
||||
passage_id: str
|
||||
url: str
|
||||
title: str
|
||||
quote: str
|
||||
text: str
|
||||
|
||||
|
||||
class EvidenceArtifact(BaseModel):
|
||||
"""evidence.json — the serialized EvidenceStore at DONE."""
|
||||
|
||||
v: int = 1
|
||||
run_id: str
|
||||
passages: list[EvidencePassage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReportSection(BaseModel):
|
||||
heading: str
|
||||
body: str
|
||||
|
||||
|
||||
class ReportCitation(BaseModel):
|
||||
"""A page-anchored citation — passage-level, quote included."""
|
||||
|
||||
passage_id: str # pN form
|
||||
url: str
|
||||
title: str
|
||||
quote: str
|
||||
health: str
|
||||
authority_tier: str
|
||||
|
||||
|
||||
class ReportArtifact(BaseModel):
|
||||
"""report.json — the structured Reading-Room payload at DONE."""
|
||||
|
||||
v: int = 1
|
||||
run_id: str
|
||||
owner_id: str | None = None
|
||||
title: str
|
||||
question: str
|
||||
methodology_slug: str
|
||||
generated_at: datetime
|
||||
sections: list[ReportSection] = Field(default_factory=list)
|
||||
citations: list[ReportCitation] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _page_titles(evidence: EvidenceStore) -> dict[str, str]:
|
||||
return {page.url: page.title for page in evidence.pages()}
|
||||
|
||||
|
||||
def _clip(quote: str) -> str:
|
||||
return quote[:_QUOTE_MAX_CHARS]
|
||||
|
||||
|
||||
def build_evidence_artifact(*, run_id: str, evidence: EvidenceStore) -> EvidenceArtifact:
|
||||
"""Serialize the full EvidenceStore — ids renumbered ``pN``.
|
||||
|
||||
Every passage the run collected, full text included: this is the
|
||||
only persisted copy (the in-actor store dies at DONE).
|
||||
"""
|
||||
titles = _page_titles(evidence)
|
||||
passages = [
|
||||
EvidencePassage(
|
||||
passage_id=normalize_passage_id(passage.passage_id),
|
||||
url=passage.url,
|
||||
title=titles.get(passage.url, ""),
|
||||
quote=_clip(passage.quote),
|
||||
text=passage.text,
|
||||
)
|
||||
for passage in evidence.writer_context()
|
||||
]
|
||||
return EvidenceArtifact(run_id=run_id, passages=passages)
|
||||
|
||||
|
||||
def build_report_artifact(
|
||||
*,
|
||||
run_id: str,
|
||||
owner_id: str | None,
|
||||
question: str,
|
||||
methodology_name: str,
|
||||
methodology_slug: str,
|
||||
report_markdown: str,
|
||||
ledger: CitationLedger,
|
||||
evidence: EvidenceStore,
|
||||
generated_at: datetime,
|
||||
) -> ReportArtifact:
|
||||
"""Build report.json from the raw report.md + ledger + evidence.
|
||||
|
||||
Section bodies carry normalized ``[^pN]`` markers (C3-10); the raw
|
||||
``report.md`` on disk is untouched. Citations are the ledger's cited
|
||||
URL entries resolved to their stored passages — real quotes from
|
||||
the evidence in hand, never fabricated.
|
||||
"""
|
||||
title = extract_title(report_markdown, fallback=f"{methodology_name} — {question}")
|
||||
sections = [
|
||||
ReportSection(heading=heading, body=normalize_markers(body))
|
||||
for heading, body in split_sections(report_markdown)
|
||||
]
|
||||
titles = _page_titles(evidence)
|
||||
citations: list[ReportCitation] = []
|
||||
for entry in ledger.entries:
|
||||
for passage_id in entry.passages:
|
||||
passage = evidence.get(passage_id)
|
||||
if passage is None:
|
||||
continue
|
||||
citations.append(
|
||||
ReportCitation(
|
||||
passage_id=normalize_passage_id(passage_id),
|
||||
url=entry.url,
|
||||
title=titles.get(entry.url, "") or entry.url,
|
||||
quote=_clip(passage.quote),
|
||||
health=entry.health,
|
||||
authority_tier=entry.authority_tier,
|
||||
)
|
||||
)
|
||||
return ReportArtifact(
|
||||
run_id=run_id,
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
question=question,
|
||||
methodology_slug=methodology_slug,
|
||||
generated_at=generated_at,
|
||||
sections=sections,
|
||||
citations=citations,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EvidenceArtifact",
|
||||
"EvidencePassage",
|
||||
"ReportArtifact",
|
||||
"ReportCitation",
|
||||
"ReportSection",
|
||||
"build_evidence_artifact",
|
||||
"build_report_artifact",
|
||||
"extract_title",
|
||||
"normalize_markers",
|
||||
"normalize_passage_id",
|
||||
"split_sections",
|
||||
]
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Structured artifacts at DONE (T-3.1, D-113): report.json +
|
||||
evidence.json — marker normalization at the persistence boundary ONLY
|
||||
(C3-10: report.json bodies carry [^pN], report.md keeps raw [p-N])."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
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 EngineError, RunActor, make_run_config
|
||||
from researchx.engine.artifacts import (
|
||||
build_evidence_artifact,
|
||||
build_report_artifact,
|
||||
extract_title,
|
||||
normalize_markers,
|
||||
normalize_passage_id,
|
||||
split_sections,
|
||||
)
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.methodologies import registry
|
||||
from researchx.trust.ledger import build_ledger
|
||||
from researchx.trust.liveness import LivenessStatus
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{
|
||||
"sub_questions": ["methodology verification", "quote anchoring"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro", "findings"],
|
||||
}
|
||||
)
|
||||
|
||||
# Multi-section report with [p-N] markers on multiple lines.
|
||||
_WRITER_REPORT = (
|
||||
"# Battery Advances\n\n"
|
||||
"The verification pass re-anchored every quoted claim [p-1].\n\n"
|
||||
"## Findings\n\n"
|
||||
"Methodology-first selection converts ad-hoc prompting [p-1] "
|
||||
"into reproducible decisions [p-2].\n\n"
|
||||
"## Conclusion\n\n"
|
||||
"The anchored loop holds [p-2].\n"
|
||||
)
|
||||
|
||||
|
||||
def _canned() -> list[ChatResult]:
|
||||
return [
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
|
||||
]
|
||||
|
||||
|
||||
def _storage(tmp_path: Path) -> LocalStorageAdapter:
|
||||
return LocalStorageAdapter(base_dir=tmp_path, owner_id="abcd2345")
|
||||
|
||||
|
||||
async def _run_to_done(tmp_path: Path) -> RunActor:
|
||||
_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",
|
||||
owner_id="abcd2345",
|
||||
)
|
||||
model = FakeModelAdapter(canned=_canned())
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=search,
|
||||
storage=_storage(tmp_path),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
actor.state.owner_id = "abcd2345"
|
||||
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()
|
||||
await task
|
||||
assert actor.state.state == RunStateName.DONE
|
||||
return actor
|
||||
|
||||
|
||||
async def _read_json(tmp_path: Path, name: str) -> dict[str, object]:
|
||||
raw = await _storage(tmp_path).read_run_artifact("abcd2345", name)
|
||||
return json.loads(raw) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- unit helpers
|
||||
|
||||
|
||||
def test_normalize_markers_converts_p_dash_form() -> None:
|
||||
assert normalize_markers("claim [p-1] and [p-12]") == "claim [^p1] and [^p12]"
|
||||
|
||||
|
||||
def test_normalize_passage_id_renumbers() -> None:
|
||||
assert normalize_passage_id("p-1") == "p1"
|
||||
assert normalize_passage_id("p-42") == "p42"
|
||||
|
||||
|
||||
def test_split_sections_on_headings() -> None:
|
||||
sections = split_sections("# Title\n\nintro body\n\n## A\n\na body\n\n### B\n\nb body")
|
||||
assert sections == [
|
||||
("Title", "intro body"),
|
||||
("A", "a body"),
|
||||
("B", "b body"),
|
||||
]
|
||||
|
||||
|
||||
def test_split_sections_preamble_and_no_headings() -> None:
|
||||
assert split_sections("loose text only") == [("", "loose text only")]
|
||||
assert split_sections("before\n\n# H\n\nafter") == [("", "before"), ("H", "after")]
|
||||
|
||||
|
||||
def test_extract_title_prefers_first_h1() -> None:
|
||||
assert extract_title("# First H1\n\n## Second\n", "fallback") == "First H1"
|
||||
assert extract_title("no heading at all", "Deep Report — q?") == "Deep Report — q?"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build paths
|
||||
|
||||
|
||||
def _fixture_store() -> EvidenceStore:
|
||||
store = EvidenceStore()
|
||||
store.add_page(
|
||||
"https://example.org/verification",
|
||||
"Verification passes in research pipelines",
|
||||
(
|
||||
"The verification pass re-anchored every quoted claim to its stored "
|
||||
"passage before the report was allowed to ship, so the audit trail "
|
||||
"stays honest claim by claim."
|
||||
),
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def test_build_evidence_artifact_renumbers_ids_and_keeps_text() -> None:
|
||||
artifact = build_evidence_artifact(run_id="abcd2345", evidence=_fixture_store())
|
||||
payload = json.loads(artifact.model_dump_json())
|
||||
assert payload["v"] == 1
|
||||
assert payload["run_id"] == "abcd2345"
|
||||
assert len(payload["passages"]) == 1
|
||||
passage = payload["passages"][0]
|
||||
assert passage["passage_id"] == "p1" # p-1 -> p1
|
||||
assert passage["url"] == "https://example.org/verification"
|
||||
assert passage["title"] == "Verification passes in research pipelines"
|
||||
assert passage["quote"] == passage["text"][:200]
|
||||
assert "re-anchored" in passage["text"]
|
||||
|
||||
|
||||
def test_build_report_artifact_sections_and_citations() -> None:
|
||||
store = _fixture_store()
|
||||
markdown = "# Report\n\nClaim [p-1] here.\n\n## Findings\n\nAnother [p-1] claim.\n"
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=markdown,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/verification": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
artifact = build_report_artifact(
|
||||
run_id="abcd2345",
|
||||
owner_id="abcd2345",
|
||||
question="q?",
|
||||
methodology_name="Rapid Answer",
|
||||
methodology_slug="rapid-answer",
|
||||
report_markdown=markdown,
|
||||
ledger=ledger,
|
||||
evidence=store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
payload = json.loads(artifact.model_dump_json())
|
||||
assert payload["v"] == 1
|
||||
assert payload["owner_id"] == "abcd2345"
|
||||
assert payload["title"] == "Report"
|
||||
assert payload["question"] == "q?"
|
||||
assert payload["methodology_slug"] == "rapid-answer"
|
||||
assert [s["heading"] for s in payload["sections"]] == ["Report", "Findings"]
|
||||
# C3-10: normalized markers in section bodies ONLY.
|
||||
assert "[^p1]" in payload["sections"][0]["body"]
|
||||
assert "[p-1]" not in payload["sections"][0]["body"]
|
||||
assert [c["passage_id"] for c in payload["citations"]] == ["p1"]
|
||||
citation = payload["citations"][0]
|
||||
assert citation["url"] == "https://example.org/verification"
|
||||
assert citation["title"] == "Verification passes in research pipelines"
|
||||
assert citation["quote"].startswith("The verification pass")
|
||||
assert citation["health"] == "live"
|
||||
assert citation["authority_tier"] == "reference"
|
||||
|
||||
|
||||
def test_build_report_artifact_title_fallback_uses_methodology_name() -> None:
|
||||
store = _fixture_store()
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text="no heading [p-1]",
|
||||
evidence=store,
|
||||
liveness={},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
artifact = build_report_artifact(
|
||||
run_id="abcd2345",
|
||||
owner_id=None,
|
||||
question="what about X?",
|
||||
methodology_name="Deep Report",
|
||||
methodology_slug="deep-report",
|
||||
report_markdown="no heading [p-1]",
|
||||
ledger=ledger,
|
||||
evidence=store,
|
||||
generated_at=ledger.generated_at,
|
||||
)
|
||||
assert artifact.title == "Deep Report — what about X?"
|
||||
|
||||
|
||||
# --------------------------------------------------- at-DONE persistence
|
||||
|
||||
|
||||
async def test_done_writes_report_and_evidence_artifacts(tmp_path: Path) -> None:
|
||||
await _run_to_done(tmp_path)
|
||||
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
evidence_payload = await _read_json(tmp_path, "evidence.json")
|
||||
|
||||
# report.json shape
|
||||
assert report_payload["v"] == 1
|
||||
assert report_payload["run_id"] == "abcd2345"
|
||||
assert report_payload["owner_id"] == "abcd2345"
|
||||
assert report_payload["title"] == "Battery Advances"
|
||||
assert report_payload["methodology_slug"] == "rapid-answer"
|
||||
assert "generated_at" in report_payload
|
||||
assert [s["heading"] for s in report_payload["sections"]] == [ # type: ignore[call-overload]
|
||||
"Battery Advances",
|
||||
"Findings",
|
||||
"Conclusion",
|
||||
]
|
||||
|
||||
# evidence.json shape
|
||||
assert evidence_payload["v"] == 1
|
||||
assert evidence_payload["run_id"] == "abcd2345"
|
||||
assert len(evidence_payload["passages"]) >= 2 # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_marker_normalization_at_boundary_only(tmp_path: Path) -> None:
|
||||
"""C3-10 pinned end to end: report.json bodies carry [^pN]; the raw
|
||||
report.md keeps [p-N] (the trust-loop regexes' form)."""
|
||||
await _run_to_done(tmp_path)
|
||||
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
markdown = await _storage(tmp_path).read_run_artifact("abcd2345", "report.md")
|
||||
|
||||
sections = report_payload["sections"]
|
||||
assert isinstance(sections, list) and sections
|
||||
joined = "\n".join(str(s["body"]) for s in sections)
|
||||
assert "[^p1]" in joined
|
||||
assert "[p-1]" not in joined
|
||||
assert "[^p2]" in joined
|
||||
# The raw artifact is untouched — trust-loop regexes keep working.
|
||||
assert "[p-1]" in markdown
|
||||
assert "[^p1]" not in markdown
|
||||
|
||||
|
||||
async def test_citations_carry_real_quotes_and_pn_ids(tmp_path: Path) -> None:
|
||||
await _run_to_done(tmp_path)
|
||||
report_payload = await _read_json(tmp_path, "report.json")
|
||||
evidence_payload = await _read_json(tmp_path, "evidence.json")
|
||||
|
||||
citations = report_payload["citations"]
|
||||
assert isinstance(citations, list) and citations
|
||||
for citation in citations:
|
||||
assert re.fullmatch(r"^p\d+$", str(citation["passage_id"]))
|
||||
assert citation["url"].startswith("https://")
|
||||
assert citation["quote"], "citation quote must be a real stored quote"
|
||||
assert len(str(citation["quote"])) <= 200
|
||||
assert citation["health"] in {"live", "stale", "dead"}
|
||||
assert citation["authority_tier"]
|
||||
|
||||
# Evidence carries full texts: quote is the clipped head of text.
|
||||
passages = evidence_payload["passages"]
|
||||
assert isinstance(passages, list) and passages
|
||||
evidence_by_id = {str(p["passage_id"]): p for p in passages}
|
||||
for citation in citations:
|
||||
passage = evidence_by_id[str(citation["passage_id"])]
|
||||
assert str(passage["quote"]) == str(citation["quote"])
|
||||
assert len(str(passage["text"])) >= len(str(passage["quote"]))
|
||||
|
||||
|
||||
async def test_artifacts_only_written_at_done(tmp_path: Path) -> None:
|
||||
"""A run cancelled at AWAITING_APPROVAL leaves no structured
|
||||
artifacts — the write point is _verify_phase, nothing earlier."""
|
||||
_run_id, config = make_run_config(
|
||||
question="q",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
owner_id="abcd2345",
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=FakeModelAdapter(canned=_canned()),
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=_storage(tmp_path),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
actor.cancel()
|
||||
await actor.approve()
|
||||
with contextlib.suppress(asyncio.CancelledError, EngineError):
|
||||
await task
|
||||
run_dir = tmp_path / "runs" / "abcd2345" / "abcd2345"
|
||||
assert not (run_dir / "report.json").exists()
|
||||
assert not (run_dir / "evidence.json").exists()
|
||||
@@ -0,0 +1,687 @@
|
||||
"""Live-surface route tests (T-3.3, REQ-76..82).
|
||||
|
||||
Every new/reworked route over a fixture disk tree: report/ledger
|
||||
disk-read with pre-artifact DONE fallback, library/connections/graph/
|
||||
tasks/suggestions, refine (200 on fakes, honest 503 on adapter
|
||||
failure), profile round-trip, and the FRESH-APP-OVER-EXISTING-DATA-
|
||||
ROOT test (P-8 full closure — every read route correct against a
|
||||
second create_app with zero in-memory actors).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Research Memo\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
_DEV = "dev00000" # the synthetic dev admin (auth_enabled=False harness)
|
||||
|
||||
|
||||
def _state(run_id: str, question: str, state: str, created_at: str, plan: dict | None = None) -> dict:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"question": question,
|
||||
"context": "",
|
||||
"methodology_slug": "deep-report",
|
||||
"state": state,
|
||||
"owner_id": _DEV,
|
||||
"plan": plan if plan is not None else {
|
||||
"sub_questions": ["battery density question"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
},
|
||||
"depth_cursor": 0,
|
||||
"depth_target": 1,
|
||||
"counters": {},
|
||||
"probe": None,
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _seed_run(
|
||||
tmp_path: Path,
|
||||
run_id: str,
|
||||
*,
|
||||
state: str,
|
||||
question: str = "What about battery research?",
|
||||
created_at: str = "2026-09-20T10:00:00Z",
|
||||
plan: dict | None = None,
|
||||
report_markdown: str | None = None,
|
||||
report_json: dict | None = None,
|
||||
evidence_json: dict | None = None,
|
||||
ledger_json: dict | None = None,
|
||||
owner: str = _DEV,
|
||||
) -> None:
|
||||
run_dir = tmp_path / "runs" / owner / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "state.json").write_text(
|
||||
json.dumps(_state(run_id, question, state, created_at, plan)), encoding="utf-8"
|
||||
)
|
||||
if report_markdown is not None:
|
||||
(run_dir / "report.md").write_text(report_markdown, encoding="utf-8")
|
||||
if report_json is not None:
|
||||
(run_dir / "report.json").write_text(json.dumps(report_json), encoding="utf-8")
|
||||
if evidence_json is not None:
|
||||
(run_dir / "evidence.json").write_text(json.dumps(evidence_json), encoding="utf-8")
|
||||
if ledger_json is not None:
|
||||
(run_dir / "ledger.json").write_text(json.dumps(ledger_json), encoding="utf-8")
|
||||
|
||||
|
||||
_REPORT_JSON = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"owner_id": _DEV,
|
||||
"title": "Battery Advances",
|
||||
"question": "What about battery research?",
|
||||
"methodology_slug": "deep-report",
|
||||
"generated_at": "2026-09-20T10:05:00Z",
|
||||
"sections": [
|
||||
{"heading": "Research Memo", "body": "The verified core finding [^p1]."}
|
||||
],
|
||||
"citations": [
|
||||
{
|
||||
"passage_id": "p1",
|
||||
"url": "https://example.org/x",
|
||||
"title": "Verification passes",
|
||||
"quote": "The verified core finding.",
|
||||
"health": "live",
|
||||
"authority_tier": "reference",
|
||||
}
|
||||
],
|
||||
}
|
||||
_EVIDENCE_JSON = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"passages": [
|
||||
{
|
||||
"passage_id": "p1",
|
||||
"url": "https://example.org/x",
|
||||
"title": "Verification passes",
|
||||
"quote": "The verified core finding.",
|
||||
"text": "The verified core finding in full, stored at DONE.",
|
||||
}
|
||||
],
|
||||
}
|
||||
_LEDGER_JSON = {
|
||||
"run_id": "runaaaa1",
|
||||
"generated_at": "2026-09-20T10:05:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"url": "https://example.org/x",
|
||||
"visited_at": "2026-09-20T10:04:00Z",
|
||||
"passages": ["p-1"],
|
||||
"claims_supported": 2,
|
||||
"health": "live",
|
||||
"authority_tier": "reference",
|
||||
}
|
||||
],
|
||||
"checklist": {"run_id": "runaaaa1", "citations_checked": 1},
|
||||
}
|
||||
|
||||
|
||||
def _seed_tree(tmp_path: Path) -> Path:
|
||||
"""The route fixture tree under the dev owner:
|
||||
- runaaaa1: DONE with report.md + report.json + evidence.json + ledger
|
||||
- runbbbb1: DONE pre-artifact (report.md only — no report.json)
|
||||
- runcccc1: non-terminal (SEARCHING) — swept to CANCELLED by create_app
|
||||
"""
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runaaaa1",
|
||||
state="DONE",
|
||||
report_markdown="# Research Memo\n\nThe verified core finding [p-1].\n",
|
||||
report_json=_REPORT_JSON,
|
||||
evidence_json=_EVIDENCE_JSON,
|
||||
ledger_json=_LEDGER_JSON,
|
||||
)
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runbbbb1",
|
||||
state="DONE",
|
||||
question="A pre-artifact done run?",
|
||||
created_at="2026-09-19T10:00:00Z",
|
||||
report_markdown="# Old Report\n\nRaw [p-1] markers only.\n",
|
||||
)
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runcccc1",
|
||||
state="SEARCHING",
|
||||
question="Swept mid-flight question?",
|
||||
created_at="2026-09-21T10:00:00Z",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _fake_app(tmp_path: Path, canned: list[ChatResult] | None = None) -> object:
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=canned or [ChatResult(content=_PLAN, model="p")]),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(app: object) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- report route
|
||||
|
||||
|
||||
async def test_report_route_disk_read_returns_full_payload(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runaaaa1/report")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["run_id"] == "runaaaa1"
|
||||
assert "[p-1]" in body["markdown"] # raw form retained (back-compat)
|
||||
assert body["report"] == _REPORT_JSON
|
||||
assert body["evidence"] == _EVIDENCE_JSON
|
||||
|
||||
|
||||
async def test_report_route_pre_artifact_done_fallback(tmp_path: Path) -> None:
|
||||
"""A DONE run from before T-3.1 (report.md only): markdown served,
|
||||
null report/evidence — never a 500 (D-106)."""
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runbbbb1/report")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["run_id"] == "runbbbb1"
|
||||
assert "Raw [p-1] markers" in body["markdown"]
|
||||
assert body["report"] is None
|
||||
assert body["evidence"] is None
|
||||
|
||||
|
||||
async def test_report_route_missing_run_404(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
assert (await client.get("/api/runs/zzzz9999/report")).status_code == 404
|
||||
|
||||
|
||||
async def test_ledger_route_disk_read(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs/runaaaa1/ledger")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _LEDGER_JSON
|
||||
assert (await client.get("/api/runs/zzzz9999/ledger")).status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------ run list route
|
||||
|
||||
|
||||
async def test_runs_route_lists_from_disk_recent_first(tmp_path: Path) -> None:
|
||||
"""GET /api/runs from disk state files; the SEARCHING fixture is
|
||||
swept to CANCELLED at create_app — it surfaces cancelled, NOT open
|
||||
(post-sweep honesty)."""
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
runs = response.json()
|
||||
assert [run["id"] for run in runs] == ["runcccc1", "runaaaa1", "runbbbb1"]
|
||||
statuses = {run["id"]: run["status"] for run in runs}
|
||||
assert statuses["runcccc1"] == "cancelled" # swept, honest
|
||||
assert statuses["runaaaa1"] == "done"
|
||||
assert statuses["runbbbb1"] == "done"
|
||||
first = runs[1]
|
||||
assert first["question"] == "What about battery research?"
|
||||
assert first["methodology"] == "deep-report"
|
||||
assert first["plan"]["sub_questions"] == ["battery density question"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------- library routes
|
||||
|
||||
|
||||
async def test_library_route(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
response = await client.get("/api/library")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [folder["id"] for folder in body["folders"]] == ["deep-report-battery"]
|
||||
assert [report["runId"] for report in body["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
first = body["reports"][0]
|
||||
assert first["title"] == "Battery Advances"
|
||||
assert first["citationCount"] == 1
|
||||
assert first["category"] == "deep-report-battery"
|
||||
# title sort: "Battery Advances" < "Deep Report — A pre-artifact..."
|
||||
titled = await client.get("/api/library?sort=title")
|
||||
assert [r["runId"] for r in titled.json()["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
|
||||
|
||||
async def test_connections_graph_tasks_suggestions_routes(tmp_path: Path) -> None:
|
||||
_seed_tree(tmp_path)
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
connections = await client.get("/api/library/connections")
|
||||
assert connections.status_code == 200
|
||||
# runaaaa1 has a ledger; runbbbb1 has none -> no shared URL, and
|
||||
# same methodology+category -> related-topic edge.
|
||||
assert connections.json() == [
|
||||
{
|
||||
"fromReportId": "rep-runbbbb1",
|
||||
"toReportId": "rep-runaaaa1",
|
||||
"kind": "related-topic",
|
||||
"label": "Adjacent topic",
|
||||
}
|
||||
]
|
||||
|
||||
graph = await client.get("/api/graph")
|
||||
assert graph.status_code == 200
|
||||
payload = graph.json()
|
||||
assert {node["id"] for node in payload["nodes"]} == {"runaaaa1", "runbbbb1"}
|
||||
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
|
||||
assert node["topic"] == "Battery Advances"
|
||||
assert node["linkedSources"] == ["https://example.org/x"]
|
||||
assert payload["edges"] == [{"fromId": "runbbbb1", "toId": "runaaaa1", "sharedSourceCount": 0}]
|
||||
|
||||
tasks = await client.get("/api/tasks")
|
||||
assert tasks.status_code == 200
|
||||
items = {item["id"]: item for item in tasks.json()}
|
||||
assert items["runaaaa1#0"]["status"] == "done"
|
||||
assert items["runbbbb1#0"]["status"] == "done"
|
||||
# The swept CANCELLED run's question stays OPEN (D-114).
|
||||
assert items["runcccc1#0"]["status"] == "open"
|
||||
assert items["runcccc1#0"]["question"] == "battery density question"
|
||||
|
||||
suggestions = await client.get("/api/suggestions")
|
||||
assert suggestions.status_code == 200
|
||||
assert suggestions.json() == [
|
||||
{
|
||||
"id": "sug-runcccc1#0",
|
||||
"question": "battery density question",
|
||||
"sourceRunId": "runcccc1",
|
||||
"sourceReportTitle": "Deep Report — Swept mid-flight question?",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_fresh_user_empty_states(tmp_path: Path) -> None:
|
||||
"""Honest empty states: no synthetic content anywhere."""
|
||||
async with await _client(_fake_app(tmp_path)) as client:
|
||||
assert (await client.get("/api/library")).json() == {"folders": [], "reports": []}
|
||||
assert (await client.get("/api/library/connections")).json() == []
|
||||
assert (await client.get("/api/graph")).json() == {"nodes": [], "edges": []}
|
||||
assert (await client.get("/api/tasks")).json() == []
|
||||
assert (await client.get("/api/suggestions")).json() == []
|
||||
assert (await client.get("/api/runs")).json() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- refine
|
||||
|
||||
|
||||
class _RefineModel:
|
||||
"""Fake model for the refine exchange: answers the questions prompt,
|
||||
then the compose prompt; a second instance answers with an
|
||||
AdapterError to pin the honest 503."""
|
||||
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self._fail = fail
|
||||
self._calls = 0
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
del messages, temperature, model
|
||||
if self._fail:
|
||||
raise AdapterError("no local model reachable")
|
||||
self._calls += 1
|
||||
if self._calls == 1:
|
||||
return ChatResult(
|
||||
content=json.dumps(
|
||||
{
|
||||
"questions": [
|
||||
"What scope matters most?",
|
||||
"What decision will this inform?",
|
||||
"How deep should the answer go?",
|
||||
]
|
||||
}
|
||||
),
|
||||
model="fake-refine",
|
||||
)
|
||||
return ChatResult(
|
||||
content=json.dumps(
|
||||
{
|
||||
"question": "How do batteries work — focusing on density?",
|
||||
"context": "Feeds a supplier decision. Depth: executive summary.",
|
||||
}
|
||||
),
|
||||
model="fake-refine",
|
||||
)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return ["fake-refine"]
|
||||
|
||||
def roles(self) -> object:
|
||||
from researchx.adapters.model import ModelRoles
|
||||
|
||||
return ModelRoles(planner="fake-refine", writer="fake-refine")
|
||||
|
||||
|
||||
async def test_refine_questions_and_compose(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(app) as client:
|
||||
asked = await client.post("/api/refine/questions", json={"draft": "how do batteries work?"})
|
||||
assert asked.status_code == 200
|
||||
assert asked.json() == {
|
||||
"questions": [
|
||||
"What scope matters most?",
|
||||
"What decision will this inform?",
|
||||
"How deep should the answer go?",
|
||||
]
|
||||
}
|
||||
composed = await client.post(
|
||||
"/api/refine/compose",
|
||||
json={"draft": "how do batteries work?", "answers": ["density", "supplier choice"]},
|
||||
)
|
||||
assert composed.status_code == 200
|
||||
assert composed.json() == {
|
||||
"question": "How do batteries work — focusing on density?",
|
||||
"context": "Feeds a supplier decision. Depth: executive summary.",
|
||||
}
|
||||
|
||||
|
||||
async def test_refine_honest_503_never_hang(tmp_path: Path) -> None:
|
||||
"""A keyless user with no reachable endpoint gets an honest 503 —
|
||||
never a hang, never a silent success."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(fail=True),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(app) as client:
|
||||
asked = await asyncio.wait_for(
|
||||
client.post("/api/refine/questions", json={"draft": "q"}), timeout=5.0
|
||||
)
|
||||
assert asked.status_code == 503
|
||||
composed = await asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/refine/compose", json={"draft": "q", "answers": ["a"]}, headers=_HEADERS
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert composed.status_code == 503
|
||||
|
||||
|
||||
async def test_refine_requires_csrf_header_when_authed(tmp_path: Path) -> None:
|
||||
"""Refine POSTs are mutations: the CSRF header applies (T-3.6)."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
# register + login (both with the header)
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "a@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login", json={"email": "a@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
no_header = await client.post("/api/refine/questions", json={"draft": "q"})
|
||||
assert no_header.status_code == 403
|
||||
with_header = await client.post("/api/refine/questions", json={"draft": "q"}, headers=_HEADERS)
|
||||
assert with_header.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- profile
|
||||
|
||||
|
||||
async def test_profile_round_trip(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "first@example.org", "password": "long-enough-pass", "display_name": "First"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "first@example.org", "password": "long-enough-pass"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
got = await client.get("/api/profile")
|
||||
assert got.status_code == 200
|
||||
assert got.json() == {
|
||||
"user_id": got.json()["user_id"],
|
||||
"display_name": "First",
|
||||
"email": "first@example.org",
|
||||
}
|
||||
put = await client.put(
|
||||
"/api/profile", json={"display_name": "Renamed", "email": "new@example.org"}, headers=_HEADERS
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["display_name"] == "Renamed"
|
||||
assert put.json()["email"] == "new@example.org"
|
||||
again = await client.get("/api/profile")
|
||||
assert again.json() == put.json()
|
||||
|
||||
|
||||
async def test_profile_email_unique_across_users(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await admin.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
# admin tries to steal the second user's email
|
||||
taken = await admin.put(
|
||||
"/api/profile", json={"email": "second@example.org"}, headers=_HEADERS
|
||||
)
|
||||
assert taken.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------- cross-user isolation (authed)
|
||||
|
||||
|
||||
async def test_cross_user_404_on_run_scoped_routes(tmp_path: Path) -> None:
|
||||
"""Owner-scoped disk reads: user 2 requesting user 1's run gets a
|
||||
404 (no existence leak) — the owner dir simply doesn't exist under
|
||||
user 2's storage view."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=_RefineModel(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await admin.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
admin_id = (await admin.get("/api/auth/me")).json()["user_id"]
|
||||
_seed_run(
|
||||
tmp_path,
|
||||
"runaaaa1",
|
||||
state="DONE",
|
||||
report_markdown="# Mine\n\n[p-1]\n",
|
||||
report_json=_REPORT_JSON,
|
||||
evidence_json=_EVIDENCE_JSON,
|
||||
ledger_json=_LEDGER_JSON,
|
||||
owner=admin_id,
|
||||
)
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login", json={"email": "second@example.org", "password": "long-enough-pass"}, headers=_HEADERS
|
||||
)
|
||||
assert (await second.get("/api/runs/runaaaa1/report")).status_code == 404
|
||||
assert (await second.get("/api/runs/runaaaa1/ledger")).status_code == 404
|
||||
# The owner-scoped collections are simply empty for user 2.
|
||||
assert (await second.get("/api/runs")).json() == []
|
||||
assert (await second.get("/api/library")).json() == {"folders": [], "reports": []}
|
||||
assert (await second.get("/api/tasks")).json() == []
|
||||
assert (await second.get("/api/suggestions")).json() == []
|
||||
|
||||
|
||||
# ------------------------------------- P-8 full closure: fresh app, same root
|
||||
|
||||
|
||||
async def test_full_run_over_http_writes_and_serves_artifacts(tmp_path: Path) -> None:
|
||||
"""End-to-end marker contract through the API: a run driven to DONE
|
||||
persists report.json/evidence.json (T-3.1), and the reworked report
|
||||
route serves normalized [^pN] bodies while report.md (via the
|
||||
markdown field) keeps raw [p-N]."""
|
||||
app = _fake_app(
|
||||
tmp_path,
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
],
|
||||
)
|
||||
async with await _client(app) 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"]
|
||||
|
||||
for _ in range(400): # await AWAITING_APPROVAL
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await client.post(f"/api/runs/{run_id}/approve")
|
||||
for _ in range(400): # await DONE
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert status == "done"
|
||||
|
||||
report = (await client.get(f"/api/runs/{run_id}/report")).json()
|
||||
assert report["report"] is not None
|
||||
assert report["evidence"] is not None
|
||||
# C3-10 through the API: bodies normalized, markdown raw.
|
||||
joined = "\n".join(str(s["body"]) for s in report["report"]["sections"])
|
||||
assert "[^p1]" in joined or "[^p2]" in joined
|
||||
assert "[p-1]" not in joined
|
||||
assert "[p-1]" in report["markdown"]
|
||||
# citations + evidence ids are pN-form with real quotes.
|
||||
for citation in report["report"]["citations"]:
|
||||
assert citation["passage_id"].startswith("p")
|
||||
assert "-" not in citation["passage_id"]
|
||||
assert citation["quote"]
|
||||
passages = report["evidence"]["passages"]
|
||||
assert passages, "evidence must carry the full stored passages"
|
||||
assert all(len(str(p["text"])) >= len(str(p["quote"])) for p in passages)
|
||||
|
||||
# The disk read-models see the run too (library/tasks derive).
|
||||
library = (await client.get("/api/library")).json()
|
||||
assert [r["runId"] for r in library["reports"]] == [run_id]
|
||||
tasks = (await client.get("/api/tasks")).json()
|
||||
assert all(item["status"] == "done" for item in tasks)
|
||||
|
||||
|
||||
async def test_fresh_app_over_existing_data_root(tmp_path: Path) -> None:
|
||||
"""P-8 full closure: a SECOND create_app over the same data root —
|
||||
zero in-memory actors/registry — every read route still correct.
|
||||
(The registry-independence proof; the first app's actor state is
|
||||
unreachable by construction here.)"""
|
||||
_seed_tree(tmp_path)
|
||||
first = _fake_app(tmp_path)
|
||||
del first # the first app instance is discarded; nothing carries over
|
||||
|
||||
second = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=[ChatResult(content=_PLAN, model="p")]),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with await _client(second) as client:
|
||||
# report: full payload, disk-read
|
||||
report = (await client.get("/api/runs/runaaaa1/report")).json()
|
||||
assert report["report"] == _REPORT_JSON
|
||||
assert report["evidence"] == _EVIDENCE_JSON
|
||||
assert "[p-1]" in report["markdown"]
|
||||
# pre-artifact fallback survives the restart too
|
||||
legacy = (await client.get("/api/runs/runbbbb1/report")).json()
|
||||
assert legacy["report"] is None and legacy["markdown"]
|
||||
# ledger
|
||||
assert (await client.get("/api/runs/runaaaa1/ledger")).json() == _LEDGER_JSON
|
||||
# run list: swept CANCELLED is cancelled, not open
|
||||
statuses = {run["id"]: run["status"] for run in (await client.get("/api/runs")).json()}
|
||||
assert statuses["runcccc1"] == "cancelled"
|
||||
# library + connections + graph + tasks + suggestions
|
||||
library = (await client.get("/api/library")).json()
|
||||
assert [r["runId"] for r in library["reports"]] == ["runaaaa1", "runbbbb1"]
|
||||
assert (await client.get("/api/library/connections")).status_code == 200
|
||||
assert (await client.get("/api/graph")).status_code == 200
|
||||
tasks = {item["id"]: item for item in (await client.get("/api/tasks")).json()}
|
||||
assert tasks["runcccc1#0"]["status"] == "open"
|
||||
assert (await client.get("/api/suggestions")).status_code == 200
|
||||
# profile (dev harness GET works with zero stored runs)
|
||||
assert (await client.get("/api/profile")).status_code == 200
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Read-model unit tests (T-3.2, D-114/D-115 — REQ-76..79).
|
||||
|
||||
A fixture disk tree — two users; DONE, CANCELLED, FAILED runs; a
|
||||
dotfile and a .tmp leftover — asserting categorization, sorting,
|
||||
shared-source/topic edges, tasks answered-iff DONE, suggestions
|
||||
(never synthetic, empty for fresh users), dotfile/tmp skip, and
|
||||
cross-user isolation at the read-model level.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.api.read_models import (
|
||||
categorize,
|
||||
connections,
|
||||
graph,
|
||||
library,
|
||||
list_runs,
|
||||
suggestions,
|
||||
tasks,
|
||||
topic_tokens,
|
||||
)
|
||||
|
||||
_U1 = "aaaa1111"
|
||||
_U2 = "bbbb2222"
|
||||
|
||||
_PLAN_A = {
|
||||
"sub_questions": ["battery energy density gains", "solid-state pilot production"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_B = {
|
||||
"sub_questions": ["battery safety testing standards"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_CANCELLED = {
|
||||
"sub_questions": ["abandoned open question"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
_PLAN_FAILED = {"sub_questions": ["failed run question"], "source_universe": ["web"], "outline": []}
|
||||
_PLAN_INFLIGHT = {"sub_questions": ["still running question"], "source_universe": ["web"], "outline": []}
|
||||
|
||||
_REPORT_A = {
|
||||
"v": 1,
|
||||
"run_id": "runaaaa1",
|
||||
"owner_id": _U1,
|
||||
"title": "Battery Advances",
|
||||
"question": "What are the latest advances in solid-state batteries?",
|
||||
"methodology_slug": "deep-report",
|
||||
"generated_at": "2026-09-20T14:32:00Z",
|
||||
"sections": [{"heading": "Report", "body": "Claim [^p1]."}],
|
||||
"citations": [
|
||||
{"passage_id": "p1", "url": "https://example.org/x", "title": "X", "quote": "q", "health": "live", "authority_tier": "reference"}
|
||||
],
|
||||
}
|
||||
_LEDGER_A = {
|
||||
"run_id": "runaaaa1",
|
||||
"generated_at": "2026-09-20T14:32:00Z",
|
||||
"entries": [
|
||||
{"url": "https://example.org/x", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-1"], "claims_supported": 1, "health": "live", "authority_tier": "reference"},
|
||||
{"url": "https://example.org/y", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "live", "authority_tier": "general"},
|
||||
],
|
||||
"checklist": {},
|
||||
}
|
||||
|
||||
_LEDGER_B = {
|
||||
"run_id": "runbbbb1",
|
||||
"generated_at": "2026-09-22T09:14:00Z",
|
||||
"entries": [
|
||||
{"url": "https://example.org/x", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-1"], "claims_supported": 2, "health": "live", "authority_tier": "reference"},
|
||||
{"url": "https://example.org/z", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "stale", "authority_tier": "general"},
|
||||
],
|
||||
"checklist": {},
|
||||
}
|
||||
|
||||
|
||||
def _state(
|
||||
run_id: str,
|
||||
question: str,
|
||||
slug: str,
|
||||
state: str,
|
||||
plan: dict | None,
|
||||
created_at: str,
|
||||
) -> dict:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"question": question,
|
||||
"context": "",
|
||||
"methodology_slug": slug,
|
||||
"state": state,
|
||||
"owner_id": _U1,
|
||||
"plan": plan,
|
||||
"depth_cursor": 0,
|
||||
"depth_target": 1,
|
||||
"counters": {},
|
||||
"probe": None,
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _write_run(
|
||||
base: Path,
|
||||
owner: str,
|
||||
run_id: str,
|
||||
state: dict,
|
||||
report: dict | None = None,
|
||||
ledger: dict | None = None,
|
||||
) -> None:
|
||||
run_dir = base / "runs" / owner / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "state.json").write_text(json.dumps(state), encoding="utf-8")
|
||||
if report is not None:
|
||||
(run_dir / "report.json").write_text(json.dumps(report), encoding="utf-8")
|
||||
if ledger is not None:
|
||||
(run_dir / "ledger.json").write_text(json.dumps(ledger), encoding="utf-8")
|
||||
|
||||
|
||||
def _tree(tmp_path: Path) -> Path:
|
||||
"""The fixture tree: user 1 has DONE A (deep-report, battery), DONE
|
||||
B (rapid-answer, battery safety), a CANCELLED run, and a FAILED
|
||||
run; user 2 has one DONE run invisible to user 1."""
|
||||
base = tmp_path
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runaaaa1",
|
||||
_state(
|
||||
"runaaaa1",
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
"deep-report",
|
||||
"DONE",
|
||||
_PLAN_A,
|
||||
"2026-09-20T14:32:00Z",
|
||||
),
|
||||
report=_REPORT_A,
|
||||
ledger=_LEDGER_A,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runbbbb1",
|
||||
_state(
|
||||
"runbbbb1",
|
||||
"How do battery safety standards evolve?",
|
||||
"rapid-answer",
|
||||
"DONE",
|
||||
_PLAN_B,
|
||||
"2026-09-22T09:14:00Z",
|
||||
),
|
||||
ledger=_LEDGER_B,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runcanc1",
|
||||
_state(
|
||||
"runcanc1",
|
||||
"abandoned mid-flight",
|
||||
"deep-report",
|
||||
"CANCELLED",
|
||||
_PLAN_CANCELLED,
|
||||
"2026-09-21T10:00:00Z",
|
||||
),
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runfail1",
|
||||
_state(
|
||||
"runfail1",
|
||||
"it broke",
|
||||
"rapid-answer",
|
||||
"FAILED",
|
||||
_PLAN_FAILED,
|
||||
"2026-09-23T10:00:00Z",
|
||||
),
|
||||
)
|
||||
# Defensive-scan fodder (D-115): dotfile + tmp leftovers + junk.
|
||||
(base / "runs" / _U1 / ".hidden").mkdir(parents=True)
|
||||
(base / "runs" / _U1 / "runbbbb1.tmp").mkdir(parents=True)
|
||||
(base / "runs" / _U1 / "orphan001").mkdir(parents=True) # no state.json: skipped
|
||||
# An in-flight run (raw, pre-sweep — the read-model sees the state
|
||||
# as written; the sweep happens at create_app in the route layer).
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runinflg1",
|
||||
_state(
|
||||
"runinflg1",
|
||||
"still running",
|
||||
"deep-report",
|
||||
"SEARCHING",
|
||||
_PLAN_INFLIGHT,
|
||||
"2026-09-25T08:00:00Z",
|
||||
),
|
||||
)
|
||||
# The other user's run: invisible at every read-model level.
|
||||
_write_run(
|
||||
base,
|
||||
_U2,
|
||||
"runcccc1",
|
||||
_state(
|
||||
"runcccc1",
|
||||
"user two's private research",
|
||||
"deep-report",
|
||||
"DONE",
|
||||
_PLAN_B,
|
||||
"2026-09-24T10:00:00Z",
|
||||
),
|
||||
ledger=_LEDGER_A,
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scanning
|
||||
|
||||
|
||||
def test_list_runs_scans_owner_partition_only(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = [record.run_id for record in list_runs(base, _U1)]
|
||||
# CANCELLED + FAILED + in-flight included; dotfile/tmp/orphan
|
||||
# skipped; user 2's run absent.
|
||||
assert set(ids) == {"runaaaa1", "runbbbb1", "runcanc1", "runfail1", "runinflg1"}
|
||||
|
||||
|
||||
def test_list_runs_orders_most_recent_first(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = [record.run_id for record in list_runs(base, _U1)]
|
||||
assert ids[0] == "runinflg1" # 2026-09-25
|
||||
assert ids[-1] == "runaaaa1" # 2026-09-20
|
||||
|
||||
|
||||
def test_list_runs_skips_dotfiles_and_tmp(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
ids = {record.run_id for record in list_runs(base, _U1)}
|
||||
assert ".hidden" not in ids
|
||||
assert "runbbbb1.tmp" not in ids
|
||||
|
||||
|
||||
def test_list_runs_empty_for_fresh_user(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
assert list_runs(base, "cccc3333") == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------ categorization
|
||||
|
||||
|
||||
def test_categorize_is_methodology_plus_first_topic_token() -> None:
|
||||
record = list_runs # placeholder to keep import honest
|
||||
del record
|
||||
from researchx.api.read_models import RunDiskRecord
|
||||
|
||||
run = RunDiskRecord(
|
||||
run_id="runaaaa1",
|
||||
state={
|
||||
"run_id": "runaaaa1",
|
||||
"question": "q",
|
||||
"methodology_slug": "deep-report",
|
||||
"state": "DONE",
|
||||
"plan": _PLAN_A,
|
||||
"created_at": "2026-09-20T14:32:00Z",
|
||||
},
|
||||
)
|
||||
assert categorize(run) == "deep-report-battery"
|
||||
|
||||
|
||||
def test_topic_tokens_skip_stopwords_and_dedupe() -> None:
|
||||
from researchx.api.read_models import RunDiskRecord
|
||||
|
||||
run = RunDiskRecord(
|
||||
run_id="r",
|
||||
state={
|
||||
"run_id": "r",
|
||||
"question": "q",
|
||||
"methodology_slug": "rapid-answer",
|
||||
"state": "DONE",
|
||||
"plan": {"sub_questions": ["The what of battery battery gains"]},
|
||||
"created_at": "2026-09-20T14:32:00Z",
|
||||
},
|
||||
)
|
||||
assert topic_tokens(run) == ["battery", "gains"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ library
|
||||
|
||||
|
||||
def test_library_folders_and_summaries(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, _U1)
|
||||
# Folders from the two DONE runs' categories, sorted by id.
|
||||
assert [folder["id"] for folder in payload["folders"]] == [
|
||||
"deep-report-battery",
|
||||
"rapid-answer-battery",
|
||||
]
|
||||
assert payload["folders"][0]["name"] == "Deep Report: Battery"
|
||||
# Only DONE runs are filed (cancelled/failed produce nothing).
|
||||
assert {report["runId"] for report in payload["reports"]} == {"runaaaa1", "runbbbb1"}
|
||||
first = next(r for r in payload["reports"] if r["runId"] == "runaaaa1")
|
||||
assert first["id"] == "rep-runaaaa1"
|
||||
assert first["title"] == "Battery Advances"
|
||||
assert first["methodologySlug"] == "deep-report"
|
||||
assert first["category"] == "deep-report-battery"
|
||||
assert first["generatedAt"] == "2026-09-20T14:32:00Z"
|
||||
assert first["citationCount"] == 1
|
||||
# Pre-artifact DONE run: falls back to state created_at + methodology title.
|
||||
second = next(r for r in payload["reports"] if r["runId"] == "runbbbb1")
|
||||
assert second["generatedAt"] == "2026-09-22T09:14:00Z"
|
||||
assert second["title"] == "Rapid Answer — How do battery safety standards evolve?"
|
||||
|
||||
|
||||
def test_library_sort_recent_and_title(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
recent = [r["runId"] for r in library(base, _U1, sort="recent")["reports"]]
|
||||
title = [r["runId"] for r in library(base, _U1, sort="title")["reports"]]
|
||||
# recent: newest first (created_at desc).
|
||||
assert recent == ["runbbbb1", "runaaaa1"]
|
||||
# title: "Battery Advances" < "Rapid Answer — ..." A-Z.
|
||||
assert title == ["runaaaa1", "runbbbb1"]
|
||||
# unknown sort key falls back to recent.
|
||||
assert library(base, _U1, sort="bogus")["reports"][0]["runId"] == "runbbbb1"
|
||||
|
||||
|
||||
def test_library_fresh_user_is_empty(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, "cccc3333")
|
||||
assert payload == {"folders": [], "reports": []}
|
||||
|
||||
|
||||
def test_library_cross_user_isolation(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = library(base, _U1)
|
||||
assert all(report["runId"] != "runcccc1" for report in payload["reports"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- connections
|
||||
|
||||
|
||||
def test_connections_shared_source_edge(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
edges = connections(base, _U1)
|
||||
shared = [e for e in edges if e["kind"] == "shared-sources"]
|
||||
# runaaaa1 + runbbbb1 share https://example.org/x (>=1 URL overlap).
|
||||
assert shared == [
|
||||
{
|
||||
"fromReportId": "rep-runaaaa1",
|
||||
"toReportId": "rep-runbbbb1",
|
||||
"kind": "shared-sources",
|
||||
"label": "1 shared source",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_connections_related_topic_edge_when_no_shared_urls(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
# Strip the shared URL: the two DONE runs then connect only via
|
||||
# category (same methodology + first topic token) — no, they have
|
||||
# DIFFERENT methodologies. Instead verify the pairing rule: with no
|
||||
# shared URL and differing categories there is NO edge.
|
||||
other = base / "runs" / _U1 / "runbbbb1" / "ledger.json"
|
||||
payload = json.loads(other.read_text(encoding="utf-8"))
|
||||
payload["entries"] = [e for e in payload["entries"] if e["url"] != "https://example.org/x"]
|
||||
other.write_text(json.dumps(payload), encoding="utf-8")
|
||||
edges = connections(base, _U1)
|
||||
# deep-report-battery vs rapid-answer-battery: different methodology
|
||||
# folders -> not related-topic, and no shared source -> no edge.
|
||||
assert edges == []
|
||||
|
||||
|
||||
def test_connections_related_topic_edge_on_same_category(tmp_path: Path) -> None:
|
||||
base = tmp_path / "iso"
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runx0011",
|
||||
_state("runx0011", "battery density", "deep-report", "DONE", _PLAN_A, "2026-09-20T10:00:00Z"),
|
||||
ledger={"entries": [{"url": "https://example.org/x"}], "checklist": {}},
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
_U1,
|
||||
"runx0022",
|
||||
_state("runx0022", "battery pilots", "deep-report", "DONE", _PLAN_A, "2026-09-21T10:00:00Z"),
|
||||
ledger={"entries": [{"url": "https://example.org/q"}], "checklist": {}},
|
||||
)
|
||||
edges = connections(base, _U1)
|
||||
assert edges == [
|
||||
{
|
||||
"fromReportId": "rep-runx0011",
|
||||
"toReportId": "rep-runx0022",
|
||||
"kind": "related-topic",
|
||||
"label": "Adjacent topic",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_connections_cross_user_isolation(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
edges = connections(base, _U1)
|
||||
for edge in edges:
|
||||
assert "runcccc1" not in (edge["fromReportId"], edge["toReportId"])
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- graph
|
||||
|
||||
|
||||
def test_graph_nodes_and_edges(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = graph(base, _U1)
|
||||
node_ids = {node["id"] for node in payload["nodes"]}
|
||||
assert node_ids == {"runaaaa1", "runbbbb1"} # DONE runs only
|
||||
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
|
||||
assert node["topic"] == "Battery Advances"
|
||||
assert set(node["linkedSources"]) == {"https://example.org/x", "https://example.org/y"}
|
||||
assert node["findings"] == ["Battery Advances"]
|
||||
assert payload["edges"] == [
|
||||
{"fromId": "runaaaa1", "toId": "runbbbb1", "sharedSourceCount": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_graph_fresh_user_is_empty(tmp_path: Path) -> None:
|
||||
assert graph(_tree(tmp_path), "cccc3333") == {"nodes": [], "edges": []}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- tasks
|
||||
|
||||
|
||||
def test_tasks_answered_iff_done(tmp_path: Path) -> None:
|
||||
"""D-114: a sub-question is done IFF its run reached DONE; the
|
||||
post-sweep CANCELLED run's questions stay open."""
|
||||
base = _tree(tmp_path)
|
||||
items = tasks(base, _U1)
|
||||
by_id = {item["id"]: item for item in items}
|
||||
# DONE runs -> done items with <run_id>#<idx> ids.
|
||||
assert by_id["runaaaa1#0"] == {
|
||||
"id": "runaaaa1#0",
|
||||
"question": "battery energy density gains",
|
||||
"sourceRunId": "runaaaa1",
|
||||
"status": "done",
|
||||
}
|
||||
assert by_id["runbbbb1#0"]["status"] == "done"
|
||||
# CANCELLED + FAILED + in-flight runs -> open items.
|
||||
assert by_id["runcanc1#0"]["status"] == "open"
|
||||
assert by_id["runcanc1#0"]["question"] == "abandoned open question"
|
||||
assert by_id["runfail1#0"]["status"] == "open"
|
||||
assert by_id["runinflg1#0"]["status"] == "open"
|
||||
# user 2's runs absent.
|
||||
assert all("runcccc1" not in item["id"] for item in items)
|
||||
|
||||
|
||||
def test_tasks_cross_user_isolation(tmp_path: Path) -> None:
|
||||
items = tasks(_tree(tmp_path), "cccc3333")
|
||||
assert items == []
|
||||
|
||||
|
||||
# -------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
def test_suggestions_are_open_items_joined_with_titles(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
payload = suggestions(base, _U1)
|
||||
# Open items from the CANCELLED + FAILED + in-flight runs, most
|
||||
# recent run first, attributed to their source run titles; nothing
|
||||
# synthetic, nothing from DONE runs.
|
||||
assert payload == [
|
||||
{
|
||||
"id": "sug-runinflg1#0",
|
||||
"question": "still running question",
|
||||
"sourceRunId": "runinflg1",
|
||||
"sourceReportTitle": "Deep Report — still running",
|
||||
},
|
||||
{
|
||||
"id": "sug-runfail1#0",
|
||||
"question": "failed run question",
|
||||
"sourceRunId": "runfail1",
|
||||
"sourceReportTitle": "Rapid Answer — it broke",
|
||||
},
|
||||
{
|
||||
"id": "sug-runcanc1#0",
|
||||
"question": "abandoned open question",
|
||||
"sourceRunId": "runcanc1",
|
||||
"sourceReportTitle": "Deep Report — abandoned mid-flight",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_suggestions_fresh_user_empty_never_synthetic(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
assert suggestions(base, "cccc3333") == []
|
||||
|
||||
|
||||
def test_suggestions_exclude_done_runs(tmp_path: Path) -> None:
|
||||
base = _tree(tmp_path)
|
||||
questions = [s["question"] for s in suggestions(base, _U1)]
|
||||
assert "battery energy density gains" not in questions # DONE run
|
||||
|
||||
|
||||
# ------------------------------------------------- defensive scanning extras
|
||||
|
||||
|
||||
def test_list_runs_skips_corrupt_state_json(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
run_dir = base / "runs" / _U1 / "corrupt01"
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "state.json").write_text("{not json", encoding="utf-8")
|
||||
assert list_runs(base, _U1) == []
|
||||
+374
-1
@@ -299,7 +299,7 @@
|
||||
},
|
||||
"put": {
|
||||
"summary": "Put Settings",
|
||||
"description": "Persist a partial settings update (REQ-72); returns the\nupdated settings.",
|
||||
"description": "Persist a partial settings update (REQ-72); returns the\nupdated settings. An explicit null field is treated as absent\n(partial-merge semantics \u2014 the store refuses null-valued\nfields, so a null can never poison the on-disk payload).",
|
||||
"operationId": "put_settings_api_config_settings_put",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -414,6 +414,27 @@
|
||||
}
|
||||
},
|
||||
"/api/runs": {
|
||||
"get": {
|
||||
"summary": "List Runs Route",
|
||||
"description": "All of this owner's runs from disk state files, most recent\nfirst \u2014 the registry-independent run list (post-sweep\nCANCELLED runs surface cancelled, NOT open).",
|
||||
"operationId": "list_runs_route_api_runs_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunSummary"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Response List Runs Route Api Runs Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create Run",
|
||||
"operationId": "create_run_api_runs_post",
|
||||
@@ -703,6 +724,7 @@
|
||||
"/api/runs/{run_id}/report": {
|
||||
"get": {
|
||||
"summary": "Run Report",
|
||||
"description": "Disk-read, registry-independent (P-8 full closure, T-3.3):\nthe run directory under THIS owner is the existence check \u2014 no\nRunRegistry involvement, correct after a restart with zero\nin-memory actors. Pre-artifact DONE runs (and any run whose\nreport.json was not yet written) fall back to markdown-only\nwith null report/evidence \u2014 never a 500 (D-106: old artifacts\ndisposable).",
|
||||
"operationId": "run_report_api_runs__run_id__report_get",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -744,6 +766,7 @@
|
||||
"/api/runs/{run_id}/ledger": {
|
||||
"get": {
|
||||
"summary": "Run Ledger",
|
||||
"description": "Disk-read, registry-independent (T-3.3): same owner-dir\nexistence check as the report route.",
|
||||
"operationId": "run_ledger_api_runs__run_id__ledger_get",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -781,6 +804,286 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library": {
|
||||
"get": {
|
||||
"summary": "Library Route",
|
||||
"operationId": "library_route_api_library_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "recent",
|
||||
"title": "Sort"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"title": "Response Library Route Api Library Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/connections": {
|
||||
"get": {
|
||||
"summary": "Connections Route",
|
||||
"operationId": "connections_route_api_library_connections_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Response Connections Route Api Library Connections Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/graph": {
|
||||
"get": {
|
||||
"summary": "Graph Route",
|
||||
"operationId": "graph_route_api_graph_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Response Graph Route Api Graph Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tasks": {
|
||||
"get": {
|
||||
"summary": "Tasks Route",
|
||||
"operationId": "tasks_route_api_tasks_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Response Tasks Route Api Tasks Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions": {
|
||||
"get": {
|
||||
"summary": "Suggestions Route",
|
||||
"operationId": "suggestions_route_api_suggestions_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Response Suggestions Route Api Suggestions Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/refine/questions": {
|
||||
"post": {
|
||||
"summary": "Refine Questions",
|
||||
"operationId": "refine_questions_api_refine_questions_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefineDraftBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Response Refine Questions Api Refine Questions Post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/refine/compose": {
|
||||
"post": {
|
||||
"summary": "Refine Compose",
|
||||
"operationId": "refine_compose_api_refine_compose_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefineComposeBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Response Refine Compose Api Refine Compose Post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/profile": {
|
||||
"get": {
|
||||
"summary": "Get Profile",
|
||||
"operationId": "get_profile_api_profile_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Response Get Profile Api Profile Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Put Profile",
|
||||
"description": "Update display name and/or email (no deletion, REQ-81).\nThe email stays unique (casefolded) across other users.",
|
||||
"operationId": "put_profile_api_profile_put",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProfileBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Response Put Profile Api Profile Put"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1064,6 +1367,76 @@
|
||||
"title": "ModelKeyBody",
|
||||
"description": "PUT /api/config/settings/model-key body \u2014 the raw key, accepted,\nnever echoed (REQ-73)."
|
||||
},
|
||||
"ProfileBody": {
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 120,
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Display Name"
|
||||
},
|
||||
"email": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 254,
|
||||
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Email"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ProfileBody"
|
||||
},
|
||||
"RefineComposeBody": {
|
||||
"properties": {
|
||||
"draft": {
|
||||
"type": "string",
|
||||
"maxLength": 2000,
|
||||
"minLength": 1,
|
||||
"title": "Draft"
|
||||
},
|
||||
"answers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"title": "Answers"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"title": "RefineComposeBody"
|
||||
},
|
||||
"RefineDraftBody": {
|
||||
"properties": {
|
||||
"draft": {
|
||||
"type": "string",
|
||||
"maxLength": 2000,
|
||||
"minLength": 1,
|
||||
"title": "Draft"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"title": "RefineDraftBody"
|
||||
},
|
||||
"RegisterBody": {
|
||||
"properties": {
|
||||
"email": {
|
||||
|
||||
@@ -19,6 +19,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
liveMode,
|
||||
type InboxItem,
|
||||
type ProjectGraph,
|
||||
type ReportConnection,
|
||||
@@ -424,10 +425,19 @@ function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
|
||||
/* Task inbox panel (REQ-60) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* G-109 (task-inbox honesty): in LIVE mode the status is DERIVED
|
||||
* server-side (answered-iff the source run reached DONE — D-114), so
|
||||
* the checkbox is display-honest — disabled and annotated, never a
|
||||
* free toggle that silently reverts on the next fetch. Persisted
|
||||
* per-question check state is v0.4+ (REQ-45 backlog). MOCK mode keeps
|
||||
* the free toggle byte-for-byte (the dev fixture owns its state).
|
||||
*/
|
||||
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (liveMode) return; // display-honest: live status is derived, not local
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id
|
||||
@@ -452,9 +462,16 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Open questions worth following up · {openCount} open
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-faint">
|
||||
Toggles are local-only — the inbox persists in v0.2.
|
||||
</p>
|
||||
{liveMode ? (
|
||||
<p className="mt-1 text-caption text-ink-faint" data-testid="inbox-derived-note">
|
||||
A question is answered when its research run completes — status
|
||||
follows the run.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-caption text-ink-faint">
|
||||
Toggles are local-only — the inbox persists in v0.2.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{items.map((item) => (
|
||||
@@ -463,7 +480,15 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
|
||||
type="checkbox"
|
||||
checked={item.status === "done"}
|
||||
onChange={() => toggle(item.id)}
|
||||
// G-109: derived status in live mode — disabled, never a
|
||||
// silent revert; mock keeps the free toggle.
|
||||
disabled={liveMode}
|
||||
aria-label={item.question}
|
||||
title={
|
||||
liveMode
|
||||
? "Follows its research run — answered when the run completes"
|
||||
: undefined
|
||||
}
|
||||
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Live-mode component tests (T-3.9, G-109; REQ-68, REQ-80).
|
||||
*
|
||||
* The previously-delegated surfaces driving REAL components in live
|
||||
* mode — no boundary mocks, the ApiClient surface only:
|
||||
* - G-109: the task-inbox checkbox is DISPLAY-HONEST in live mode —
|
||||
* derived status (open-iff run not DONE), disabled + annotated;
|
||||
* a click never flips it; the mock keeps the free toggle (pinned
|
||||
* by the unchanged library-view suite).
|
||||
* - Refine honesty: a 503 from the real planner endpoint surfaces
|
||||
* the error state — never a hang, never synthetic questions.
|
||||
*
|
||||
* House pattern (user-menu-session suite): the module's `api` +
|
||||
* `liveMode` exports are swapped per case through module
|
||||
* interception; the components themselves are UNTOUCHED.
|
||||
*/
|
||||
|
||||
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 { AskView } from "@/components/ask-view";
|
||||
import { LibraryView } from "@/components/library-view";
|
||||
import type {
|
||||
InboxItem,
|
||||
ProjectGraph,
|
||||
ReportConnection,
|
||||
ReportLibrary,
|
||||
} from "@/lib/api/client";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
liveMode: false,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
get liveMode() {
|
||||
return state.liveMode;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live-mode boundary stub — payloads verbatim from the read-models */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LIVE_LIBRARY: ReportLibrary = {
|
||||
folders: [
|
||||
{
|
||||
id: "deep-report-battery",
|
||||
name: "Deep Report: Battery",
|
||||
description: "Auto-filed from deep report battery research.",
|
||||
},
|
||||
],
|
||||
reports: [
|
||||
{
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LIVE_CONNECTIONS: ReportConnection[] = [
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
];
|
||||
|
||||
const LIVE_GRAPH: ProjectGraph = {
|
||||
nodes: [
|
||||
{
|
||||
id: "runaaaa1",
|
||||
topic: "Battery Advances",
|
||||
linkedSources: ["https://example.org/x"],
|
||||
findings: ["Battery Advances"],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const LIVE_INBOX: InboxItem[] = [
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
];
|
||||
|
||||
/** A live-shaped ApiClient surface for the library panels. */
|
||||
function liveLibraryApi(): Record<string, unknown> {
|
||||
return {
|
||||
getReportLibrary: vi.fn().mockResolvedValue(LIVE_LIBRARY),
|
||||
getReportConnections: vi.fn().mockResolvedValue(LIVE_CONNECTIONS),
|
||||
getProjectGraph: vi.fn().mockResolvedValue(LIVE_GRAPH),
|
||||
getTaskInbox: vi.fn().mockResolvedValue(LIVE_INBOX),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* G-109 — the task-inbox checkbox is display-honest in live mode */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("LibraryView task inbox — live-mode honesty (G-109)", () => {
|
||||
it("renders every live surface: folders, connections, graph, tasks", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
render(<LibraryView />);
|
||||
|
||||
// Library feeds from the LIVE read-model payload (T-3.9).
|
||||
const card = await screen.findByRole("link", { name: /Battery Advances/i });
|
||||
expect(card).toHaveAttribute("href", "/run/runaaaa1/report");
|
||||
expect(screen.getByRole("button", { name: /Deep Report: Battery/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 citation/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Shared sources — 2 shared sources/i),
|
||||
).toBeInTheDocument();
|
||||
// The graph panel renders the run as a node (topic = report title).
|
||||
const graph = await screen.findByRole("region", { name: "Project graph" });
|
||||
expect(graph.textContent).toContain("Battery Advances");
|
||||
});
|
||||
|
||||
it("live checkboxes are DISABLED and the derived-status note shows", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
render(<LibraryView />);
|
||||
|
||||
await screen.findByRole("region", { name: "Task inbox" });
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
const done = await screen.findByRole("checkbox", {
|
||||
name: "An answered question",
|
||||
});
|
||||
// Derived status rendered — open stays open, done stays done.
|
||||
expect(open).not.toBeChecked();
|
||||
expect(done).toBeChecked();
|
||||
// G-109: display-honest — both disabled.
|
||||
expect(open).toBeDisabled();
|
||||
expect(done).toBeDisabled();
|
||||
// Annotated, never silent.
|
||||
expect(
|
||||
screen.getByTestId("inbox-derived-note"),
|
||||
).toHaveTextContent(/answered when its research run completes/i);
|
||||
// The open count reflects the server-derived statuses.
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 1 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a click on a live checkbox never flips the derived status", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<LibraryView />);
|
||||
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
await user.click(open);
|
||||
// Still unchecked — no silent revert because nothing ever changed.
|
||||
expect(open).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 1 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("mock mode keeps the free toggle byte-for-byte (G-109 dev fixture)", async () => {
|
||||
state.api = liveLibraryApi();
|
||||
state.liveMode = false;
|
||||
const user = userEvent.setup();
|
||||
render(<LibraryView />);
|
||||
|
||||
const open = await screen.findByRole("checkbox", {
|
||||
name: "What is the research question?",
|
||||
});
|
||||
expect(open).toBeEnabled();
|
||||
expect(
|
||||
screen.queryByTestId("inbox-derived-note"),
|
||||
).toBeNull();
|
||||
await user.click(open);
|
||||
expect(open).toBeChecked();
|
||||
expect(
|
||||
screen.getByText(/open questions worth following up · 0 open/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Refine — the real exchange with an honest 503 (T-3.6, REQ-80) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("RefineDrawer — live-mode 503 honesty", () => {
|
||||
it("surfaces the honest unavailable state — never a hang, never synthetic", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("live: refine is unavailable — 503")),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Refine question/i }));
|
||||
expect(
|
||||
await screen.findByRole("region", { name: /Refine your question/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The honest error state — the drawer never invents questions.
|
||||
expect(
|
||||
await screen.findByRole("alert"),
|
||||
).toHaveTextContent(/Refine is unavailable right now/i);
|
||||
expect(
|
||||
screen.queryByLabelText(/matters most to you/i),
|
||||
).toBeNull();
|
||||
|
||||
// Keep original closes the drawer with the draft untouched.
|
||||
await user.click(screen.getByRole("button", { name: "Keep original" }));
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Refine your question/i }),
|
||||
).toBeNull();
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
});
|
||||
|
||||
it("drives the REAL live exchange: model questions → model composition", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "q-1",
|
||||
prompt: "Which application domain matters most?",
|
||||
placeholder: "one short answer",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
id: "q-2",
|
||||
prompt: "What decision will this research inform?",
|
||||
placeholder: "one short answer",
|
||||
optional: true,
|
||||
},
|
||||
]),
|
||||
composeRefinedQuestion: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
question: "Solid-state batteries — automotive applications",
|
||||
context: "Feeds a supplier choice.",
|
||||
}),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Refine question/i }));
|
||||
|
||||
const q1 = await screen.findByLabelText(/Which application domain matters most\?/i);
|
||||
await user.type(q1, "automotive applications");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
const q2 = await screen.findByLabelText(/What decision will this research inform\?/i);
|
||||
await user.type(q2, "choosing a supplier");
|
||||
await user.click(screen.getByRole("button", { name: "Compose question" }));
|
||||
|
||||
// The model-composed refinement arrives and fills the form.
|
||||
expect(
|
||||
await screen.findByText("Solid-state batteries — automotive applications"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Use this" }));
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"Solid-state batteries — automotive applications",
|
||||
);
|
||||
expect(screen.getByLabelText(/anything else it should know\?/i)).toHaveValue(
|
||||
"Feeds a supplier choice.",
|
||||
);
|
||||
});
|
||||
|
||||
it("live suggestions render with source attribution and never auto-start", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "sug-runcccc1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runcccc1",
|
||||
sourceReportTitle: "Deep Report — an in-flight run",
|
||||
},
|
||||
]),
|
||||
getRefinementQuestions: vi.fn(),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
const card = await screen.findByRole("button", {
|
||||
name: /What is the research question\?/i,
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/From your research — Deep Report — an in-flight run/i),
|
||||
).toBeInTheDocument();
|
||||
await user.click(card);
|
||||
// Click-fills the box — never auto-starts.
|
||||
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
|
||||
"What is the research question?",
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a fresh user sees the honest empty suggestions state in live mode", async () => {
|
||||
state.api = {
|
||||
getSuggestedQueries: vi.fn().mockResolvedValue([]),
|
||||
getRefinementQuestions: vi.fn(),
|
||||
composeRefinedQuestion: vi.fn(),
|
||||
};
|
||||
state.liveMode = true;
|
||||
render(<AskView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No open questions yet — they collect here as you run research/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Reading Room live parity + honest exports (T-3.10, REQ-82, REQ-59,
|
||||
* REQ-34 — P-12 closure pins).
|
||||
*
|
||||
* The REAL ReadingRoom component rendering a REAL live payload —
|
||||
* the enriched GET /api/runs/{id}/report envelope (report.json +
|
||||
* evidence.json + markdown + ledger) stubbed at fetch level:
|
||||
* - structured sections render with [^pN] markers (zero
|
||||
* report-body.ts changes — C3-10);
|
||||
* - hover/focus on a marker reveals the EXACT stored quoted passage
|
||||
* (parity with the mock Reading Room, REQ-82);
|
||||
* - the ledger panel lists real entries with real quotes;
|
||||
* - exports carry REAL data: markdown from the served markdown,
|
||||
* JSON with real claims_supported/health/tier/quotes — the
|
||||
* fabricated claims_supported:1 and empty quotes are DEAD, and a
|
||||
* test pins their absence (P-12).
|
||||
*
|
||||
* House pattern (user-menu-session suite): the module's `api` export
|
||||
* is swapped per case through a hoisted module interception backed by
|
||||
* a GeneratedApiClient over a stubbed fetch — the components
|
||||
* themselves are UNTOUCHED, and every call still speaks only the
|
||||
* ApiClient surface.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReadingRoom } from "@/components/reading-room";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
api: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
Promise<typeof import("@/lib/api/client")>
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
api: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
const handler = state.api[prop as string];
|
||||
return typeof handler === "function"
|
||||
? (handler as (...args: unknown[]) => unknown).bind(state.api)
|
||||
: handler;
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixture live payload — the DONE-run envelope, verbatim shapes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const RUN_ID = "runaaaa1";
|
||||
|
||||
const FIXTURE_ENVELOPE = {
|
||||
run_id: RUN_ID,
|
||||
markdown:
|
||||
"# Battery Advances\n\nThe verified core finding [p-1]. A second corroborated observation [p-2].\n",
|
||||
report: {
|
||||
v: 1,
|
||||
run_id: RUN_ID,
|
||||
owner_id: "abcd2345",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodology_slug: "deep-report",
|
||||
generated_at: "2026-09-20T10:05:00Z",
|
||||
sections: [
|
||||
{
|
||||
heading: "Research Memo",
|
||||
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
passage_id: "p1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
quote: "The verified core finding.",
|
||||
health: "live",
|
||||
authority_tier: "official",
|
||||
},
|
||||
{
|
||||
passage_id: "p2",
|
||||
url: "https://example.com/y",
|
||||
title: "Quote anchoring and auditability",
|
||||
quote: "Anchoring makes auditability real.",
|
||||
health: "stale",
|
||||
authority_tier: "general",
|
||||
},
|
||||
],
|
||||
},
|
||||
evidence: {
|
||||
v: 1,
|
||||
run_id: RUN_ID,
|
||||
passages: [
|
||||
{
|
||||
passage_id: "p1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
quote: "The verified core finding.",
|
||||
text: "The verified core finding in full, stored at DONE.",
|
||||
},
|
||||
{
|
||||
passage_id: "p2",
|
||||
url: "https://example.com/y",
|
||||
title: "Quote anchoring and auditability",
|
||||
quote: "Anchoring makes auditability real.",
|
||||
text: "Anchoring is the mechanism that makes auditability real.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const FIXTURE_LEDGER = {
|
||||
run_id: RUN_ID,
|
||||
generated_at: "2026-09-20T10:05:00Z",
|
||||
entries: [
|
||||
{
|
||||
url: "https://example.org/x",
|
||||
visited_at: "2026-09-20T10:04:00Z",
|
||||
passages: ["p-1"],
|
||||
claims_supported: 2,
|
||||
health: "live",
|
||||
authority_tier: "official",
|
||||
},
|
||||
{
|
||||
url: "https://example.com/y",
|
||||
visited_at: "2026-09-20T10:04:30Z",
|
||||
passages: ["p-2"],
|
||||
claims_supported: 1,
|
||||
health: "stale",
|
||||
authority_tier: "general",
|
||||
},
|
||||
],
|
||||
checklist: {
|
||||
run_id: RUN_ID,
|
||||
citations_checked: 2,
|
||||
live: 1,
|
||||
stale: 1,
|
||||
dead: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** A fetch stub serving the DONE-run report envelope + ledger. */
|
||||
function liveStackFetch(): typeof globalThis.fetch {
|
||||
const routes: Record<string, unknown> = {
|
||||
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/report`]: FIXTURE_ENVELOPE,
|
||||
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/ledger`]: FIXTURE_LEDGER,
|
||||
};
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
for (const [prefix, body] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** The live client over the stubbed stack — the boundary per case. */
|
||||
function liveApi(): Record<string, unknown> {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
return client as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.api = liveApi();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component parity — the REAL ReadingRoom over the live payload */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ReadingRoom live parity (T-3.10, REQ-82)", () => {
|
||||
it("renders structured sections + [^pN] markers + the real title", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
// The REAL report.json title — not a placeholder.
|
||||
expect(await screen.findByText("Battery Advances")).toBeInTheDocument();
|
||||
// Structured section heading from report.json.
|
||||
expect(screen.getByText("Research Memo")).toBeInTheDocument();
|
||||
// Section body text renders around the parsed [^pN] markers —
|
||||
// both text segments of the memo section are present.
|
||||
const memo = screen.getByText("Research Memo").closest("section");
|
||||
expect(memo?.textContent).toContain("The verified core finding ");
|
||||
expect(memo?.textContent).toContain("A second corroborated observation");
|
||||
// Markers render as citation buttons (marker index from pN).
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /Citation 1:/i }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /Citation 2:/i }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hover/focus reveals the EXACT stored quoted passage (REQ-82 parity)", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
const markers = await screen.findAllByRole("button", { name: /Citation 1:/i });
|
||||
markers[0]?.focus();
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
// The stored passage quote — exactly what the engine anchored.
|
||||
expect(tooltip.textContent).toContain("The verified core finding.");
|
||||
expect(tooltip.textContent).toContain("Verification passes");
|
||||
expect(tooltip.textContent).toContain("example.org");
|
||||
// Tier map: official → 1.
|
||||
expect(tooltip.textContent).toContain("tier 1");
|
||||
});
|
||||
|
||||
it("the ledger panel lists the REAL entries with quotes + health", async () => {
|
||||
render(<ReadingRoom id={RUN_ID} />);
|
||||
|
||||
expect(await screen.findByText("Citation ledger")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("2 sources · every claim traceable to a visited passage"),
|
||||
).toBeInTheDocument();
|
||||
// Real entry titles + real quotes.
|
||||
expect(screen.getByText("Verification passes")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Quote anchoring and auditability"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/“The verified core finding\.”/)).toBeInTheDocument();
|
||||
// Health badges from the real ledger health values.
|
||||
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/stale/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("markdown-only fallback renders honestly for a pre-artifact DONE run", async () => {
|
||||
// Swap in a pre-artifact envelope: markdown only, null artifacts —
|
||||
// with a ledger still served (the run reached DONE and the ledger
|
||||
// predates report.json; the route never 500s — D-106).
|
||||
const preArtifact = new GeneratedApiClient(
|
||||
(async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.includes("/report")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
run_id: "runbbbb1",
|
||||
markdown: "# Old Report\n\nRaw [p-1] markers only.",
|
||||
report: null,
|
||||
evidence: null,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/ledger")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
run_id: "runbbbb1",
|
||||
generated_at: "2026-09-19T10:05:00Z",
|
||||
entries: [],
|
||||
checklist: { run_id: "runbbbb1", citations_checked: 0 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof globalThis.fetch,
|
||||
);
|
||||
state.api = preArtifact as unknown as Record<string, unknown>;
|
||||
|
||||
render(<ReadingRoom id="runbbbb1" />);
|
||||
|
||||
// The served markdown, verbatim — never a 500, never fabricated
|
||||
// sections or citations.
|
||||
expect(await screen.findByText("Report")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Raw \[p-1\] markers only\./i)).toBeInTheDocument();
|
||||
// No citation markers as buttons — there are no citations.
|
||||
expect(screen.queryByRole("button", { name: /Citation/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Honest exports — P-12 closure pins (boundary level) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("exportReport — real data, P-12 fabrication dead (T-3.10, REQ-34)", () => {
|
||||
it("markdown export carries the SERVED markdown + a real-quote citation block", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const markdown = await client.exportReport(RUN_ID, "markdown");
|
||||
expect(markdown.filename).toBe(`researchx-${RUN_ID}.md`);
|
||||
// The served report.md verbatim — raw [p-N] markers intact (C3-10).
|
||||
expect(markdown.content).toContain("# Battery Advances");
|
||||
expect(markdown.content).toContain("The verified core finding [p-1].");
|
||||
// Citations block: real URLs as links + real health/tier.
|
||||
expect(markdown.content).toContain("](https://example.org/x)");
|
||||
expect(markdown.content).toContain("✓ live");
|
||||
expect(markdown.content).toContain("△ stale");
|
||||
// The REAL stored quote appears in the citation block.
|
||||
expect(markdown.content).toContain("The verified core finding.");
|
||||
});
|
||||
|
||||
it("JSON export carries REAL claims_supported/health/tier/quotes per entry", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const json = await client.exportReport(RUN_ID, "json");
|
||||
expect(json.filename).toBe(`researchx-${RUN_ID}.json`);
|
||||
const parsed = JSON.parse(json.content) as {
|
||||
run_id: string;
|
||||
generated_at: string;
|
||||
entries: Array<{
|
||||
url: string;
|
||||
visited_at: string;
|
||||
passages: string[];
|
||||
claims_supported: number;
|
||||
health: string;
|
||||
authority_tier: string;
|
||||
quote: string;
|
||||
}>;
|
||||
checklist: Record<string, unknown>;
|
||||
};
|
||||
expect(parsed.run_id).toBe(RUN_ID);
|
||||
expect(parsed.entries).toHaveLength(2);
|
||||
// Entry 0: the REAL values from ledger.json — claims_supported 2
|
||||
// (NOT the fabricated 1), live health, official tier.
|
||||
expect(parsed.entries[0]?.url).toBe("https://example.org/x");
|
||||
expect(parsed.entries[0]?.claims_supported).toBe(2);
|
||||
expect(parsed.entries[0]?.health).toBe("live");
|
||||
expect(parsed.entries[0]?.authority_tier).toBe("official");
|
||||
// Entry 1 keeps its own real values — the entries are not clones.
|
||||
expect(parsed.entries[1]?.claims_supported).toBe(1);
|
||||
expect(parsed.entries[1]?.health).toBe("stale");
|
||||
// The REAL stored quotes — never empty strings.
|
||||
expect(parsed.entries[0]?.quote).toBe("The verified core finding.");
|
||||
expect(parsed.entries[1]?.quote).toBe("Anchoring makes auditability real.");
|
||||
// The real checklist from the ledger artifact.
|
||||
expect(parsed.checklist).toEqual(FIXTURE_LEDGER.checklist);
|
||||
});
|
||||
|
||||
it("P-12 pin: the fabricated claims_supported:1 and empty quotes are ABSENT", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const json = await client.exportReport(RUN_ID, "json");
|
||||
// Every entry carries its OWN claims count — an all-1s dump would
|
||||
// mean the fabrication is back (entry 0's real count is 2).
|
||||
const claims = JSON.parse(json.content) as {
|
||||
entries: Array<{ claims_supported: number; quote: string }>;
|
||||
};
|
||||
expect(claims.entries.map((e) => e.claims_supported)).toEqual([2, 1]);
|
||||
// No empty quotes anywhere.
|
||||
for (const entry of claims.entries) {
|
||||
expect(entry.quote.length).toBeGreaterThan(0);
|
||||
}
|
||||
// And the export never emits the mock-era placeholder title either.
|
||||
expect(json.content).not.toContain('"Research Report"');
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* getReport/getLedger mappings — the boundary contract */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("getReport/getLedger live mappings (T-3.10)", () => {
|
||||
it("getReport maps report.json → Report with real quotes + the tier map", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const report = await client.getReport(RUN_ID);
|
||||
expect(report.runId).toBe(RUN_ID);
|
||||
expect(report.title).toBe("Battery Advances");
|
||||
expect(report.sections).toEqual([
|
||||
{
|
||||
heading: "Research Memo",
|
||||
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
|
||||
},
|
||||
]);
|
||||
// [^pN] markers survive byte-for-byte — zero report-body.ts changes.
|
||||
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
|
||||
expect(report.citations).toHaveLength(2);
|
||||
// Tier map: official→1, general→3.
|
||||
expect(report.citations[0]).toEqual({
|
||||
id: "c-1",
|
||||
url: "https://example.org/x",
|
||||
title: "Verification passes",
|
||||
site: "example.org",
|
||||
passageId: "p1",
|
||||
quote: "The verified core finding.",
|
||||
verification: "live",
|
||||
authorityTier: 1,
|
||||
});
|
||||
expect(report.citations[1]?.authorityTier).toBe(3);
|
||||
expect(report.citations[1]?.site).toBe("example.com");
|
||||
expect(report.citations[1]?.verification).toBe("stale");
|
||||
});
|
||||
|
||||
it("getLedger maps real entries with pN-normalized ids + stored quotes", async () => {
|
||||
const client = new GeneratedApiClient(liveStackFetch());
|
||||
const ledger = await client.getLedger(RUN_ID);
|
||||
expect(ledger.runId).toBe(RUN_ID);
|
||||
expect(ledger.generatedAt).toBe("2026-09-20T10:05:00Z");
|
||||
expect(ledger.citations).toHaveLength(2);
|
||||
// ledger.json ids are p-N; the Citation passageId is the pN form
|
||||
// the [^pN] markers parse to.
|
||||
expect(ledger.citations[0]?.passageId).toBe("p1");
|
||||
expect(ledger.citations[0]?.quote).toBe("The verified core finding.");
|
||||
expect(ledger.citations[0]?.authorityTier).toBe(1);
|
||||
expect(ledger.citations[1]?.passageId).toBe("p2");
|
||||
expect(ledger.citations[1]?.verification).toBe("stale");
|
||||
});
|
||||
|
||||
it("the report + ledger share one network read per DONE run (memoized envelope)", async () => {
|
||||
let reportCalls = 0;
|
||||
const counting = new GeneratedApiClient(
|
||||
(async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.includes("/report")) {
|
||||
reportCalls += 1;
|
||||
return new Response(JSON.stringify(FIXTURE_ENVELOPE), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/ledger")) {
|
||||
return new Response(JSON.stringify(FIXTURE_LEDGER), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof globalThis.fetch,
|
||||
);
|
||||
await counting.getReport(RUN_ID);
|
||||
await counting.getLedger(RUN_ID);
|
||||
await counting.exportReport(RUN_ID, "markdown");
|
||||
// report route hit exactly ONCE: the envelope is memoized per run
|
||||
// once report.json exists (a terminal run's artifacts are final).
|
||||
expect(reportCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
* Components consume ONLY this interface via the `api` singleton. In
|
||||
* v0.1 the singleton is bound to MockApiClient; in v0.2 phase 3 the
|
||||
* binding swaps to the OpenAPI-generated client with no component
|
||||
* rewrites. This is the ONLY module allowed to import MockApiClient.
|
||||
* rewrites. Since v0.3 P3 (D-112) the live client is standalone —
|
||||
* this boundary module selects the binding, and it is the ONLY module
|
||||
* allowed to import MockApiClient.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -315,8 +317,9 @@ export const liveMode: boolean =
|
||||
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
|
||||
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
|
||||
* switches to the OpenAPI-generated client in live mode — selected by
|
||||
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers
|
||||
* the core research loop only (G-002); other surfaces stay mock-backed.
|
||||
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Since
|
||||
* v0.3 P3 (D-112) the live client is STANDALONE — every surface
|
||||
* live-bound, zero delegation to the mock.
|
||||
*/
|
||||
class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle {
|
||||
async pauseRun(id: string): Promise<ResearchRun> {
|
||||
@@ -333,7 +336,7 @@ function resolveBinding(): ApiClient & RunLifecycle {
|
||||
process.env.RESEARCHX_API_MODE ??
|
||||
"mock";
|
||||
if (mode === "live") {
|
||||
return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle;
|
||||
return new GeneratedApiClient();
|
||||
}
|
||||
return new MockApiClientWithLifecycle();
|
||||
}
|
||||
|
||||
+453
-4
@@ -163,7 +163,9 @@ export interface paths {
|
||||
/**
|
||||
* Put Settings
|
||||
* @description Persist a partial settings update (REQ-72); returns the
|
||||
* updated settings.
|
||||
* updated settings. An explicit null field is treated as absent
|
||||
* (partial-merge semantics — the store refuses null-valued
|
||||
* fields, so a null can never poison the on-disk payload).
|
||||
*/
|
||||
put: operations["put_settings_api_config_settings_put"];
|
||||
post?: never;
|
||||
@@ -223,7 +225,13 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* List Runs Route
|
||||
* @description All of this owner's runs from disk state files, most recent
|
||||
* first — the registry-independent run list (post-sweep
|
||||
* CANCELLED runs surface cancelled, NOT open).
|
||||
*/
|
||||
get: operations["list_runs_route_api_runs_get"];
|
||||
put?: never;
|
||||
/** Create Run */
|
||||
post: operations["create_run_api_runs_post"];
|
||||
@@ -342,7 +350,16 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Run Report */
|
||||
/**
|
||||
* Run Report
|
||||
* @description Disk-read, registry-independent (P-8 full closure, T-3.3):
|
||||
* the run directory under THIS owner is the existence check — no
|
||||
* RunRegistry involvement, correct after a restart with zero
|
||||
* in-memory actors. Pre-artifact DONE runs (and any run whose
|
||||
* report.json was not yet written) fall back to markdown-only
|
||||
* with null report/evidence — never a 500 (D-106: old artifacts
|
||||
* disposable).
|
||||
*/
|
||||
get: operations["run_report_api_runs__run_id__report_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -359,7 +376,11 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Run Ledger */
|
||||
/**
|
||||
* Run Ledger
|
||||
* @description Disk-read, registry-independent (T-3.3): same owner-dir
|
||||
* existence check as the report route.
|
||||
*/
|
||||
get: operations["run_ledger_api_runs__run_id__ledger_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -369,6 +390,147 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/library": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Library Route */
|
||||
get: operations["library_route_api_library_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/library/connections": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Connections Route */
|
||||
get: operations["connections_route_api_library_connections_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/graph": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Graph Route */
|
||||
get: operations["graph_route_api_graph_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Tasks Route */
|
||||
get: operations["tasks_route_api_tasks_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/suggestions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Suggestions Route */
|
||||
get: operations["suggestions_route_api_suggestions_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/refine/questions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Refine Questions */
|
||||
post: operations["refine_questions_api_refine_questions_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/refine/compose": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Refine Compose */
|
||||
post: operations["refine_compose_api_refine_compose_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Profile */
|
||||
get: operations["get_profile_api_profile_get"];
|
||||
/**
|
||||
* Put Profile
|
||||
* @description Update display name and/or email (no deletion, REQ-81).
|
||||
* The email stays unique (casefolded) across other users.
|
||||
*/
|
||||
put: operations["put_profile_api_profile_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -494,6 +656,25 @@ export interface components {
|
||||
/** Key */
|
||||
key: string;
|
||||
};
|
||||
/** ProfileBody */
|
||||
ProfileBody: {
|
||||
/** Display Name */
|
||||
display_name?: string | null;
|
||||
/** Email */
|
||||
email?: string | null;
|
||||
};
|
||||
/** RefineComposeBody */
|
||||
RefineComposeBody: {
|
||||
/** Draft */
|
||||
draft: string;
|
||||
/** Answers */
|
||||
answers?: string[];
|
||||
};
|
||||
/** RefineDraftBody */
|
||||
RefineDraftBody: {
|
||||
/** Draft */
|
||||
draft: string;
|
||||
};
|
||||
/** RegisterBody */
|
||||
RegisterBody: {
|
||||
/** Email */
|
||||
@@ -960,6 +1141,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_runs_route_api_runs_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RunSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_run_api_runs_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1249,4 +1450,252 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
library_route_api_library_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
sort?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
connections_route_api_library_connections_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
graph_route_api_graph_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
tasks_route_api_tasks_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
suggestions_route_api_suggestions_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
refine_questions_api_refine_questions_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RefineDraftBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
refine_compose_api_refine_compose_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RefineComposeBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_profile_api_profile_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put_profile_api_profile_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProfileBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,15 +9,35 @@ import { describe, expect, it } from "vitest";
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
import type { BackendRun } from "@/lib/api/test-helpers";
|
||||
|
||||
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
|
||||
type RouteHandler = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
// openapi-fetch serializes the body onto the Request — surface it
|
||||
// (plus credentials/headers) back to the route handler so tests
|
||||
// can assert the CSRF header and cookie discipline per call.
|
||||
let body: string | undefined;
|
||||
let sentInit: RequestInit | undefined = init;
|
||||
if (request) {
|
||||
const cloned = request.clone();
|
||||
body = cloned.body ? await cloned.text() : undefined;
|
||||
sentInit = {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
};
|
||||
}
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body } = handler(url, init);
|
||||
return new Response(JSON.stringify(body), {
|
||||
const { status, body: responseBody } = handler(url, sentInit, body);
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -27,6 +47,26 @@ function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fet
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Headers actually sent on a call (CSRF + cookie assertions). */
|
||||
function headersOf(init?: RequestInit): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const backendRun: BackendRun = {
|
||||
id: "abcd2345",
|
||||
status: "awaiting_approval",
|
||||
@@ -54,7 +94,20 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest path to a confident cited answer.",
|
||||
output_shape: "memo",
|
||||
dials: { depth: 1, breadth: 1 },
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -65,18 +118,41 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
expect(cards[0]?.slug).toBe("rapid-answer");
|
||||
expect(cards[0]?.etaMinutes).toBe(2);
|
||||
expect(cards[0]?.costEstimate).toContain("estimate");
|
||||
expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 });
|
||||
// Snake_case dials map into the full 12-key frontend DialValues
|
||||
// shape (T-3.8): the Advanced drawer reads every key.
|
||||
expect(cards[0]?.defaultDials).toEqual({
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "single",
|
||||
queryStrategy: "decomposed",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 1,
|
||||
synthesisStyle: "memo",
|
||||
outputStructure: "brief",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates runs through the backend contract", async () => {
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => {
|
||||
const method =
|
||||
init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined);
|
||||
return {
|
||||
status: 201,
|
||||
body: method === "POST" ? backendRun : { detail: "wrong method" },
|
||||
};
|
||||
"http://127.0.0.1:8000/api/runs": (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(init?.method).toBe("POST");
|
||||
// T-3.8 — the P1-verify flag stays closed: EVERY core-loop
|
||||
// mutation rides credentials + the CSRF header (C3-05).
|
||||
expect(init?.credentials).toBe("include");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(JSON.parse(body ?? "null")).toMatchObject({
|
||||
question: "How do engines run?",
|
||||
methodology: "rapid-answer",
|
||||
});
|
||||
return { status: 201, body: backendRun };
|
||||
},
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
@@ -91,29 +167,29 @@ describe("GeneratedApiClient (live binding)", () => {
|
||||
});
|
||||
|
||||
it("approves, cancels, pauses, resumes via lifecycle routes", async () => {
|
||||
// The discipline holds on EVERY lifecycle mutation, not just the
|
||||
// create — the wrapper is the single injection point (T-3.8).
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const capture =
|
||||
(status: string) =>
|
||||
(_url: string, init?: RequestInit): { status: number; body: unknown } => {
|
||||
expect(init?.credentials).toBe("include");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
seen.push({ method: init?.method, status });
|
||||
return { status: 200, body: { ...backendRun, status } };
|
||||
};
|
||||
const routes = {
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "searching" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "cancelled" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "searching" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({
|
||||
status: 200,
|
||||
body: { ...backendRun, status: "verifying" },
|
||||
}),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/approve": capture("searching"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": capture("cancelled"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/pause": capture("searching"),
|
||||
"http://127.0.0.1:8000/api/runs/abcd2345/resume": capture("verifying"),
|
||||
};
|
||||
const client = new GeneratedApiClient(stubRoutes(routes));
|
||||
expect((await client.approvePlan("abcd2345")).status).toBe("searching");
|
||||
expect((await client.cancelRun("abcd2345")).status).toBe("cancelled");
|
||||
expect((await client.pauseRun("abcd2345")).status).toBe("searching");
|
||||
expect((await client.resumeRun("abcd2345")).status).toBe("verifying");
|
||||
expect(seen.map((s) => s.method)).toEqual(["POST", "POST", "POST", "POST"]);
|
||||
});
|
||||
|
||||
it("maps the ledger to frontend citations with health + tier", async () => {
|
||||
|
||||
+845
-295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Live-surface boundary tests (T-3.9, REQ-76..81 — the surface swaps).
|
||||
*
|
||||
* Every previously-delegated surface, contract-tested against a
|
||||
* stubbed global fetch (no network, no keys): real routes, cookie
|
||||
* discipline on every call, CSRF header on every mutation (C3-05),
|
||||
* payloads mapped through the SAME frontend types the components
|
||||
* consume (zero rewrites proven — REQ-54).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
|
||||
type RouteHandler = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => { status: number; body: unknown };
|
||||
|
||||
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request ? request.url : String(input);
|
||||
// openapi-fetch serializes the body onto the Request — surface it
|
||||
// (plus credentials/headers) back to the route handler.
|
||||
let body: string | undefined;
|
||||
let sentInit: RequestInit | undefined = init;
|
||||
if (request) {
|
||||
const cloned = request.clone();
|
||||
body = cloned.body ? await cloned.text() : undefined;
|
||||
sentInit = {
|
||||
...init,
|
||||
method: request.method,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
};
|
||||
}
|
||||
for (const [prefix, handler] of Object.entries(routes)) {
|
||||
if (url.startsWith(prefix)) {
|
||||
const { status, body: responseBody } = handler(url, sentInit, body);
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Headers actually sent on a call (CSRF + cookie assertions). */
|
||||
function headersOf(init?: RequestInit): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
const record: Record<string, string> = {};
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
typeof (raw as Headers).forEach === "function"
|
||||
) {
|
||||
(raw as Headers).forEach((v, k) => {
|
||||
record[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const [k, v] of raw) record[String(k)] = String(v);
|
||||
} else if (raw) {
|
||||
Object.assign(record, raw);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
const BASE = "http://127.0.0.1:8000";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Read-model payloads (verbatim shapes from the live app) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LIBRARY_PAYLOAD = {
|
||||
folders: [
|
||||
{
|
||||
id: "deep-report-battery",
|
||||
name: "Deep Report: Battery",
|
||||
description: "Auto-filed from deep report battery research.",
|
||||
},
|
||||
],
|
||||
reports: [
|
||||
{
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CONNECTIONS_PAYLOAD = [
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runcccc1",
|
||||
kind: "related-topic",
|
||||
label: "Adjacent topic",
|
||||
},
|
||||
];
|
||||
|
||||
const GRAPH_PAYLOAD = {
|
||||
nodes: [
|
||||
{
|
||||
id: "runaaaa1",
|
||||
topic: "Battery Advances",
|
||||
linkedSources: ["https://example.org/x"],
|
||||
findings: ["Battery Advances"],
|
||||
},
|
||||
],
|
||||
edges: [{ fromId: "runaaaa1", toId: "runbbbb1", sharedSourceCount: 2 }],
|
||||
};
|
||||
|
||||
const TASKS_PAYLOAD = [
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
];
|
||||
|
||||
const SUGGESTIONS_PAYLOAD = [
|
||||
{
|
||||
id: "sug-runcccc1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runcccc1",
|
||||
sourceReportTitle: "Deep Report — an in-flight run",
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILE_PAYLOAD = {
|
||||
user_id: "abcd2345",
|
||||
display_name: "Ada Owner",
|
||||
email: "owner@example.org",
|
||||
};
|
||||
|
||||
const CONFIG_PAYLOAD = {
|
||||
resolved_tier: "user",
|
||||
key_store: "enabled",
|
||||
key_status: {
|
||||
configured: true,
|
||||
masked: "…7f2a",
|
||||
disabled: false,
|
||||
reason: null,
|
||||
},
|
||||
model_adapter: "OllamaModelAdapter",
|
||||
search_adapter: "TavilySearchAdapter",
|
||||
storage_dir: "/var/lib/researchx/runs",
|
||||
probe: null,
|
||||
cost_counters: { runs_created: 4, runs_completed: 3 },
|
||||
};
|
||||
|
||||
/** One stub serving every read-model surface at once. */
|
||||
function surfaceRoutes(): Record<string, RouteHandler> {
|
||||
return {
|
||||
[`${BASE}/api/library/connections`]: () => ({
|
||||
status: 200,
|
||||
body: CONNECTIONS_PAYLOAD,
|
||||
}),
|
||||
[`${BASE}/api/library`]: (_url: string, init?: RequestInit) => {
|
||||
expect(init?.credentials).toBe("include");
|
||||
return { status: 200, body: LIBRARY_PAYLOAD };
|
||||
},
|
||||
[`${BASE}/api/graph`]: () => ({ status: 200, body: GRAPH_PAYLOAD }),
|
||||
[`${BASE}/api/tasks`]: () => ({ status: 200, body: TASKS_PAYLOAD }),
|
||||
[`${BASE}/api/suggestions`]: () => ({
|
||||
status: 200,
|
||||
body: SUGGESTIONS_PAYLOAD,
|
||||
}),
|
||||
[`${BASE}/api/profile`]: () => ({ status: 200, body: PROFILE_PAYLOAD }),
|
||||
[`${BASE}/api/config`]: () => ({ status: 200, body: CONFIG_PAYLOAD }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("GeneratedApiClient live surfaces (T-3.9)", () => {
|
||||
it("getReportLibrary maps the camelCase read-model payload 1:1", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const library = await client.getReportLibrary();
|
||||
expect(library.folders).toHaveLength(1);
|
||||
expect(library.folders[0]?.id).toBe("deep-report-battery");
|
||||
expect(library.reports).toHaveLength(1);
|
||||
expect(library.reports[0]).toEqual({
|
||||
id: "rep-runaaaa1",
|
||||
runId: "runaaaa1",
|
||||
title: "Battery Advances",
|
||||
question: "What about battery research?",
|
||||
methodologySlug: "deep-report",
|
||||
category: "deep-report-battery",
|
||||
generatedAt: "2026-09-20T10:05:00Z",
|
||||
citationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("getReportConnections maps kind + label edges", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const edges = await client.getReportConnections();
|
||||
expect(edges).toEqual([
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runbbbb1",
|
||||
kind: "shared-sources",
|
||||
label: "2 shared sources",
|
||||
},
|
||||
{
|
||||
fromReportId: "rep-runaaaa1",
|
||||
toReportId: "rep-runcccc1",
|
||||
kind: "related-topic",
|
||||
label: "Adjacent topic",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getProjectGraph maps nodes + edges", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const graph = await client.getProjectGraph();
|
||||
expect(graph.nodes[0]?.topic).toBe("Battery Advances");
|
||||
expect(graph.nodes[0]?.linkedSources).toEqual(["https://example.org/x"]);
|
||||
expect(graph.edges[0]?.sharedSourceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("getTaskInbox maps derived statuses (G-109: open|done only)", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const inbox = await client.getTaskInbox();
|
||||
expect(inbox).toEqual([
|
||||
{
|
||||
id: "runaaaa1#0",
|
||||
question: "What is the research question?",
|
||||
sourceRunId: "runaaaa1",
|
||||
status: "open",
|
||||
},
|
||||
{
|
||||
id: "runbbbb1#0",
|
||||
question: "An answered question",
|
||||
sourceRunId: "runbbbb1",
|
||||
status: "done",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getSuggestedQueries maps open items with source titles; honest empty", async () => {
|
||||
const withItems = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const suggestions = await withItems.getSuggestedQueries();
|
||||
expect(suggestions[0]?.sourceReportTitle).toBe("Deep Report — an in-flight run");
|
||||
expect(suggestions[0]?.id).toBe("sug-runcccc1#0");
|
||||
|
||||
// A fresh user gets an honest empty array — never synthetic content.
|
||||
const empty = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/suggestions`]: () => ({ status: 200, body: [] }),
|
||||
}),
|
||||
);
|
||||
expect(await empty.getSuggestedQueries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getUserProfile maps GET /api/profile into the DisplayProfile", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const profile = await client.getUserProfile();
|
||||
expect(profile.name).toBe("Ada Owner");
|
||||
expect(profile.initials).toBe("AO");
|
||||
expect(profile.tagline).toBe("owner@example.org");
|
||||
});
|
||||
|
||||
it("getConnectionStatus maps GET /api/config through the five slots (P2-verify closure)", async () => {
|
||||
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
|
||||
const slots = await client.getConnectionStatus();
|
||||
expect(slots.map((s) => s.slot)).toEqual([
|
||||
"model",
|
||||
"search",
|
||||
"storage",
|
||||
"memory",
|
||||
"secrets",
|
||||
]);
|
||||
const model = slots[0];
|
||||
expect(model?.status).toBe("connected"); // tier "user" — not keyless
|
||||
expect(model?.resolvedTier).toBe("user");
|
||||
expect(model?.keyStore).toBe("enabled");
|
||||
expect(model?.keyStatus?.masked).toBe("…7f2a");
|
||||
expect(model?.detail).toContain("OllamaModelAdapter");
|
||||
// Storage is server-managed, display-only (D-111).
|
||||
expect(slots[2]?.detail).toContain("/var/lib/researchx/runs");
|
||||
});
|
||||
|
||||
it("getSettings composes the v0.1 shape live from config + catalog", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
...surfaceRoutes(),
|
||||
[`${BASE}/api/methodologies`]: () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
slug: "rapid-answer",
|
||||
name: "Rapid Answer",
|
||||
description: "One pass, one memo.",
|
||||
eta_minutes: 2,
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest.",
|
||||
output_shape: "memo",
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "deep-report",
|
||||
name: "Deep Report",
|
||||
description: "Full depth.",
|
||||
eta_minutes: 12,
|
||||
cost_estimate: "~25k tokens (estimate)",
|
||||
why_recommended: "Thorough.",
|
||||
output_shape: "report",
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const settings = await client.getSettings();
|
||||
// The recommended card is the first in catalog order here — deep-report
|
||||
// drives the resolved default (index 0 = recommended per the mapping).
|
||||
expect(settings.defaultMethodologySlug).toBe("rapid-answer");
|
||||
expect(settings.storageDirectory).toBe("/var/lib/researchx/runs");
|
||||
// Masked-only key status — never the raw key.
|
||||
expect(settings.apiKeys[0]?.configured).toBe(true);
|
||||
expect(settings.apiKeys[0]?.maskedValue).toBe("…7f2a");
|
||||
expect(settings.theme).toBe("paper");
|
||||
});
|
||||
|
||||
it("saveSettings answers with the authoritative server read (D-111)", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
...surfaceRoutes(),
|
||||
[`${BASE}/api/methodologies`]: () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
slug: "rapid-answer",
|
||||
name: "Rapid Answer",
|
||||
description: "One pass, one memo.",
|
||||
eta_minutes: 2,
|
||||
cost_estimate: "~5k tokens (estimate)",
|
||||
why_recommended: "Fastest.",
|
||||
output_shape: "memo",
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The server owns every v0.1 field live — a save is a re-read,
|
||||
// never a client-side pretend-persist.
|
||||
const saved = await client.saveSettings({ storageDirectory: "~/elsewhere" });
|
||||
expect(saved.storageDirectory).toBe("/var/lib/researchx/runs");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Refine — real POSTs, honest 503 (T-3.6/T-3.9) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("GeneratedApiClient refine (live binding, REQ-80)", () => {
|
||||
it("getRefinementQuestions POSTs the draft with CSRF + credentials", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/questions`]: (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
const parsed = JSON.parse(body ?? "null") as { draft: string };
|
||||
expect(parsed.draft).toBe("How do batteries work?");
|
||||
return { status: 200, body: { questions: ["Which scope?", "What decision?"] } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const questions = await client.getRefinementQuestions("How do batteries work?");
|
||||
expect(questions).toHaveLength(2);
|
||||
expect(questions[0]?.prompt).toBe("Which scope?");
|
||||
expect(questions[0]?.optional).toBe(false); // the first question is required
|
||||
expect(questions[1]?.optional).toBe(true); // the rest are skippable
|
||||
});
|
||||
|
||||
it("getRefinementQuestions 503 → honest error, never a hang, never synthetic", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/questions`]: () => ({
|
||||
status: 503,
|
||||
body: { detail: "no local endpoint and no key — tier unavailable" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(client.getRefinementQuestions("draft")).rejects.toThrow(
|
||||
/refine is unavailable/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("composeRefinedQuestion POSTs ordered answers with CSRF + credentials", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/compose`]: (
|
||||
_url: string,
|
||||
init?: RequestInit,
|
||||
body?: string,
|
||||
) => {
|
||||
expect(headersOf(init)["x-researchx-request"]).toBe("1");
|
||||
expect(init?.credentials).toBe("include");
|
||||
const parsed = JSON.parse(body ?? "null") as {
|
||||
draft: string;
|
||||
answers: string[];
|
||||
};
|
||||
// Answers travel in exchange order, trimmed.
|
||||
expect(parsed.draft).toBe("How do batteries work?");
|
||||
expect(parsed.answers).toEqual(["automotive", "supplier choice", ""]);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
question: "How do batteries work? — automotive focus",
|
||||
context: "Feeds a supplier choice.",
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
const refined = await client.composeRefinedQuestion("How do batteries work?", {
|
||||
"q-1": "automotive",
|
||||
"q-2": "supplier choice",
|
||||
"q-3": "",
|
||||
});
|
||||
expect(refined.question).toBe("How do batteries work? — automotive focus");
|
||||
expect(refined.context).toBe("Feeds a supplier choice.");
|
||||
});
|
||||
|
||||
it("composeRefinedQuestion 503 → honest error", async () => {
|
||||
const client = new GeneratedApiClient(
|
||||
stubRoutes({
|
||||
[`${BASE}/api/refine/compose`]: () => ({
|
||||
status: 503,
|
||||
body: { detail: "planner unreachable" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.composeRefinedQuestion("draft", { "q-1": "answer" }),
|
||||
).rejects.toThrow(/refine is unavailable/i);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* D-112: the delegation is DEAD — structural proof */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("GeneratedApiClient standalone (D-112)", () => {
|
||||
it("has no MockApiClient in its prototype chain — the delegation is gone", () => {
|
||||
const client = new GeneratedApiClient();
|
||||
expect(client).not.toBeInstanceOf(MockApiClient);
|
||||
let proto = Object.getPrototypeOf(client);
|
||||
while (proto !== null) {
|
||||
expect(proto.constructor.name).not.toBe("MockApiClient");
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
});
|
||||
|
||||
it("implements every ApiClient surface as its own methods", () => {
|
||||
const client = new GeneratedApiClient();
|
||||
const required = [
|
||||
"getMethodologies",
|
||||
"createRun",
|
||||
"getRun",
|
||||
"approvePlan",
|
||||
"cancelRun",
|
||||
"pauseRun",
|
||||
"resumeRun",
|
||||
"getRunEvents",
|
||||
"getReport",
|
||||
"getLedger",
|
||||
"exportReport",
|
||||
"getProjectGraph",
|
||||
"getTaskInbox",
|
||||
"getReportLibrary",
|
||||
"getReportConnections",
|
||||
"getUserProfile",
|
||||
"getConnectionStatus",
|
||||
"getSettings",
|
||||
"saveSettings",
|
||||
"getUserSettings",
|
||||
"saveUserSettings",
|
||||
"setModelKey",
|
||||
"getConfigStatus",
|
||||
"getSuggestedQueries",
|
||||
"getRefinementQuestions",
|
||||
"composeRefinedQuestion",
|
||||
"login",
|
||||
"logout",
|
||||
"getSession",
|
||||
"listUsers",
|
||||
"createUser",
|
||||
"deactivateUser",
|
||||
] as const;
|
||||
for (const method of required) {
|
||||
expect(
|
||||
typeof (client as unknown as Record<string, unknown>)[method],
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user