Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions .context/app/planning/development-plan.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions .context/app/planning/features/f9.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
feature: F9.2
title: Operational runbook
phase: P9 — Hardening + forking docs
status: in flight
owner: TBD
status: shipped
owner: John / Simon
deps: F9.1, F9.4 (demo content seed — built first so the runbook can exercise it)
opened: 2026-06-09
plan: .context/app/planning/development-plan.md#f92--operational-runbook
Expand Down Expand Up @@ -57,8 +57,8 @@ path so a stock demo needs zero manual setup.
- `npm run validate` clean (the runbook + tracker are docs; no code beyond F9.4).
- Doc links resolve (sibling `.md` files exist; `forking.md` is a known P9 forward-reference,
matching the existing `demo-clients.md` reference).
- **Pending — human road-test.** John/Simon run the checklist on a clean machine; friction is
folded into the doc, then the feature flips to `shipped`.
- **Done — human road-test.** John/Simon ran the checklist on a clean machine; the runbook was
corrected against the friction and the feature flipped to `shipped`.

## No CHANGELOG entry

Expand Down
13 changes: 8 additions & 5 deletions .context/app/planning/features/f9.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,19 @@ it is pure demo scaffolding that takes the same persistence path a real upload d
- **Replace-on-rerun idempotency.** The demo client upserts by unique `slug`; the questionnaire
is found-and-replaced by a stable title marker (`Northwind Logistics — Onboarding Experience
Review`). Editing the sample content and re-seeding refreshes the demo with no duplicates.
- **Profile capture on.** `anonymousMode` stays false and two profile fields (name, work email)
are seeded, so the demo visibly shows the F8.3 session-start profile step.
- **Anonymous, with contradiction flagging on.** `anonymousMode: true` (no profile fields) so the
admin can one-click "Preview as respondent" through the no-login public surface, and
`contradictionMode: 'flag'` so the chat's "I noticed something" callout fires on inconsistent
answers — the two demo-polish surfacings depend on this config. (Originally seeded with
`anonymousMode: false` + name/email profile capture; changed during the demo-polish pass.)

## Build shape (branch `feat/F9.4-demo-seed-and-F9.2-runbook`)

- **Seed unit** — `prisma/seeds/app-questionnaire/025-demo-content.ts`, name
`app-questionnaire/025-demo-content` (next free prefix after `024`). Env-gate → upsert demo
client → replace prior demo questionnaire → create questionnaire (attributed) → version
(goal + audience + provenance) → `writeGraph` (2 sections, 6 slots spanning the question-type
range) → config row (defaults + profile fields) → flip version to `launched`. Whole file
range) → config row (anonymous + contradiction-flag) → flip version to `launched`. Whole file
marked `// DEMO-ONLY`; nothing imports it, so a fork deletes it wholesale.
- **`.env.example`** — a documented, commented `# LOAD_DEMO_CONTENT=1` block (DEMO-ONLY) under
a new "Demo content" section, including the re-load recipe for the no-op gotcha.
Expand All @@ -77,13 +80,13 @@ Vertical: **B2B SaaS onboarding feedback** — "Northwind Logistics" demo client
CTA/accent/logo/welcome copy) and a launched questionnaire "Northwind Logistics — Onboarding
Experience Review": 2 sections (Getting started · Value & support), 6 questions covering
`likert | free_text | single_choice | numeric | multi_choice | boolean`, a full 7-field
audience, and name/email profile capture.
audience, run anonymously with contradiction flagging on.

## Verification

- `LOAD_DEMO_CONTENT=1 npm run db:seed` — seeds the client + launched questionnaire; logged.
- Row check: 1 demo client, 1 questionnaire (attributed), 1 version (`launched`, goal set),
2 sections, 6 slots, 1 config row (2 profile fields, `anonymousMode=false`).
2 sections, 6 slots, 1 config row (`anonymousMode=true`, `contradictionMode='flag'`, no profile fields).
- `npm run db:seed` again **without** the env var → seed no-ops (skip log), creates nothing.
- `npm run validate` clean (type-check + lint + format).

