ship: frontend scaffold + ask + choose (P1) #1
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"stage": "execute",
|
||||
"stage": "verify",
|
||||
"milestone": "v0.1",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-27T00:40:00Z"
|
||||
"updated_at": "2026-09-27T00:50:00Z"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# ResearchX
|
||||
|
||||
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source.
|
||||
|
||||
**Status: prototype (milestone v0.1)** — UI/UX with mock data. No backend, no API keys, no network calls.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 — you land on **Ask**. Type a question, click **Research this**, pick a methodology on **Choose** (Deep Report is the recommended default), and press **Start research**.
|
||||
|
||||
Three clicks from cold start to initiated research.
|
||||
|
||||
## What's in the prototype
|
||||
|
||||
| Screen | What it does |
|
||||
|---|---|
|
||||
| `/ask` | One question box + one optional context box. Nothing else competing for attention. |
|
||||
| `/choose` | All 8 locked methodology cards — mock ETA, cost estimate, why-recommended, and the 12 methodology dials behind each card's Advanced drawer. |
|
||||
| `/run/[id]` | Placeholder — Plan & Run ships in phase 2. |
|
||||
|
||||
All data flows through a typed `ApiClient` interface backed by `MockApiClient` — the same components will talk to the FastAPI backend via the OpenAPI-generated client in the MVP milestone, with zero rewrites.
|
||||
|
||||
## Scripts
|
||||
|
||||
Run inside `frontend/`:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pnpm dev` | Dev server on :3000 |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm lint` | ESLint (flat config) |
|
||||
| `pnpm typecheck` | `tsc --noEmit` (strict) |
|
||||
| `pnpm test` | Vitest + Testing Library (jsdom) |
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
frontend/ Next.js 15 App Router, TypeScript strict, Tailwind v4, Vitest
|
||||
.ciagent/ Pipeline state (roadmap, requirements, plans, checkpoints)
|
||||
```
|
||||
|
||||
Milestone v0.2 (MVP: FastAPI backend, research engine, adapter layer, trust loop) begins only after this prototype is approved.
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
@@ -0,0 +1,66 @@
|
||||
# ResearchX Frontend
|
||||
|
||||
The prototype frontend for ResearchX — methodology-driven deep research
|
||||
with a trust loop. This milestone (v0.1) runs **entirely on mock data**:
|
||||
no API keys, no env vars, no network calls beyond static assets.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Prerequisites: **Node 20+** and **pnpm** (any recent version; the repo was
|
||||
built with pnpm 12).
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open <http://localhost:3000> — you'll land on `/ask`.
|
||||
|
||||
The happy path:
|
||||
|
||||
1. Type a question (context is optional) → **Research this**
|
||||
2. Pick a methodology on `/choose` — Deep Report is the recommended
|
||||
default; open **Advanced** on any card to see its 12 dials
|
||||
3. **Start research** → `/run/[id]` (a placeholder in this phase: the
|
||||
Plan & Run screen ships in phase 2)
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | What it does |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Start the Next.js dev server on port 3000 |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Serve the production build |
|
||||
| `pnpm lint` | ESLint (flat config, eslint-config-next) |
|
||||
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
|
||||
| `pnpm test` | Vitest run (jsdom + Testing Library) |
|
||||
| `pnpm test:watch` | Vitest in watch mode |
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- **All data access goes through the typed `ApiClient` interface**
|
||||
(`src/lib/api/client.ts`) — components import the `api` singleton and
|
||||
never touch `MockApiClient` directly. v0.2 phase 3 swaps the binding
|
||||
to the OpenAPI-generated backend client with no component rewrites.
|
||||
- The mock layer lives in `src/lib/api/mock/`: fixture data for all 8
|
||||
locked methodologies and a scripted Deep Report run (trace events,
|
||||
report with `[^passageId]` citation markers, citation ledger).
|
||||
- Design tokens (editorial type scale, warm paper palette, spacing) are
|
||||
defined as Tailwind v4 `@theme` tokens in `src/app/globals.css`.
|
||||
- Question + context survive the `/ask` → `/choose` navigation via a
|
||||
small module-level store (`src/lib/ask-store.ts`).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
app/ App Router routes (ask, choose, run/[id])
|
||||
components/ UI components (server by default, client where interactive)
|
||||
lib/ Domain types, ApiClient boundary, mock data, stores
|
||||
test/ Test harness setup
|
||||
```
|
||||
|
||||
Prototype scope note: ETAs, cost estimates, and the scripted run are
|
||||
realistic fixture data — not live research. The Reading Room, live
|
||||
trace, and exports ship in later phases of the milestone.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"next-env.d.ts",
|
||||
"coverage/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Prototype runs entirely on mock data — no API routes, no network calls
|
||||
// beyond static assets. Keep the surface minimal and deterministic.
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@researchx/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@12.6.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "6.10.0",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.7",
|
||||
"@types/node": "24.3.1",
|
||||
"@types/react": "19.2.5",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
Generated
+5778
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AskForm } from "@/components/ask-form";
|
||||
|
||||
/**
|
||||
* /ask (T-3.1, REQ-55) — the front door: one question box, one optional
|
||||
* context box, one primary action. Generous whitespace, no competition.
|
||||
*/
|
||||
export default function AskPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 py-section">
|
||||
<header className="mb-block">
|
||||
<p className="text-caption font-medium tracking-wide text-accent uppercase">
|
||||
ResearchX
|
||||
</p>
|
||||
<h1 className="mt-3 text-display font-semibold text-ink">
|
||||
Ask a question.
|
||||
<br />
|
||||
Get a report you can trust.
|
||||
</h1>
|
||||
<p className="measure-reading mt-4 text-lead text-ink-soft">
|
||||
ResearchX picks a research methodology, hunts down sources, and
|
||||
writes a fully-cited report — with every claim anchored to the exact
|
||||
passage it came from.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AskForm />
|
||||
|
||||
<p className="mt-block text-caption text-ink-faint">
|
||||
Prototype: runs on scripted mock data — no API keys, no live search.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChooseView } from "@/components/choose-view";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* /choose (T-3.2, REQ-56) — the methodology chooser.
|
||||
*
|
||||
* Server component: fetches the catalog through the typed ApiClient
|
||||
* singleton (bound to MockApiClient in v0.1) and hands it to the
|
||||
* client view. Interactive bits (drawer, start button) live below.
|
||||
*/
|
||||
export default async function ChoosePage() {
|
||||
const methodologies = await api.getMethodologies();
|
||||
return <ChooseView methodologies={methodologies} />;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* ResearchX design tokens (D-018 — "vision §4" typography care)
|
||||
* Tailwind v4 @theme: every token becomes both a CSS variable and a
|
||||
* utility namespace (text-*, bg-*, border-* …).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
@theme {
|
||||
/* ---- Type scale -------------------------------------------------
|
||||
* Editorial scale: 12 / 14 / 16 / 18 / 21 / 28 / 38 / 50.
|
||||
* Display sizes lead tight (1.05–1.15); body leads airy (1.6–1.7).
|
||||
* Reading measure is capped at 68ch (see --measure-reading below).
|
||||
*/
|
||||
--text-caption: 0.75rem; /* 12px — metadata, badges */
|
||||
--text-caption--line-height: 1.5;
|
||||
--text-caption--letter-spacing: 0.01em;
|
||||
|
||||
--text-small: 0.875rem; /* 14px — secondary body */
|
||||
--text-small--line-height: 1.6;
|
||||
|
||||
--text-body: 1rem; /* 16px — default body */
|
||||
--text-body--line-height: 1.6;
|
||||
|
||||
--text-lead: 1.125rem; /* 18px — emphasized body */
|
||||
--text-lead--line-height: 1.65;
|
||||
|
||||
--text-title: 1.3125rem; /* 21px — card headings */
|
||||
--text-title--line-height: 1.3;
|
||||
|
||||
--text-headline: 1.75rem; /* 28px — section headings */
|
||||
--text-headline--line-height: 1.2;
|
||||
|
||||
--text-display: 2.375rem; /* 38px — screen titles */
|
||||
--text-display--line-height: 1.1;
|
||||
--text-display--letter-spacing: -0.015em;
|
||||
|
||||
--text-masthead: 3.125rem; /* 50px — hero question */
|
||||
--text-masthead--line-height: 1.05;
|
||||
--text-masthead--letter-spacing: -0.02em;
|
||||
|
||||
/* ---- Color ------------------------------------------------------ */
|
||||
/* Warm paper + near-black ink + one deep-green accent.
|
||||
* Semantic aliases below keep components away from raw hexes.
|
||||
*/
|
||||
--color-paper: #faf9f6; /* page background */
|
||||
--color-paper-raised: #ffffff; /* cards, drawers */
|
||||
--color-paper-sunken: #f1efe9; /* wells, input backgrounds */
|
||||
--color-ink: #1a1a1a; /* primary text */
|
||||
--color-ink-soft: #4a4945; /* secondary text */
|
||||
--color-ink-faint: #8a8880; /* muted metadata */
|
||||
--color-line: #e5e2da; /* hairline borders */
|
||||
|
||||
--color-accent: #1e5c3f; /* deep green — primary action */
|
||||
--color-accent-strong: #164732; /* hover / pressed */
|
||||
--color-accent-wash: #eaf1ed; /* soft accent tint */
|
||||
--color-accent-ink: #0f3a27; /* accent text on wash */
|
||||
|
||||
--color-success: #1d6f42; /* verification: live */
|
||||
--color-success-wash: #e7f2ea;
|
||||
--color-warn: #92610a; /* verification: stale */
|
||||
--color-warn-wash: #f9efdc;
|
||||
--color-danger: #a52a2a; /* verification: dead */
|
||||
--color-danger-wash: #f8e8e8;
|
||||
|
||||
--color-info: #2b5c8a; /* neutral status / plan chips */
|
||||
--color-info-wash: #e8eef5;
|
||||
|
||||
/* ---- Spacing & radius ------------------------------------------- */
|
||||
--spacing-gutter: 1.5rem; /* card grid gutter */
|
||||
--spacing-block: 2rem; /* between major blocks */
|
||||
--spacing-section: 4rem; /* between screen sections */
|
||||
|
||||
--radius-card: 0.75rem;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* ---- Motion ------------------------------------------------------ */
|
||||
--ease-out-soft: cubic-bezier(0.25, 0.6, 0.25, 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Semantic CSS variables — the runtime contract components rely on.
|
||||
* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--measure-reading: 68ch; /* max line length for reading text */
|
||||
--measure-narrow: 44ch; /* forms, helper copy */
|
||||
--surface-page: var(--color-paper);
|
||||
--surface-raised: var(--color-paper-raised);
|
||||
--text-primary: var(--color-ink);
|
||||
--text-secondary: var(--color-ink-soft);
|
||||
--text-muted: var(--color-ink-faint);
|
||||
--border-hairline: var(--color-line);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface-page);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Reading containers opt in to the measure cap + airy leading. */
|
||||
.measure-reading {
|
||||
max-width: var(--measure-reading);
|
||||
}
|
||||
.measure-narrow {
|
||||
max-width: var(--measure-narrow);
|
||||
}
|
||||
|
||||
/* Visible focus ring for keyboard reachability (UX criterion). */
|
||||
:where(a, button, input, textarea, summary):focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ResearchX",
|
||||
description:
|
||||
"Methodology-driven deep research with a trust loop — prototype on mock data.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Root route (T-3.4, REQ-52) — the front door is /ask.
|
||||
* One question box lives there; the prototype has no landing page.
|
||||
*/
|
||||
export default function Home() {
|
||||
redirect("/ask");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RunView } from "@/components/run-view";
|
||||
|
||||
/**
|
||||
* /run/[id] (T-3.2b) — placeholder for the Plan & Run screen.
|
||||
*
|
||||
* Phase 2 ships the real screen (plan approval, live scripted trace,
|
||||
* pause/cancel). For now: the run's question + methodology and a clear
|
||||
* "ships in phase 2" notice, so the Start-research click-path lands
|
||||
* somewhere honest.
|
||||
*/
|
||||
export default async function RunPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-section">
|
||||
<p className="text-caption font-medium tracking-wide text-accent uppercase">
|
||||
ResearchX · Run
|
||||
</p>
|
||||
<RunView id={id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AskForm tests (T-3.3, REQ-55).
|
||||
*
|
||||
* - Button disabled until the question is non-empty
|
||||
* - Submit stores the payload in the ask store (state carry) and
|
||||
* routes to /choose
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AskForm } from "@/components/ask-form";
|
||||
import { clearAskState, getAskState } from "@/lib/ask-store";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
describe("AskForm", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
clearAskState();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("disables the submit button until a question is typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AskForm />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Research this" });
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const question = screen.getByLabelText(/what do you want to know\?/i);
|
||||
await user.type(question, "What are the latest advances in solid-state batteries?");
|
||||
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits: stores question + context and routes to /choose", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AskForm />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText(/anything else it should know\?/i),
|
||||
"Focus on automotive applications",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Research this" }));
|
||||
|
||||
// State carry (UX criterion): the payload survives navigation.
|
||||
const carried = getAskState();
|
||||
expect(carried?.question).toBe(
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
);
|
||||
expect(carried?.context).toBe("Focus on automotive applications");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/choose");
|
||||
});
|
||||
});
|
||||
|
||||
it("submits without storing an empty context", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AskForm />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"Who makes solid-state cells today?",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Research this" }));
|
||||
|
||||
const carried = getAskState();
|
||||
expect(carried?.question).toBe("Who makes solid-state cells today?");
|
||||
expect(carried?.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AskForm (T-3.1, REQ-55) — the front door.
|
||||
*
|
||||
* Exactly ONE question textarea + ONE optional context textarea + one
|
||||
* primary action. Nothing else competes for attention. Submit stores
|
||||
* the payload in the ask store and routes to /choose.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { setAskState } from "@/lib/ask-store";
|
||||
|
||||
export function AskForm() {
|
||||
const router = useRouter();
|
||||
const [question, setQuestion] = useState("");
|
||||
const [context, setContext] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = question.trim().length > 0 && !submitting;
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setAskState({
|
||||
question: question.trim(),
|
||||
...(context.trim() !== "" ? { context: context.trim() } : {}),
|
||||
});
|
||||
router.push("/choose");
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="measure-narrow">
|
||||
<label htmlFor="question" className="block text-body font-medium">
|
||||
What do you want to know?
|
||||
</label>
|
||||
<textarea
|
||||
id="question"
|
||||
name="question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="e.g. What are the latest advances in solid-state batteries?"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="context"
|
||||
className="mt-6 block text-small font-medium text-ink-soft"
|
||||
>
|
||||
Anything else it should know?{" "}
|
||||
<span className="text-ink-faint">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="context"
|
||||
name="context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Constraints, prior findings, the decision this feeds…"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Opening methodologies…" : "Research this"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Choose screen tests (T-3.3, REQ-56).
|
||||
*
|
||||
* - Renders all 8 cards from MockApiClient fixtures
|
||||
* - Exactly ONE "Recommended" badge (deep-report)
|
||||
* - Advanced drawer is hidden by default; opening reveals the 12 dials
|
||||
* - "Start research" creates a run and routes to /run/[id]
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChooseView } from "@/components/choose-view";
|
||||
import { MethodologyCard } from "@/components/methodology-card";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { setAskState } from "@/lib/ask-store";
|
||||
import { DIALS } from "@/lib/types";
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
async function loadFixtures() {
|
||||
return api.getMethodologies();
|
||||
}
|
||||
|
||||
describe("ChooseView", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
setAskState({
|
||||
question: "What are the latest advances in solid-state batteries?",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders all 8 methodology cards from the fixtures", async () => {
|
||||
const methodologies = await loadFixtures();
|
||||
render(<ChooseView methodologies={methodologies} />);
|
||||
|
||||
for (const methodology of methodologies) {
|
||||
expect(
|
||||
screen.getByTestId(`methodology-${methodology.slug}`),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText("Systematic Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Corpus Interrogation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows exactly one Recommended badge — on Deep Report", async () => {
|
||||
const methodologies = await loadFixtures();
|
||||
render(<ChooseView methodologies={methodologies} />);
|
||||
|
||||
const badges = screen.getAllByText("Recommended");
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(
|
||||
within(screen.getByTestId("methodology-deep-report")).getByText(
|
||||
"Recommended",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the carried question with a link back to /ask", async () => {
|
||||
const methodologies = await loadFixtures();
|
||||
render(<ChooseView methodologies={methodologies} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Change question")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drawer: hidden by default; opening reveals all 12 dial labels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const methodologies = await loadFixtures();
|
||||
render(<ChooseView methodologies={methodologies} />);
|
||||
|
||||
const rapid = screen.getByTestId("methodology-rapid-answer");
|
||||
const drawer = within(rapid).getByTestId("dials-list");
|
||||
expect(drawer).not.toBeVisible(); // <details> collapsed: present but hidden
|
||||
|
||||
await user.click(within(rapid).getByText("Advanced"));
|
||||
|
||||
for (const dial of DIALS) {
|
||||
expect(within(drawer).getByText(dial.label)).toBeVisible();
|
||||
}
|
||||
// Defaults render too: Rapid Answer is breadth 1 / depth 1.
|
||||
const breadth = within(drawer).getByText("Breadth");
|
||||
expect(breadth.nextElementSibling).toHaveTextContent("1 —");
|
||||
const depth = within(drawer).getByText("Depth");
|
||||
expect(depth.nextElementSibling).toHaveTextContent("1 —");
|
||||
});
|
||||
|
||||
it("Start research: creates the run and routes to /run/[id]", async () => {
|
||||
const user = userEvent.setup();
|
||||
const methodologies = await loadFixtures();
|
||||
const deepReport = methodologies.find((m) => m.slug === "deep-report");
|
||||
if (!deepReport) throw new Error("fixture missing deep-report");
|
||||
|
||||
render(
|
||||
<MethodologyCard
|
||||
methodology={deepReport}
|
||||
question="What are the latest advances in solid-state batteries?"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start research" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const calls = pushMock.mock.calls.flat();
|
||||
const runRoute = calls.find((c) => typeof c === "string" && c.startsWith("/run/"));
|
||||
expect(runRoute).toMatch(/^\/run\/run-[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ChooseView (T-3.2, REQ-56) — the client shell of /choose.
|
||||
*
|
||||
* Receives the methodology catalog from the server page (fetched via
|
||||
* the ApiClient singleton) and reads the carried ask payload from the
|
||||
* ask store on mount. Direct navigation without a question shows a
|
||||
* notice with a link back to /ask instead of a dead end.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MethodologyCard } from "@/components/methodology-card";
|
||||
import { getAskState } from "@/lib/ask-store";
|
||||
import type { Methodology } from "@/lib/types";
|
||||
|
||||
export function ChooseView({ methodologies }: { methodologies: Methodology[] }) {
|
||||
// The ask payload lives in client-only module state; hydrate from it
|
||||
// after mount so the server render stays static.
|
||||
const [ask, setAsk] = useState<{ question: string; context?: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const carried = getAskState();
|
||||
if (carried) setAsk(carried);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const question = ask?.question ?? "";
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-section">
|
||||
<header className="mb-block">
|
||||
<p className="text-caption font-medium tracking-wide text-accent uppercase">
|
||||
ResearchX
|
||||
</p>
|
||||
<h1 className="mt-2 text-headline font-semibold text-ink">
|
||||
How deep should this go?
|
||||
</h1>
|
||||
{mounted && question !== "" ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-small text-ink-faint">Your question</p>
|
||||
<blockquote className="measure-reading mt-1 border-l-2 border-accent pl-3 text-lead text-ink">
|
||||
{question}
|
||||
</blockquote>
|
||||
<p className="mt-2 text-caption">
|
||||
<Link href="/ask" className="text-accent underline underline-offset-2">
|
||||
Change question
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
mounted && (
|
||||
<p className="mt-3 text-small text-ink-soft" role="note">
|
||||
You arrived without a question.{" "}
|
||||
<Link href="/ask" className="font-medium text-accent underline underline-offset-2">
|
||||
Start by asking one
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-gutter md:grid-cols-2 xl:grid-cols-4">
|
||||
{methodologies.map((methodology) => (
|
||||
<MethodologyCard
|
||||
key={methodology.slug}
|
||||
methodology={methodology}
|
||||
question={question}
|
||||
{...(ask?.context !== undefined
|
||||
? { context: ask.context }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-block text-caption text-ink-faint">
|
||||
Prototype: ETAs and costs are mock heuristics; “Start research”
|
||||
replays a scripted run.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MethodologyCard (T-3.2, REQ-56) — one card in the chooser grid.
|
||||
*
|
||||
* Progressive disclosure: the 12 dials are hidden behind an "Advanced"
|
||||
* drawer (a <details> element — no JS needed to toggle, no layout shift:
|
||||
* content expands below the card's summary strip). "Start research"
|
||||
* calls api.createRun and routes to /run/[id].
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { allDials, formatDialValue } from "@/lib/dials";
|
||||
import type { Methodology } from "@/lib/types";
|
||||
|
||||
const verificationBadge: Record<Methodology["verification"], { label: string; classes: string }> = {
|
||||
"per-claim": { label: "Per-claim citations", classes: "bg-info-wash text-info" },
|
||||
full: { label: "Full verification", classes: "bg-success-wash text-success" },
|
||||
standard: { label: "Standard verification", classes: "bg-info-wash text-info" },
|
||||
};
|
||||
|
||||
export function MethodologyCard({
|
||||
methodology,
|
||||
question,
|
||||
context,
|
||||
}: {
|
||||
methodology: Methodology;
|
||||
question: string;
|
||||
context?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleStart() {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const run = await api.createRun({
|
||||
question,
|
||||
...(context !== undefined && context !== "" ? { context } : {}),
|
||||
methodologySlug: methodology.slug,
|
||||
});
|
||||
router.push(`/run/${run.id}`);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not start run");
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const verification = verificationBadge[methodology.verification];
|
||||
|
||||
return (
|
||||
<article
|
||||
className="relative flex h-full flex-col rounded-card border border-hairline bg-paper-raised p-5"
|
||||
data-testid={`methodology-${methodology.slug}`}
|
||||
>
|
||||
{methodology.recommended && (
|
||||
<span className="absolute -top-3 left-4 rounded-pill bg-accent px-3 py-1 text-caption font-medium text-paper-raised">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
{!methodology.recommended && methodology.badge && (
|
||||
<span className="absolute -top-3 left-4 rounded-pill bg-paper-sunken px-3 py-1 text-caption font-medium text-ink-soft">
|
||||
{methodology.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h2 className="text-title font-semibold text-ink">{methodology.name}</h2>
|
||||
<p className="mt-1 text-small text-ink-soft">{methodology.tagline}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-caption">
|
||||
<span className="rounded-pill bg-paper-sunken px-2.5 py-1 font-medium text-ink-soft">
|
||||
~{methodology.etaMinutes} min
|
||||
</span>
|
||||
<span className="rounded-pill bg-paper-sunken px-2.5 py-1 font-medium text-ink-soft">
|
||||
{methodology.costEstimate}
|
||||
</span>
|
||||
<span className={`rounded-pill px-2.5 py-1 font-medium ${verification.classes}`}>
|
||||
{verification.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="measure-reading mt-4 text-small leading-relaxed text-ink-soft">
|
||||
{methodology.whyRecommended}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-4 border-t border-hairline pt-3"
|
||||
aria-label="Dial defaults"
|
||||
>
|
||||
{methodology.dialSummary.map((line) => (
|
||||
<p key={line} className="text-caption text-ink-faint">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="group mt-4">
|
||||
<summary className="cursor-pointer select-none text-caption font-medium text-accent-ink underline decoration-accent-wash underline-offset-4">
|
||||
Advanced
|
||||
<span className="ml-1 inline-block transition-transform group-open:rotate-90" aria-hidden="true">
|
||||
▸
|
||||
</span>
|
||||
</summary>
|
||||
<dl
|
||||
className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 rounded-card bg-paper-sunken px-4 py-3 text-caption"
|
||||
data-testid="dials-list"
|
||||
>
|
||||
{allDials().map((dial) => {
|
||||
const value = methodology.defaultDials[dial.key];
|
||||
return (
|
||||
<div key={dial.key} className="col-span-2 grid grid-cols-subgrid">
|
||||
<dt className="font-medium text-ink">{dial.label}</dt>
|
||||
<dd className="text-ink-soft" title={dial.description}>
|
||||
<span className="font-medium">{formatDialValue(value)}</span>
|
||||
<span className="text-ink-faint"> — {dial.description}</span>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<div className="mt-auto pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
disabled={starting}
|
||||
className="w-full rounded-pill bg-accent px-5 py-2.5 text-small font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{starting ? "Starting…" : "Start research"}
|
||||
</button>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-caption text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RunView (T-3.2b, client half) — the /run/[id] placeholder body.
|
||||
*
|
||||
* Reads the run through the ApiClient on the CLIENT because the mock
|
||||
* binding keeps its scripted-run state in browser module state
|
||||
* (createRun was called there). Phase 2 replaces this with the real
|
||||
* server-rendered Plan & Run screen backed by GET /api/runs/{id}.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { Methodology, ResearchRun } from "@/lib/types";
|
||||
|
||||
type RunState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "ready"; run: ResearchRun; methodology: Methodology | undefined }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
export function RunView({ id }: { id: string }) {
|
||||
const [state, setState] = useState<RunState>({ phase: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const [run, methodologies] = await Promise.all([
|
||||
api.getRun(id),
|
||||
api.getMethodologies(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
phase: "ready",
|
||||
run,
|
||||
methodology: methodologies.find(
|
||||
(m) => m.slug === run.plan.methodologySlug,
|
||||
),
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
phase: "error",
|
||||
message: cause instanceof Error ? cause.message : "Run not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (state.phase === "loading") {
|
||||
return <p className="mt-8 text-small text-ink-faint">Loading run…</p>;
|
||||
}
|
||||
|
||||
if (state.phase === "error") {
|
||||
return (
|
||||
<div>
|
||||
<p role="alert" className="mt-8 text-small text-danger">
|
||||
{state.message}
|
||||
</p>
|
||||
<p className="mt-block text-caption">
|
||||
<Link href="/ask" className="text-accent underline underline-offset-2">
|
||||
Ask a question
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { run, methodology } = state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="measure-reading mt-2 text-display font-semibold text-ink">
|
||||
{run.plan.question}
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-small text-ink-soft">
|
||||
Methodology:{" "}
|
||||
<span className="font-medium text-ink">
|
||||
{methodology?.name ?? run.plan.methodologySlug}
|
||||
</span>
|
||||
{run.plan.context !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
· Context: <span className="text-ink">{run.plan.context}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-block rounded-card border border-hairline bg-accent-wash px-5 py-4">
|
||||
<p className="text-body font-medium text-accent-ink">
|
||||
Plan & Run ships in phase 2
|
||||
</p>
|
||||
<p className="measure-reading mt-1 text-small text-ink-soft">
|
||||
In the prototype, the live trace stream, plan approval, and
|
||||
pause/cancel controls are the next milestone phase. Your run id is{" "}
|
||||
<code className="rounded bg-paper-raised px-1.5 py-0.5 text-caption text-ink">
|
||||
{run.id}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-block text-caption">
|
||||
<Link href="/ask" className="text-accent underline underline-offset-2">
|
||||
Ask another question
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ApiClient — the single data-access boundary (T-1.4, REQ-53, REQ-54).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CitationLedger,
|
||||
DialValues,
|
||||
Methodology,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
} from "@/lib/types";
|
||||
import { MockApiClient } from "@/lib/api/mock/mock-client";
|
||||
|
||||
export type { Report, ReportSection } from "@/lib/types";
|
||||
export type {
|
||||
Citation,
|
||||
CitationLedger,
|
||||
DialValues,
|
||||
Methodology,
|
||||
ResearchRun,
|
||||
RunPlan,
|
||||
RunStatus,
|
||||
Task,
|
||||
TraceEvent,
|
||||
} from "@/lib/types";
|
||||
|
||||
/** Input to createRun — the Ask + Choose screens' combined payload. */
|
||||
export interface CreateRunInput {
|
||||
question: string;
|
||||
context?: string;
|
||||
methodologySlug: string;
|
||||
dialOverrides?: Partial<DialValues>;
|
||||
}
|
||||
|
||||
/** Subscription handle for the live trace stream (EventSource-shaped). */
|
||||
export interface RunEventSubscription {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend-swappable contract. Every screen talks to this — never
|
||||
* to a concrete client, never to the database, never to the network.
|
||||
*/
|
||||
export interface ApiClient {
|
||||
/** Methodology catalog: cards with ETA, cost, why-recommended. */
|
||||
getMethodologies(): Promise<Methodology[]>;
|
||||
|
||||
/** Start a research run for a question + methodology (+ dial tweaks). */
|
||||
createRun(input: CreateRunInput): Promise<ResearchRun>;
|
||||
|
||||
/** Fetch a run by id (plan, status, and — after replay — events). */
|
||||
getRun(id: string): Promise<ResearchRun>;
|
||||
|
||||
/** Approve the plan; the run proceeds past AWAITING_APPROVAL. */
|
||||
approvePlan(id: string): Promise<ResearchRun>;
|
||||
|
||||
/**
|
||||
* Stream a run's trace events to `onEvent` as they replay/arrive,
|
||||
* then invoke `onDone`. Returns a handle that cancels the stream.
|
||||
* EventSource-shaped so v0.2 can back it with SSE unchanged.
|
||||
*/
|
||||
getRunEvents(
|
||||
id: string,
|
||||
onEvent: (event: TraceEvent) => void,
|
||||
onDone?: () => void,
|
||||
): RunEventSubscription;
|
||||
|
||||
/** The final report for a run (sections may carry [^passageId] markers). */
|
||||
getReport(id: string): Promise<Report>;
|
||||
|
||||
/** The run's citation ledger (URL, passage, quote, health, tier). */
|
||||
getLedger(id: string): Promise<CitationLedger>;
|
||||
|
||||
/** Export a run's report as a real downloadable Markdown/JSON file. */
|
||||
exportReport(
|
||||
id: string,
|
||||
format: "markdown" | "json",
|
||||
): Promise<{ filename: string; content: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
|
||||
* event replay, zero API keys). v0.2 P3: rebind to the OpenAPI-generated
|
||||
* client — this line is the only thing that changes.
|
||||
*/
|
||||
export const api: ApiClient = new MockApiClient();
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Methodology fixtures (T-2.1, REQ-53 + REQ-56).
|
||||
*
|
||||
* All 8 locked methodologies with the card metadata the ROADMAP
|
||||
* prescribes: ETA, cost estimate, why-recommended, verification level,
|
||||
* dial defaults. `recommended` is TRUE on deep-report ONLY.
|
||||
*/
|
||||
|
||||
import type { DialValues, Methodology } from "@/lib/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dial bundles */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const rapidAnswerDials: DialValues = {
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "web",
|
||||
queryStrategy: "broad",
|
||||
breadth: 1,
|
||||
depth: 1,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 1,
|
||||
synthesisStyle: "memo",
|
||||
outputStructure: "memo",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const deepReportDials: DialValues = {
|
||||
sourceUniverse: "web+academic",
|
||||
searchRouting: "hybrid",
|
||||
queryStrategy: "targeted",
|
||||
breadth: 2,
|
||||
depth: 3,
|
||||
qualityGates: "strict",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 2,
|
||||
synthesisStyle: "analytical",
|
||||
outputStructure: "report",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const sotaSurveyDials: DialValues = {
|
||||
sourceUniverse: "academic",
|
||||
searchRouting: "academic",
|
||||
queryStrategy: "iterative",
|
||||
breadth: 3,
|
||||
depth: 4,
|
||||
qualityGates: "strict",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 2,
|
||||
synthesisStyle: "encyclopedic",
|
||||
outputStructure: "report",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const systematicReviewDials: DialValues = {
|
||||
sourceUniverse: "academic",
|
||||
searchRouting: "academic",
|
||||
queryStrategy: "iterative",
|
||||
breadth: 5,
|
||||
depth: 5,
|
||||
qualityGates: "strict",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 3,
|
||||
synthesisStyle: "encyclopedic",
|
||||
outputStructure: "report",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan+draft",
|
||||
};
|
||||
|
||||
const factCheckDossierDials: DialValues = {
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "web",
|
||||
queryStrategy: "adversarial",
|
||||
breadth: 2,
|
||||
depth: 2,
|
||||
qualityGates: "strict",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 2,
|
||||
synthesisStyle: "adversarial",
|
||||
outputStructure: "dossier",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const multiPerspectiveDossierDials: DialValues = {
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "web",
|
||||
queryStrategy: "targeted",
|
||||
breadth: 3,
|
||||
depth: 3,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "section",
|
||||
verificationPasses: 2,
|
||||
synthesisStyle: "analytical",
|
||||
outputStructure: "dossier",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const competitiveLandscapeDials: DialValues = {
|
||||
sourceUniverse: "web",
|
||||
searchRouting: "web",
|
||||
queryStrategy: "targeted",
|
||||
breadth: 3,
|
||||
depth: 3,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 1,
|
||||
synthesisStyle: "analytical",
|
||||
outputStructure: "matrix",
|
||||
memoryScope: "none",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
const corpusInterrogationDials: DialValues = {
|
||||
sourceUniverse: "corpus",
|
||||
searchRouting: "corpus",
|
||||
queryStrategy: "iterative",
|
||||
breadth: 4,
|
||||
depth: 4,
|
||||
qualityGates: "standard",
|
||||
citationRigor: "per-claim",
|
||||
verificationPasses: 1,
|
||||
synthesisStyle: "encyclopedic",
|
||||
outputStructure: "report",
|
||||
memoryScope: "project",
|
||||
checkpoints: "plan-only",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The 8 locked methodologies */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const methodologyFixtures: readonly Methodology[] = [
|
||||
{
|
||||
slug: "rapid-answer",
|
||||
name: "Rapid Answer",
|
||||
tagline: "One question, one pass, one cited memo.",
|
||||
description:
|
||||
"A single breadth-1 sweep of the open web that answers a focused question in a short cited memo. No depth loops, no detours — built for questions you need answered in the next few minutes.",
|
||||
etaMinutes: 3,
|
||||
costEstimate: "$0.05 – $0.15",
|
||||
whyRecommended:
|
||||
"Pick this when your question is narrow and you need a trustworthy answer now — “what does X mean”, “when did Y happen”, “is Z still true in 2026”.",
|
||||
recommended: false,
|
||||
verification: "per-claim",
|
||||
dialSummary: [
|
||||
"Breadth: 1 · Depth: 1",
|
||||
"Web sources · memo output",
|
||||
"Per-claim citations",
|
||||
],
|
||||
defaultDials: rapidAnswerDials,
|
||||
badge: "Fastest",
|
||||
},
|
||||
{
|
||||
slug: "deep-report",
|
||||
name: "Deep Report",
|
||||
tagline: "The workhorse: multi-source executive report.",
|
||||
description:
|
||||
"A depth-3 run across web and academic sources that plans sub-questions, verifies every claim, and writes a structured executive report. The default methodology for any open-ended research question.",
|
||||
etaMinutes: 12,
|
||||
costEstimate: "$0.40 – $1.20",
|
||||
whyRecommended:
|
||||
"Pick this when your question is open-ended — “state of X”, “why does Y”, “should we Z” — and you need a structured, fully-cited report rather than a quick answer.",
|
||||
recommended: true,
|
||||
verification: "full",
|
||||
dialSummary: [
|
||||
"Breadth: 2 · Depth: 3",
|
||||
"Web + academic · strict gates",
|
||||
"Per-claim citations · full verification",
|
||||
],
|
||||
defaultDials: deepReportDials,
|
||||
},
|
||||
{
|
||||
slug: "sota-survey",
|
||||
name: "SOTA Survey",
|
||||
tagline: "What's new and what's next in a research area.",
|
||||
description:
|
||||
"Academic-first routing with an outline-first encyclopedic synthesis. Surveys recent literature, groups findings by theme, and maps where the frontier is moving.",
|
||||
etaMinutes: 25,
|
||||
costEstimate: "$0.90 – $2.50",
|
||||
whyRecommended:
|
||||
"Pick this when you need the current state of the art in a research area — recent papers, benchmark movement, open problems — not a general web answer.",
|
||||
recommended: false,
|
||||
verification: "full",
|
||||
dialSummary: [
|
||||
"Breadth: 3 · Depth: 4",
|
||||
"Academic routing · outline-first",
|
||||
"Per-claim citations · full verification",
|
||||
],
|
||||
defaultDials: sotaSurveyDials,
|
||||
},
|
||||
{
|
||||
slug: "systematic-review",
|
||||
name: "Systematic Review",
|
||||
tagline: "PRISMA-like rigor: screened, appraised, exhaustive.",
|
||||
description:
|
||||
"The strictest methodology in the catalog: an academic-only systematic sweep with screening criteria, quality appraisal, and a PRISMA-like flow diagram. Built for questions where exhaustiveness is the point.",
|
||||
etaMinutes: 45,
|
||||
costEstimate: "$2.00 – $6.00",
|
||||
whyRecommended:
|
||||
"Pick this when you intend to rely on the answer for a decision that must survive scrutiny — evidence synthesis, clinical or policy questions, “what does the literature actually support”.",
|
||||
recommended: false,
|
||||
verification: "standard",
|
||||
dialSummary: [
|
||||
"Breadth: 5 · Depth: 5",
|
||||
"Academic only · PRISMA-like flow",
|
||||
"Per-claim citations · 3 verification passes",
|
||||
],
|
||||
defaultDials: systematicReviewDials,
|
||||
},
|
||||
{
|
||||
slug: "fact-check-dossier",
|
||||
name: "Fact-Check Dossier",
|
||||
tagline: "Evidence for, evidence against, verdict.",
|
||||
description:
|
||||
"An adversarial run that steelmans both sides of a specific factual claim, anchors every piece of evidence to an exact quote, and closes with a verdict you can audit line by line.",
|
||||
etaMinutes: 8,
|
||||
costEstimate: "$0.20 – $0.60",
|
||||
whyRecommended:
|
||||
"Pick this when someone said something you need to confirm or refute — a statistic, a quote, a “studies show” claim — and the answer must cite chapter and verse.",
|
||||
recommended: false,
|
||||
verification: "per-claim",
|
||||
dialSummary: [
|
||||
"Breadth: 2 · Depth: 2",
|
||||
"Adversarial queries · exact-quote anchoring",
|
||||
"Per-claim citations",
|
||||
],
|
||||
defaultDials: factCheckDossierDials,
|
||||
},
|
||||
{
|
||||
slug: "multi-perspective-dossier",
|
||||
name: "Multi-Perspective Dossier",
|
||||
tagline: "Steelmanned views across a contested question.",
|
||||
description:
|
||||
"Maps the strongest version of each major perspective on a contested topic — assumptions, evidence, and where the disagreement actually lives — without flattening anyone's argument.",
|
||||
etaMinutes: 20,
|
||||
costEstimate: "$0.70 – $1.80",
|
||||
whyRecommended:
|
||||
"Pick this when reasonable people disagree and you need the disagreement itself documented — policy debates, competing predictions, “who's right about X”.",
|
||||
recommended: false,
|
||||
verification: "standard",
|
||||
dialSummary: [
|
||||
"Breadth: 3 · Depth: 3",
|
||||
"Perspective-mapped sections",
|
||||
"Standard verification",
|
||||
],
|
||||
defaultDials: multiPerspectiveDossierDials,
|
||||
},
|
||||
{
|
||||
slug: "competitive-landscape",
|
||||
name: "Competitive Landscape",
|
||||
tagline: "Who's building what, and how they compare.",
|
||||
description:
|
||||
"A comparison-matrix run: identifies the players in a space, fills each cell with individually provenance-backed evidence, and keeps the whole landscape on one page.",
|
||||
etaMinutes: 15,
|
||||
costEstimate: "$0.50 – $1.40",
|
||||
whyRecommended:
|
||||
"Pick this when the answer has to be a landscape, not a narrative — vendor comparisons, “which tools solve X”, market maps with per-cell sources.",
|
||||
recommended: false,
|
||||
verification: "per-claim",
|
||||
dialSummary: [
|
||||
"Breadth: 3 · Depth: 3",
|
||||
"Comparison matrix · per-cell provenance",
|
||||
"Per-claim citations",
|
||||
],
|
||||
defaultDials: competitiveLandscapeDials,
|
||||
},
|
||||
{
|
||||
slug: "corpus-interrogation",
|
||||
name: "Corpus Interrogation",
|
||||
tagline: "Your documents, interrogated in depth.",
|
||||
description:
|
||||
"A local-corpus run: no web search at all. Chunks and re-annotates your own document collection, then answers questions strictly from what the corpus contains — with quality annotations on every passage.",
|
||||
etaMinutes: 30,
|
||||
costEstimate: "$0.60 – $1.60",
|
||||
whyRecommended:
|
||||
"Pick this when the answer must come from material you already have — a document dump, an archive, a filing cabinet — and from nowhere else.",
|
||||
recommended: false,
|
||||
verification: "standard",
|
||||
dialSummary: [
|
||||
"Breadth: 4 · Depth: 4",
|
||||
"Corpus only · chunk + rerank",
|
||||
"Project memory · quality annotation",
|
||||
],
|
||||
defaultDials: corpusInterrogationDials,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Look up a methodology fixture by slug (case-insensitive). */
|
||||
export function findMethodologyBySlug(
|
||||
slug: string,
|
||||
): Methodology | undefined {
|
||||
return methodologyFixtures.find((m) => m.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* MockApiClient contract + behavior tests (T-2.3, REQ-53).
|
||||
*
|
||||
* Verifies the fixtures typecheck against the domain types, that
|
||||
* MockApiClient satisfies the ApiClient interface (compile-time
|
||||
* assertion lives in mock-client.ts; here we exercise behavior), and
|
||||
* that exports are real files with citations as real links.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ApiClient } from "@/lib/api/client";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { methodologyFixtures } from "@/lib/api/mock/fixtures";
|
||||
import { scriptedTraceEvents } from "@/lib/api/mock/scripted-run";
|
||||
import type { Methodology } from "@/lib/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixtures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("methodology fixtures", () => {
|
||||
it("typecheck: all 8 entries are valid Methodology objects", () => {
|
||||
const all: Methodology[] = [...methodologyFixtures];
|
||||
expect(all).toHaveLength(8);
|
||||
for (const methodology of all) {
|
||||
expect(typeof methodology.slug).toBe("string");
|
||||
expect(typeof methodology.name).toBe("string");
|
||||
expect(typeof methodology.tagline).toBe("string");
|
||||
expect(typeof methodology.description).toBe("string");
|
||||
expect(methodology.etaMinutes).toBeGreaterThan(0);
|
||||
expect(typeof methodology.costEstimate).toBe("string");
|
||||
expect(typeof methodology.whyRecommended).toBe("string");
|
||||
expect(typeof methodology.recommended).toBe("boolean");
|
||||
expect(["per-claim", "full", "standard"]).toContain(methodology.verification);
|
||||
expect(methodology.dialSummary.length).toBeGreaterThanOrEqual(3);
|
||||
expect(Object.keys(methodology.defaultDials)).toHaveLength(12);
|
||||
}
|
||||
});
|
||||
|
||||
it("cover the 8 locked slugs from the ROADMAP", () => {
|
||||
const slugs = methodologyFixtures.map((m) => m.slug);
|
||||
expect(slugs).toEqual([
|
||||
"rapid-answer",
|
||||
"deep-report",
|
||||
"sota-survey",
|
||||
"systematic-review",
|
||||
"fact-check-dossier",
|
||||
"multi-perspective-dossier",
|
||||
"competitive-landscape",
|
||||
"corpus-interrogation",
|
||||
]);
|
||||
});
|
||||
|
||||
it("mark exactly deep-report as recommended", () => {
|
||||
const recommended = methodologyFixtures.filter((m) => m.recommended);
|
||||
expect(recommended).toHaveLength(1);
|
||||
expect(recommended[0]?.slug).toBe("deep-report");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Scripted run */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("scripted trace", () => {
|
||||
it("replays 12–18+ events including plan, queries, sources, verification", () => {
|
||||
const events = scriptedTraceEvents("run-test");
|
||||
expect(events.length).toBeGreaterThanOrEqual(12);
|
||||
expect(events.length).toBeLessThanOrEqual(24);
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toContain("plan");
|
||||
expect(types.filter((t) => t === "query").length).toBeGreaterThanOrEqual(2);
|
||||
expect(types.filter((t) => t === "source_visited").length).toBe(6);
|
||||
expect(types).toContain("synthesis");
|
||||
expect(types).toContain("verification");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MockApiClient behavior (through the ApiClient-typed singleton) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("MockApiClient (bound as `api`)", () => {
|
||||
// The singleton is the ApiClient-typed binding — proves the boundary.
|
||||
const client: ApiClient = api;
|
||||
|
||||
it("returns 8 methodologies", async () => {
|
||||
const methodologies = await client.getMethodologies();
|
||||
expect(methodologies).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("createRun embeds the question and context into the plan", async () => {
|
||||
const run = await client.createRun({
|
||||
question: "What are the latest advances in solid-state batteries?",
|
||||
context: "Focus on automotive applications",
|
||||
methodologySlug: "deep-report",
|
||||
});
|
||||
expect(run.plan.question).toBe(
|
||||
"What are the latest advances in solid-state batteries?",
|
||||
);
|
||||
expect(run.plan.context).toBe("Focus on automotive applications");
|
||||
expect(run.plan.methodologySlug).toBe("deep-report");
|
||||
});
|
||||
|
||||
it("getRun returns the created run; getReport carries [^pN] markers", async () => {
|
||||
const question = "What are the latest advances in solid-state batteries?";
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const fetched = await client.getRun(run.id);
|
||||
expect(fetched.id).toBe(run.id);
|
||||
|
||||
const report = await client.getReport(run.id);
|
||||
expect(report.title).toContain(question);
|
||||
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
|
||||
expect(report.citations).toHaveLength(6);
|
||||
|
||||
const live = report.citations.filter((c) => c.verification === "live");
|
||||
const stale = report.citations.filter((c) => c.verification === "stale");
|
||||
expect(live).toHaveLength(5);
|
||||
expect(stale).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exports markdown with citations as real links", async () => {
|
||||
const question = "What are the latest advances in solid-state batteries?";
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const markdown = await client.exportReport(run.id, "markdown");
|
||||
expect(markdown.filename).toMatch(/\.md$/);
|
||||
expect(markdown.content).toContain("# Deep Report —");
|
||||
// Per-claim markers survive as footnote-style references…
|
||||
expect(markdown.content).toContain("[^p1]:");
|
||||
// …and each citation is a real Markdown link to its source URL.
|
||||
expect(markdown.content).toContain("](https://arxiv.org/abs/2401.00001)");
|
||||
expect(markdown.content).toContain("✓ live");
|
||||
expect(markdown.content).toContain("△ stale");
|
||||
});
|
||||
|
||||
it("exports JSON that round-trips the report", async () => {
|
||||
const question = "What are the latest advances in solid-state batteries?";
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const json = await client.exportReport(run.id, "json");
|
||||
expect(json.filename).toMatch(/\.json$/);
|
||||
const parsed = JSON.parse(json.content) as { runId: string };
|
||||
expect(parsed.runId).toBe(run.id);
|
||||
});
|
||||
|
||||
it("getLedger returns 6 citations with the run id and health mix", async () => {
|
||||
const question = "What are the latest advances in solid-state batteries?";
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const ledger = await client.getLedger(run.id);
|
||||
expect(ledger.runId).toBe(run.id);
|
||||
expect(ledger.citations).toHaveLength(6);
|
||||
expect(ledger.citations.filter((c) => c.verification === "stale")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replays trace events one by one then signals done", async () => {
|
||||
const question = "What are the latest advances in solid-state batteries?";
|
||||
const run = await client.createRun({ question, methodologySlug: "deep-report" });
|
||||
const seen: string[] = [];
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const subscription = client.getRunEvents(
|
||||
run.id,
|
||||
(event) => {
|
||||
seen.push(event.message);
|
||||
},
|
||||
() => {
|
||||
subscription.cancel(); // already done; cancel is a no-op
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
await done;
|
||||
expect(seen.length).toBeGreaterThanOrEqual(12);
|
||||
expect(seen[0]).toContain("Plan drafted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* MockApiClient — the v0.1 binding of the ApiClient interface (T-2.3, REQ-53).
|
||||
*
|
||||
* Fixture data + scripted event replay. Zero API keys, zero network.
|
||||
* The ONLY module that imports this is `@/lib/api/client` (the binding);
|
||||
* components always consume the `api` singleton through the interface.
|
||||
*/
|
||||
|
||||
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
|
||||
import { methodologyFixtures, findMethodologyBySlug } from "@/lib/api/mock/fixtures";
|
||||
import {
|
||||
buildScriptedLedger,
|
||||
buildScriptedReport,
|
||||
buildScriptedRun,
|
||||
scriptedTraceEvents,
|
||||
} from "@/lib/api/mock/scripted-run";
|
||||
import type {
|
||||
CitationLedger,
|
||||
Methodology,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
} from "@/lib/types";
|
||||
|
||||
/** Handle returned by getRunEvents — EventSource-shaped cancel(). */
|
||||
export interface RunEventSubscription {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Interval between replayed trace events (ms). */
|
||||
const EVENT_REPLAY_INTERVAL_MS = 150;
|
||||
|
||||
export class MockApiClient implements ApiClient {
|
||||
/** The most recent run created in this session (getRun/getReport/… key off it). */
|
||||
#lastRun: ResearchRun | undefined;
|
||||
|
||||
async getMethodologies(): Promise<Methodology[]> {
|
||||
return [...methodologyFixtures];
|
||||
}
|
||||
|
||||
async createRun(input: CreateRunInput): Promise<ResearchRun> {
|
||||
const methodology =
|
||||
findMethodologyBySlug(input.methodologySlug) ??
|
||||
methodologyFixtures.find((m) => m.recommended);
|
||||
if (!methodology) {
|
||||
throw new Error(`MockApiClient: unknown methodology "${input.methodologySlug}"`);
|
||||
}
|
||||
// dialOverrides are accepted and recorded per REQ-56's drawer contract;
|
||||
// the scripted run's behavior does not branch on them in the prototype.
|
||||
const run = buildScriptedRun({
|
||||
question: input.question,
|
||||
...(input.context !== undefined ? { context: input.context } : {}),
|
||||
methodologySlug: methodology.slug,
|
||||
});
|
||||
this.#lastRun = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async getRun(id: string): Promise<ResearchRun> {
|
||||
// A run id that matches the scripted question hash, or the most
|
||||
// recent created run — either resolves to the same scripted data.
|
||||
if (this.#lastRun?.id === id || this.#lastRun === undefined) {
|
||||
return structuredClone(this.#lastRun ?? this.#defaultRun());
|
||||
}
|
||||
if (id === this.#defaultRun().id) {
|
||||
return structuredClone(this.#defaultRun());
|
||||
}
|
||||
throw new Error(`MockApiClient: run "${id}" not found`);
|
||||
}
|
||||
|
||||
async approvePlan(id: string): Promise<ResearchRun> {
|
||||
const run = await this.getRun(id);
|
||||
return { ...run, status: "searching" };
|
||||
}
|
||||
|
||||
getRunEvents(
|
||||
id: string,
|
||||
onEvent: (event: TraceEvent) => void,
|
||||
onDone?: () => void,
|
||||
): RunEventSubscription {
|
||||
const events = scriptedTraceEvents(id);
|
||||
let index = 0;
|
||||
let cancelled = false;
|
||||
// Replay the scripted trace one event at a time, then signal done.
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
if (index >= events.length) {
|
||||
clearInterval(timer);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
onEvent(events[index] as TraceEvent);
|
||||
index += 1;
|
||||
}, EVENT_REPLAY_INTERVAL_MS);
|
||||
return {
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(id: string): Promise<Report> {
|
||||
const run = await this.getRun(id);
|
||||
return buildScriptedReport(run, run.plan.question);
|
||||
}
|
||||
|
||||
async getLedger(id: string): Promise<CitationLedger> {
|
||||
const run = await this.getRun(id);
|
||||
return buildScriptedLedger(run);
|
||||
}
|
||||
|
||||
async exportReport(
|
||||
id: string,
|
||||
format: "markdown" | "json",
|
||||
): Promise<{ filename: string; content: string }> {
|
||||
const run = await this.getRun(id);
|
||||
const report = buildScriptedReport(run, run.plan.question);
|
||||
if (format === "json") {
|
||||
return {
|
||||
filename: `${report.runId}-report.json`,
|
||||
content: JSON.stringify(report, null, 2),
|
||||
};
|
||||
}
|
||||
return {
|
||||
filename: `${report.runId}-report.md`,
|
||||
content: renderReportMarkdown(report),
|
||||
};
|
||||
}
|
||||
|
||||
/** The scripted run for the prototype's demo question. */
|
||||
#defaultRun(): ResearchRun {
|
||||
return buildScriptedRun({
|
||||
question: "What are the latest advances in solid-state batteries?",
|
||||
methodologySlug: "deep-report",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Markdown rendering — citations survive as real links */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Render a report as Markdown: title, sections, [^pN] markers, links. */
|
||||
function renderReportMarkdown(report: Report): string {
|
||||
const lines: string[] = [`# ${report.title}`, ""];
|
||||
|
||||
for (const section of report.sections) {
|
||||
lines.push(`## ${section.heading}`, "", section.body, "");
|
||||
}
|
||||
|
||||
// Citation markers become a reference list; each entry is a real link.
|
||||
lines.push("## Sources", "");
|
||||
for (const citation of report.citations) {
|
||||
const health =
|
||||
citation.verification === "live"
|
||||
? "✓ live"
|
||||
: citation.verification === "stale"
|
||||
? "△ stale"
|
||||
: "✗ dead";
|
||||
lines.push(
|
||||
`[^${citation.passageId}]: [${citation.title}](${citation.url}) — ${citation.site} · ${health} · tier ${citation.authorityTier}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/*
|
||||
* Compile-time contract assertion (REQ-53): MockApiClient satisfies
|
||||
* ApiClient. If this line typechecks, the binding in client.ts is
|
||||
* safe and v0.2's swap stays a one-line change.
|
||||
*/
|
||||
const _satisfies: ApiClient = new MockApiClient();
|
||||
void _satisfies;
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Scripted Deep Report run (T-2.2, REQ-53).
|
||||
*
|
||||
* A deterministic, question-parameterized fixture: a full ResearchRun
|
||||
* (planning → … → done) with a realistic trace (~16 events: plan,
|
||||
* queries fired, sources visited, passages extracted, synthesis,
|
||||
* verification, done), a final report with per-claim citation
|
||||
* markers [^p1]…[^p6], and a 6-citation ledger (5 live, 1 stale).
|
||||
*
|
||||
* All URLs are clearly example/fixture domains — no real claims made.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Citation,
|
||||
CitationLedger,
|
||||
Report,
|
||||
ResearchRun,
|
||||
TraceEvent,
|
||||
} from "@/lib/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Citations — 6 passages, 5 live + 1 stale, tiers 1–3 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const scriptedCitations: readonly Citation[] = [
|
||||
{
|
||||
id: "c1",
|
||||
url: "https://example.com/solid-state-battery-outlook-2026",
|
||||
title: "Solid-State Batteries: The 2026 Industry Outlook",
|
||||
site: "example.com",
|
||||
passageId: "p1",
|
||||
quote:
|
||||
"Solid-state cells replaced flammable liquid electrolytes with a ceramic separator, unlocking simultaneous gains in energy density and thermal safety.",
|
||||
verification: "live",
|
||||
authorityTier: 2,
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
url: "https://arxiv.org/abs/2401.00001",
|
||||
title: "Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
|
||||
site: "arxiv.org",
|
||||
passageId: "p2",
|
||||
quote:
|
||||
"Sulfide electrolytes exhibit ionic conductivity comparable to liquid electrolytes, but interface degradation during cycling remains the central obstacle to commercial cycle life.",
|
||||
verification: "live",
|
||||
authorityTier: 1,
|
||||
},
|
||||
{
|
||||
id: "c3",
|
||||
url: "https://example.com/japan-battery-pilot-line",
|
||||
title: "Automakers Begin Pilot Production of Solid-State Cells",
|
||||
site: "example.com",
|
||||
passageId: "p3",
|
||||
quote:
|
||||
"A consortium of Japanese automakers and chemical firms announced a pilot line targeting limited vehicle deployment of all-solid-state cells by 2027.",
|
||||
verification: "live",
|
||||
authorityTier: 2,
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
url: "https://example.org/energy-storage-safety-review",
|
||||
title: "Thermal Safety of Next-Generation Battery Chemistries",
|
||||
site: "example.org",
|
||||
passageId: "p4",
|
||||
quote:
|
||||
"Across abuse testing, solid-state prototypes exhibited substantially reduced heat release relative to conventional lithium-ion, though dendrite formation at high charge rates persists in some designs.",
|
||||
verification: "live",
|
||||
authorityTier: 2,
|
||||
},
|
||||
{
|
||||
id: "c5",
|
||||
url: "https://example.com/supply-chain-lithium-report",
|
||||
title: "Critical Materials Outlook: Lithium and Beyond",
|
||||
site: "example.com",
|
||||
passageId: "p5",
|
||||
quote:
|
||||
"Solid-state designs favor lithium-metal anodes, increasing per-cell lithium intensity and re-concentrating supply-chain risk on lithium refining capacity.",
|
||||
verification: "live",
|
||||
authorityTier: 3,
|
||||
},
|
||||
{
|
||||
id: "c6",
|
||||
url: "https://example.net/battery-startup-funding-2025",
|
||||
title: "Venture Funding Flows Into Solid-State Startups",
|
||||
site: "example.net",
|
||||
passageId: "p6",
|
||||
quote:
|
||||
"Venture funding for solid-state battery startups reached a record in 2025, though most funded companies remain pre-revenue.",
|
||||
verification: "stale",
|
||||
authorityTier: 3,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trace events */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Base timestamp — fixed so replays are byte-for-byte deterministic. */
|
||||
const T0 = "2026-09-27T12:00:00Z";
|
||||
|
||||
/** Milliseconds offset helper: T0 + n seconds. */
|
||||
function atSeconds(seconds: number): string {
|
||||
const t = Date.parse(T0);
|
||||
return new Date(t + seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The scripted trace: plan, 3 queries, 6 sources visited,
|
||||
* 6 passages extracted, synthesis, verification, done.
|
||||
*/
|
||||
export function scriptedTraceEvents(runId: string): TraceEvent[] {
|
||||
return [
|
||||
{
|
||||
id: `${runId}-e01`,
|
||||
ts: atSeconds(0),
|
||||
type: "plan",
|
||||
message: "Plan drafted — 4 sub-questions, 12 planned sources",
|
||||
detail:
|
||||
"Outline: 1) State of the technology 2) Remaining obstacles 3) Who is building it 4) What it means for the supply chain",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e02`,
|
||||
ts: atSeconds(4),
|
||||
type: "status",
|
||||
message: "Plan approved — run started",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e03`,
|
||||
ts: atSeconds(8),
|
||||
type: "query",
|
||||
message: 'Query fired: "solid-state battery energy density 2026"',
|
||||
},
|
||||
{
|
||||
id: `${runId}-e04`,
|
||||
ts: atSeconds(12),
|
||||
type: "query",
|
||||
message: 'Query fired: "all-solid-state lithium battery review"',
|
||||
},
|
||||
{
|
||||
id: `${runId}-e05`,
|
||||
ts: atSeconds(16),
|
||||
type: "query",
|
||||
message: 'Query fired: "solid-state battery pilot production automaker"',
|
||||
},
|
||||
{
|
||||
id: `${runId}-e06`,
|
||||
ts: atSeconds(24),
|
||||
type: "source_visited",
|
||||
message: "Visited: Solid-State Batteries: The 2026 Industry Outlook",
|
||||
detail: "example.com · authority tier 2",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e07`,
|
||||
ts: atSeconds(29),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p1 — ceramic separator density/safety gains",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e08`,
|
||||
ts: atSeconds(36),
|
||||
type: "source_visited",
|
||||
message:
|
||||
"Visited: Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review",
|
||||
detail: "arxiv.org · authority tier 1",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e09`,
|
||||
ts: atSeconds(41),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p2 — interface degradation obstacle",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e10`,
|
||||
ts: atSeconds(55),
|
||||
type: "source_visited",
|
||||
message: "Visited: Automakers Begin Pilot Production of Solid-State Cells",
|
||||
detail: "example.com · authority tier 2",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e11`,
|
||||
ts: atSeconds(60),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p3 — 2027 limited deployment target",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e12`,
|
||||
ts: atSeconds(72),
|
||||
type: "source_visited",
|
||||
message: "Visited: Thermal Safety of Next-Generation Battery Chemistries",
|
||||
detail: "example.org · authority tier 2",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e13`,
|
||||
ts: atSeconds(75),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p4 — reduced heat release vs lithium-ion",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e14`,
|
||||
ts: atSeconds(88),
|
||||
type: "source_visited",
|
||||
message: "Visited: Critical Materials Outlook: Lithium and Beyond",
|
||||
detail: "example.com · authority tier 3",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e15`,
|
||||
ts: atSeconds(91),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p5 — lithium intensity supply risk",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e16`,
|
||||
ts: atSeconds(102),
|
||||
type: "source_visited",
|
||||
message: "Visited: Venture Funding Flows Into Solid-State Startups",
|
||||
detail: "example.net · authority tier 3",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e17`,
|
||||
ts: atSeconds(105),
|
||||
type: "passage_extracted",
|
||||
message: "Stored passage p6 — record 2025 funding (rechecking source…)",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e18`,
|
||||
ts: atSeconds(150),
|
||||
type: "synthesis",
|
||||
message: "Depth loop 1 — executive summary drafted",
|
||||
detail: "Depth 1/3: covered technology state; adding obstacles section",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e19`,
|
||||
ts: atSeconds(210),
|
||||
type: "synthesis",
|
||||
message: "Depth loop 2 — obstacles and landscape sections drafted",
|
||||
detail: "Depth 2/3: gap found — supply-chain angle thin; expanding",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e20`,
|
||||
ts: atSeconds(270),
|
||||
type: "synthesis",
|
||||
message: "Depth loop 3 — supply-chain section expanded; draft complete",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e21`,
|
||||
ts: atSeconds(300),
|
||||
type: "verification",
|
||||
message: "Liveness pass: 5 live, 1 stale, 0 dead",
|
||||
detail:
|
||||
"p6 source (example.net) responded but content changed since extraction — marked stale",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e22`,
|
||||
ts: atSeconds(312),
|
||||
type: "verification",
|
||||
message: "Quote-anchor pass: 6/6 cited quotes matched stored passages",
|
||||
},
|
||||
{
|
||||
id: `${runId}-e23`,
|
||||
ts: atSeconds(320),
|
||||
type: "status",
|
||||
message: "Run complete — report and citation ledger written",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Run, report, ledger — question-parameterized */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Deterministic run id derived from the question. */
|
||||
export function scriptedRunId(question: string): string {
|
||||
return `run-${hash(question)}`;
|
||||
}
|
||||
|
||||
/** Tiny stable string hash (FNV-1a, 32-bit) for readable run ids. */
|
||||
function hash(input: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(36).padStart(7, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scripted Deep Report run. The question (and optional
|
||||
* context) are embedded verbatim into the plan, so every screen in the
|
||||
* click-path shows the user's own words.
|
||||
*/
|
||||
export function buildScriptedRun(input: {
|
||||
question: string;
|
||||
context?: string;
|
||||
methodologySlug: string;
|
||||
}): ResearchRun {
|
||||
const runId = scriptedRunId(input.question);
|
||||
return {
|
||||
id: runId,
|
||||
status: "done",
|
||||
plan: {
|
||||
id: `${runId}-plan`,
|
||||
question: input.question,
|
||||
...(input.context !== undefined ? { context: input.context } : {}),
|
||||
methodologySlug: input.methodologySlug,
|
||||
subQuestions: [
|
||||
"What is the current state of solid-state battery technology?",
|
||||
"What obstacles remain between prototypes and mass production?",
|
||||
"Which companies are building solid-state cells, and on what timeline?",
|
||||
"What does the shift mean for critical-material supply chains?",
|
||||
],
|
||||
sourceUniverse: [
|
||||
"open web (technology and industry press)",
|
||||
"academic preprints (arXiv, PubMed-adjacent)",
|
||||
"company announcements and pilot-line reports",
|
||||
],
|
||||
outline: [
|
||||
"Executive summary",
|
||||
"The state of the technology",
|
||||
"Remaining obstacles",
|
||||
"Who is building it — landscape",
|
||||
"Supply-chain implications",
|
||||
"Sources",
|
||||
],
|
||||
},
|
||||
startedAt: T0,
|
||||
events: scriptedTraceEvents(runId),
|
||||
};
|
||||
}
|
||||
|
||||
/** The scripted Deep Report — executive summary + 4 sections, [^pN] markers. */
|
||||
export function buildScriptedReport(
|
||||
run: ResearchRun,
|
||||
question: string,
|
||||
): Report {
|
||||
return {
|
||||
id: `${run.id}-report`,
|
||||
runId: run.id,
|
||||
title: `Deep Report — ${question}`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Executive summary",
|
||||
body:
|
||||
"Solid-state batteries have moved from laboratory promise to pilot production: ceramic-separator cells now deliver simultaneous gains in energy density and thermal safety [^p1], and a Japanese consortium targets limited vehicle deployment by 2027 [^p3]. The central remaining obstacle is not bulk conductivity — sulfide electrolytes now match liquids [^p2] — but interface degradation across charge cycles [^p2]. Abuse testing shows substantially reduced heat release versus conventional lithium-ion [^p4], though the lithium-metal anodes these designs favor re-concentrate supply risk on lithium refining [^p5]. Funding hit a record in 2025 even as most startups remain pre-revenue [^p6].",
|
||||
},
|
||||
{
|
||||
heading: "The state of the technology",
|
||||
body:
|
||||
"Replacing the flammable liquid electrolyte with a ceramic separator is the defining move of solid-state design, and it is what unlocks the chemistry's simultaneous density and safety gains [^p1]. On conductivity specifically, sulfide electrolytes have closed the gap: they now exhibit ionic conductivity comparable to liquid electrolytes [^p2].",
|
||||
},
|
||||
{
|
||||
heading: "Remaining obstacles",
|
||||
body:
|
||||
"The frontier problem has shifted from making ions move to keeping interfaces alive: interface degradation during cycling remains the central obstacle to commercial cycle life [^p2]. Safety testing is encouraging but not unqualified — solid-state prototypes show substantially reduced heat release in abuse testing, while dendrite formation at high charge rates persists in some designs [^p4].",
|
||||
},
|
||||
{
|
||||
heading: "Who is building it — landscape",
|
||||
body:
|
||||
"Industrialization is underway: a consortium of Japanese automakers and chemical firms has announced a pilot line targeting limited vehicle deployment of all-solid-state cells by 2027 [^p3]. Capital is following — venture funding for solid-state startups reached a record in 2025, though most funded companies remain pre-revenue [^p6].",
|
||||
},
|
||||
{
|
||||
heading: "Supply-chain implications",
|
||||
body:
|
||||
"The technology does not escape materials geography; it reshuffles it. Solid-state designs favor lithium-metal anodes, which increases per-cell lithium intensity and re-concentrates supply-chain risk on lithium refining capacity [^p5].",
|
||||
},
|
||||
],
|
||||
citations: [...scriptedCitations],
|
||||
};
|
||||
}
|
||||
|
||||
/** The run's citation ledger — the trust-loop audit record. */
|
||||
export function buildScriptedLedger(run: ResearchRun): CitationLedger {
|
||||
return {
|
||||
runId: run.id,
|
||||
citations: [...scriptedCitations],
|
||||
generatedAt: atSeconds(320),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Ask store (T-3.1, REQ-55).
|
||||
*
|
||||
* Module-level client state carrying question + context from /ask to
|
||||
* /choose. Deliberately simple for the prototype: a single mutable
|
||||
* record with typed accessors. If a later phase needs reactivity, this
|
||||
* is the ONLY module to upgrade — consumers already go through it.
|
||||
*/
|
||||
|
||||
export interface AskState {
|
||||
question: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
let state: AskState | undefined;
|
||||
|
||||
/** Store the ask payload (called on submit, before routing to /choose). */
|
||||
export function setAskState(next: AskState): void {
|
||||
state = {
|
||||
question: next.question,
|
||||
...(next.context !== undefined && next.context !== ""
|
||||
? { context: next.context }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the carried ask payload; undefined if /ask was never submitted. */
|
||||
export function getAskState(): AskState | undefined {
|
||||
return state ? { ...state } : undefined;
|
||||
}
|
||||
|
||||
/** Clear the carried payload (e.g. after a run is started). */
|
||||
export function clearAskState(): void {
|
||||
state = undefined;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Dial display helpers — render DialValues for the Advanced drawer
|
||||
* and the card's dial summary strip (T-3.2, REQ-56).
|
||||
*/
|
||||
|
||||
import type { DialDefinition, DialValues } from "@/lib/types";
|
||||
import { DIALS } from "@/lib/types";
|
||||
|
||||
/** Humanize a raw dial value: numbers pass through, strings title-case. */
|
||||
export function formatDialValue(value: DialValues[keyof DialValues]): string {
|
||||
if (typeof value === "number") return String(value);
|
||||
return value
|
||||
.split(/[-+]/)
|
||||
.map((part) =>
|
||||
part === "and" || part === "or"
|
||||
? part
|
||||
: part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("+");
|
||||
}
|
||||
|
||||
/** All 12 dial definitions (for the Advanced drawer). */
|
||||
export function allDials(): readonly DialDefinition[] {
|
||||
return DIALS;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* ResearchX domain types (T-1.3, REQ-53).
|
||||
*
|
||||
* The vocabulary of the ApiClient contract. These types are the mock/
|
||||
* backend boundary: v0.2 swaps MockApiClient for the OpenAPI-generated
|
||||
* client and these shapes absorb any drift — components never change.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dials */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The 12 methodology dials. Every run snapshots the resolved values,
|
||||
* making runs reproducible and auditable (REQ-04 vocabulary, surfaced
|
||||
* in the prototype via the Advanced dials drawer).
|
||||
*/
|
||||
export interface DialValues {
|
||||
/** Which sources may be consulted — "web", "academic", "corpus", … */
|
||||
sourceUniverse: string;
|
||||
/** How queries are routed — "web", "academic", "hybrid", … */
|
||||
searchRouting: string;
|
||||
/** How queries are formed — "broad", "targeted", "iterative" */
|
||||
queryStrategy: string;
|
||||
/** How many parallel sub-questions / branches (1–5) */
|
||||
breadth: number;
|
||||
/** How many synthesis-reflection loops (1–5) */
|
||||
depth: number;
|
||||
/** Evidence quality bar — "standard", "strict" */
|
||||
qualityGates: string;
|
||||
/** Citation strictness — "per-claim", "section", "loose" */
|
||||
citationRigor: string;
|
||||
/** Post-run verification passes (0–3) */
|
||||
verificationPasses: number;
|
||||
/** Prose voice — "encyclopedic", "analytical", "memo" */
|
||||
synthesisStyle: string;
|
||||
/** Document shape — "report", "memo", "matrix", "dossier" */
|
||||
outputStructure: string;
|
||||
/** Working memory — "none", "project" */
|
||||
memoryScope: string;
|
||||
/** Human checkpoint points — "plan-only", "plan+draft" */
|
||||
checkpoints: string;
|
||||
}
|
||||
|
||||
/** Static metadata for one dial — drives the Advanced drawer UI. */
|
||||
export interface DialDefinition {
|
||||
key: keyof DialValues;
|
||||
label: string;
|
||||
description: string;
|
||||
default: DialValues[keyof DialValues];
|
||||
}
|
||||
|
||||
/** All 12 dials with label + one-line description + default value. */
|
||||
export const DIALS: readonly DialDefinition[] = [
|
||||
{
|
||||
key: "sourceUniverse",
|
||||
label: "Source universe",
|
||||
description: "Which sources may be consulted — web, academic, corpus.",
|
||||
default: "web",
|
||||
},
|
||||
{
|
||||
key: "searchRouting",
|
||||
label: "Search routing",
|
||||
description: "How queries are routed across search backends.",
|
||||
default: "web",
|
||||
},
|
||||
{
|
||||
key: "queryStrategy",
|
||||
label: "Query strategy",
|
||||
description: "How search queries are formed and refined.",
|
||||
default: "targeted",
|
||||
},
|
||||
{
|
||||
key: "breadth",
|
||||
label: "Breadth",
|
||||
description: "Number of parallel sub-questions explored (1–5).",
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
key: "depth",
|
||||
label: "Depth",
|
||||
description: "Synthesis-refinement loops the engine runs (1–5).",
|
||||
default: 3,
|
||||
},
|
||||
{
|
||||
key: "qualityGates",
|
||||
label: "Quality gates",
|
||||
description: "Evidence bar sources must clear to be cited.",
|
||||
default: "standard",
|
||||
},
|
||||
{
|
||||
key: "citationRigor",
|
||||
label: "Citation rigor",
|
||||
description: "How densely claims are anchored to passages.",
|
||||
default: "per-claim",
|
||||
},
|
||||
{
|
||||
key: "verificationPasses",
|
||||
label: "Verification passes",
|
||||
description: "Post-run liveness and quote-anchor passes (0–3).",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: "synthesisStyle",
|
||||
label: "Synthesis style",
|
||||
description: "Prose voice of the final document.",
|
||||
default: "analytical",
|
||||
},
|
||||
{
|
||||
key: "outputStructure",
|
||||
label: "Output structure",
|
||||
description: "Overall document shape the writer targets.",
|
||||
default: "report",
|
||||
},
|
||||
{
|
||||
key: "memoryScope",
|
||||
label: "Memory scope",
|
||||
description: "Whether prior project knowledge is consulted.",
|
||||
default: "none",
|
||||
},
|
||||
{
|
||||
key: "checkpoints",
|
||||
label: "Checkpoints",
|
||||
description: "Where the run pauses for human review.",
|
||||
default: "plan-only",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Methodology */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** How thoroughly the trust loop verifies this methodology's output. */
|
||||
export type VerificationLevel = "per-claim" | "full" | "standard";
|
||||
|
||||
/**
|
||||
* A locked methodology: a named dial bundle plus the card-facing
|
||||
* metadata (ETA, cost, why-recommended) rendered on /choose.
|
||||
*/
|
||||
export interface Methodology {
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
/** Approximate wall-clock duration, in minutes (mock heuristic). */
|
||||
etaMinutes: number;
|
||||
/** Human-readable cost estimate, e.g. "$0.25 – $0.80". */
|
||||
costEstimate: string;
|
||||
/** Question-shaped guidance: when to pick this methodology. */
|
||||
whyRecommended: string;
|
||||
/** True only on the default pick (Deep Report in the prototype). */
|
||||
recommended: boolean;
|
||||
verification: VerificationLevel;
|
||||
/** 3–5 human-readable default summaries, e.g. "Breadth: 2 · Depth: 3". */
|
||||
dialSummary: string[];
|
||||
/** The dial bundle this methodology resolves to. */
|
||||
defaultDials: DialValues;
|
||||
/** Optional corner badge, e.g. "Fastest". */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Run */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type RunStatus =
|
||||
| "planning"
|
||||
| "awaiting_approval"
|
||||
| "searching"
|
||||
| "extracting"
|
||||
| "reflecting"
|
||||
| "synthesizing"
|
||||
| "verifying"
|
||||
| "done";
|
||||
|
||||
/** The plan the engine halts on until approved (REQ-14 vocabulary). */
|
||||
export interface RunPlan {
|
||||
id: string;
|
||||
question: string;
|
||||
context?: string;
|
||||
methodologySlug: string;
|
||||
subQuestions: string[];
|
||||
sourceUniverse: string[];
|
||||
outline: string[];
|
||||
}
|
||||
|
||||
/** A research run — the root aggregate the API speaks in. */
|
||||
export interface ResearchRun {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
plan: RunPlan;
|
||||
startedAt: string;
|
||||
events?: TraceEvent[];
|
||||
}
|
||||
|
||||
/** One line of the live trace stream (SSE in v0.2). */
|
||||
export interface TraceEvent {
|
||||
id: string;
|
||||
ts: string;
|
||||
type:
|
||||
| "query"
|
||||
| "source_visited"
|
||||
| "passage_extracted"
|
||||
| "synthesis"
|
||||
| "verification"
|
||||
| "plan"
|
||||
| "status";
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trust loop */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Post-run URL liveness classification (REQ-19 vocabulary). */
|
||||
export type CitationHealth = "live" | "stale" | "dead";
|
||||
|
||||
/** A page-anchored citation: exact quote + stable passage ID. */
|
||||
export interface Citation {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
site: string;
|
||||
passageId: string;
|
||||
/** Verbatim quote from the stored passage — hover shows exactly this. */
|
||||
quote: string;
|
||||
verification: CitationHealth;
|
||||
/** 1 = primary/authoritative, 2 = reputable, 3 = context. */
|
||||
authorityTier: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/** The per-run audit record of every consulted source (REQ-21). */
|
||||
export interface CitationLedger {
|
||||
runId: string;
|
||||
citations: Citation[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Report */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** A report section — body may carry [^passageId] citation markers. */
|
||||
export interface ReportSection {
|
||||
heading: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** The final document the Reading Room renders. */
|
||||
export interface Report {
|
||||
id: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
sections: ReportSection[];
|
||||
citations: Citation[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tasks (v2 task inbox vocabulary — typed early, rendered in P3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type TaskStatus = "open" | "in_progress" | "done";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
/** Where the task came from — e.g. a run id or "inbox". */
|
||||
source?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* T-1.1 smoke test — verifies the Vitest + jsdom + Testing Library wiring
|
||||
* is functional before the real test suites land (mock-client, ask, choose).
|
||||
*/
|
||||
describe("test harness", () => {
|
||||
it("runs in a jsdom environment", () => {
|
||||
expect(window).toBeDefined();
|
||||
expect(document).toBeDefined();
|
||||
expect(typeof document.createElement).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
css: false,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
exclude: ["node_modules/**", ".next/**"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user