Expand Down
8 changes: 6 additions & 2 deletions .context/app/questionnaire/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ This creates (idempotently — safe to re-run):
- **Demo client** "Northwind Logistics (Demo)" (slug `northwind-logistics-demo`), branded
(CTA/accent colours, logo, welcome copy).
- **Launched questionnaire** "Northwind Logistics — Onboarding Experience Review" — 2 sections,
6 questions, attributed to that client, with name + work-email profile capture on.
6 questions, attributed to that client. Runs **anonymously** (so you can one-click "Preview as
respondent" from the questionnaire's admin page — no email needed) with **contradiction
flagging** on (give inconsistent answers and the chat surfaces an "I noticed something" callout;
needs the contradiction + live-sessions flags on — see §0).

Then skip to **§3 Invite a respondent**. To confirm it loaded, open `/admin/questionnaires` —
Then skip to **§3 Invite a respondent** — or just hit **Preview as respondent** on the
questionnaire's admin page to try it yourself immediately. To confirm it loaded, open `/admin/questionnaires` —
the questionnaire shows `launched` and attributed to Northwind.

> **Gotcha — it didn't appear?** The seed no-ops unless `LOAD_DEMO_CONTENT=1`. If `db:seed` ran
Expand Down
41 changes: 41 additions & 0 deletions app/admin/questionnaires/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { VersionGraph } from '@/components/admin/questionnaires/version-graph';
import { VersionEditor } from '@/components/admin/questionnaires/version-editor';
import { ReingestDialog } from '@/components/admin/questionnaires/reingest-dialog';
import { CloneForClientDialog } from '@/components/admin/questionnaires/clone-for-client-dialog';
import { LaunchChecklist } from '@/components/admin/questionnaires/launch-checklist';
import { QUESTIONNAIRE_STATUS_BADGE } from '@/components/admin/questionnaires/status-badge';
import { DemoClientAssign } from '@/components/admin/demo-clients/demo-client-assign';
import { Badge } from '@/components/ui/badge';
Expand All @@ -16,6 +17,7 @@ import { logger } from '@/lib/logging';
import {
isAdaptiveSelectionEnabled,
isDesignEvaluationEnabled,
isLiveSessionsEnabled,
isQuestionnairesEnabled,
} from '@/lib/app/questionnaire/feature-flag';
import type { AttributedDemoClient, DemoClientView } from '@/lib/app/questionnaire/demo-clients';
Expand Down Expand Up @@ -94,6 +96,9 @@ export default async function QuestionnaireDetailPage({ params, searchParams }:
const adaptiveEnabled = editing ? await isAdaptiveSelectionEnabled() : false;
// Design-evaluation sub-flag — gates the Evaluations entry (the run route 404s when off).
const designEvalEnabled = selected ? await isDesignEvaluationEnabled() : false;
// Live-sessions sub-flag — gates the "Preview as respondent" link (the /q surface 404s when off).
const liveSessionsEnabled =
selected?.status === 'launched' ? await isLiveSessionsEnabled() : false;

return (
<div className="space-y-6">
Expand Down Expand Up @@ -172,6 +177,42 @@ export default async function QuestionnaireDetailPage({ params, searchParams }:
)}
</p>
<div className="flex items-center gap-2">
{/* Review & Launch (F2.1 surfacing) — a draft's primary action, with the
launch-gate checklist shown before the flip. Outside edit mode so launch
isn't buried behind Edit. */}
{selected.status === 'draft' && graph && (
<LaunchChecklist
questionnaireId={id}
versionId={selected.id}
versionNumber={selected.versionNumber}
goal={graph.goal}
audience={graph.audience}
sectionCount={selected.sectionCount}
questionCount={selected.questionCount}
configSaved={graph.config.saved}
/>
)}
{/* Preview as respondent — one-click "try it" via the no-login public surface.
Only works on a launched, anonymous-mode version (the /q route's gates); the
demo seed satisfies both. */}
{liveSessionsEnabled &&
graph &&
(graph.config.anonymousMode ? (
<Button asChild variant="outline" size="sm">
<Link href={`/q/${selected.id}`} target="_blank" rel="noopener noreferrer">
Preview as respondent
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled
title="Enable anonymous mode in this version's configuration to preview as a respondent."
>
Preview as respondent
</Button>
))}
{/* Invitations (F3.2) are managed per-questionnaire across launched versions. */}
<Button asChild variant="outline" size="sm">
<Link href={`/admin/questionnaires/${id}/invitations`}>Invitations</Link>
Expand Down
10 changes: 10 additions & 0 deletions app/api/v1/app/questionnaire-sessions/_lib/turn-invokers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { prisma } from '@/lib/db/client';
import { capabilityDispatcher } from '@/lib/orchestration/capabilities/dispatcher';
import { registerBuiltInCapabilities } from '@/lib/orchestration/capabilities';
import {
DETECT_CONTRADICTIONS_CAPABILITY_SLUG,
EXTRACT_ANSWER_SLOTS_CAPABILITY_SLUG,
Expand Down Expand Up @@ -79,6 +80,15 @@ export async function buildTurnInvokers(opts: {
adaptiveEnabled: boolean;
}): Promise<CapabilityInvokers> {
const { userId, slots, activeQuestionKey, adaptiveEnabled } = opts;

// Flush the built-in + app capability handlers into the dispatcher before any
// invoker dispatches. The turn loop calls `capabilityDispatcher.dispatch()` directly
// (not through the orchestration chat handler / agent-call executor, which is where the
// platform normally registers), so on a fresh server process that has only served
// questionnaire traffic the handler map would otherwise be empty and every capability
// dispatch would return `unknown_capability`. Idempotent (one-shot inside the registry).
registerBuiltInCapabilities();

const [extractor, detector, refiner] = await Promise.all([
loadBinding(QUESTIONNAIRE_ANSWER_EXTRACTOR_AGENT_SLUG),
loadBinding(QUESTIONNAIRE_CONTRADICTION_DETECTOR_AGENT_SLUG),
Expand Down
162 changes: 162 additions & 0 deletions components/admin/questionnaires/launch-checklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
'use client';

/**
* LaunchChecklist (F2.1 surfacing) — a "Review & Launch" affordance for a draft version.
*
* The launch endpoint already enforces the F3.1 gate (goal + audience + ≥1 section +
* ≥1 question + a saved config row); the bare "Launch" button inside the editor only
* surfaced a failure as a raw error string. This dialog shows the same five criteria as a
* green/grey checklist computed client-side from the already-fetched graph, and enables
* Launch only when all pass — so the admin sees *why* a version isn't ready before they
* click. The button calls the same `versionStatus` route; no new backend.
*
* The readiness checks mirror `assertLaunchable` in
* `app/api/v1/app/questionnaires/[id]/versions/[vid]/status/route.ts` exactly — keep them
* in sync. The server stays the source of truth (it re-checks on PATCH); this is UX.
*/

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Check, Loader2, Rocket, X } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { API } from '@/lib/api/endpoints';
import { authoringMutate } from '@/components/admin/questionnaires/authoring-mutate';
import type { AudienceShape } from '@/lib/app/questionnaire/types';

export interface LaunchChecklistProps {
questionnaireId: string;
versionId: string;
versionNumber: number;
goal: string | null;
audience: AudienceShape | null;
sectionCount: number;
questionCount: number;
/** True once a config row exists for the version (the launch gate's deliberate signal). */
configSaved: boolean;
}

/**
* Mirrors `hasAudience` in the status route: an audience JSON counts only when it carries
* at least one defined field (the editor may persist an empty `{}`).
*/
function hasAudience(audience: AudienceShape | null): boolean {
return (
typeof audience === 'object' &&
audience !== null &&
!Array.isArray(audience) &&
Object.values(audience as Record<string, unknown>).some((v) => v !== undefined && v !== null)
);
}

function ChecklistRow({ ok, children }: { ok: boolean; children: React.ReactNode }) {
return (
<li className="flex items-center gap-2 text-sm">
{ok ? (
<Check className="h-4 w-4 shrink-0 text-emerald-600" aria-hidden="true" />
) : (
<X className="text-muted-foreground/60 h-4 w-4 shrink-0" aria-hidden="true" />
)}
<span className={ok ? 'text-foreground' : 'text-muted-foreground'}>{children}</span>
<span className="sr-only">{ok ? '(ready)' : '(not ready)'}</span>
</li>
);
}

export function LaunchChecklist({
questionnaireId,
versionId,
versionNumber,
goal,
audience,
sectionCount,
questionCount,
configSaved,
}: LaunchChecklistProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const checks = [
{ ok: Boolean(goal && goal.trim().length > 0), label: 'A goal is set' },
{ ok: hasAudience(audience), label: 'An audience is described' },
{ ok: sectionCount >= 1, label: 'At least one section' },
{ ok: questionCount >= 1, label: 'At least one question' },
{ ok: configSaved, label: 'Configuration saved' },
];
const ready = checks.every((c) => c.ok);

const launch = () => {
setBusy(true);
setError(null);
authoringMutate('PATCH', API.APP.QUESTIONNAIRES.versionStatus(questionnaireId, versionId), {
status: 'launched',
})
.then(() => {
setOpen(false);
router.refresh();
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Could not launch this version.');
})
.finally(() => setBusy(false));
};

return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setError(null);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<Rocket className="mr-1.5 h-4 w-4" aria-hidden="true" />
Review &amp; Launch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Launch v{versionNumber}</DialogTitle>
<DialogDescription>
Launching makes this version available to respondents. Once launched, editing it forks a
new draft so in-flight sessions stay pinned to what they started.
</DialogDescription>
</DialogHeader>

<ul className="space-y-2 py-1">
{checks.map((c) => (
<ChecklistRow key={c.label} ok={c.ok}>
{c.label}
</ChecklistRow>
))}
</ul>

{!ready && (
<p className="text-muted-foreground text-xs">
Finish the unchecked items above to launch.
</p>
)}
{error && <p className="text-destructive text-sm">{error}</p>}

<DialogFooter>
<Button onClick={launch} disabled={!ready || busy}>
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" />}
Launch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
56 changes: 56 additions & 0 deletions components/app/questionnaire/chat/contradiction-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

/**
* ContradictionNotice — the "the agent noticed something" callout in the respondent
* chat (F7.2 surfacing).
*
* When the per-turn orchestrator's contradiction detection (F4.3) flags a possible
* inconsistency, it streams a `warning` event with `code: 'contradiction'` whose message
* is the agent's `suggestedProbe`/`explanation`. The chat renders most warnings as a quiet
* fail-soft line; this one is the single best "the AI is reasoning about your answers"
* signal, so it gets a tasteful accent-bordered callout instead. Presentational only —
* the message text is decided upstream.
*
* Brand colour comes from the page's `BrandThemeProvider` CSS vars, matching the
* `AssistantTurn` accent dot.
*
* `// DEMO-ONLY (F7.2):` questionnaire-domain notice.
*/

import { Sparkles } from 'lucide-react';

import { cn } from '@/lib/utils';

export interface ContradictionNoticeProps {
/** The agent's probe / explanation of the possible inconsistency. */
message: string;
className?: string;
}

export function ContradictionNotice({ message, className }: ContradictionNoticeProps) {
return (
<div
role="status"
className={cn(
'flex gap-2.5 rounded-lg border px-3 py-2.5 text-sm leading-relaxed',
className
)}
style={{
borderColor:
'color-mix(in srgb, var(--app-accent-color, var(--color-primary)) 40%, transparent)',
backgroundColor:
'color-mix(in srgb, var(--app-accent-color, var(--color-primary)) 6%, transparent)',
}}
>
<Sparkles
className="mt-0.5 h-4 w-4 shrink-0"
style={{ color: 'var(--app-accent-color, var(--color-primary))' }}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-foreground text-xs font-medium">I noticed something</p>
<p className="text-muted-foreground mt-0.5">{message}</p>
</div>
</div>
);
}
Loading