diff --git a/.context/app/questionnaire/ingestion.md b/.context/app/questionnaire/ingestion.md index 9335efe3..f08b3292 100644 --- a/.context/app/questionnaire/ingestion.md +++ b/.context/app/questionnaire/ingestion.md @@ -1,9 +1,11 @@ # Questionnaire — document ingestion > How an uploaded document becomes a populated questionnaire graph. Built by -> **F1.1** ([`../planning/features/f1.1.md`](../planning/features/f1.1.md)); -> **API-only** — the review/edit UI is P2. Every surface here is gated by -> `APP_QUESTIONNAIRES_ENABLED` (seeded off). +> **F1.1** ([`../planning/features/f1.1.md`](../planning/features/f1.1.md)); the +> review/edit UI is P2. Admins drive ingestion from the `UploadQuestionnaireDialog` +> on `/admin/questionnaires` (header button + empty-state CTA), which POSTs to the +> endpoint below. Every surface here is gated by `APP_QUESTIONNAIRES_ENABLED` +> (seeded off). ## The endpoint diff --git a/app/admin/questionnaires/page.tsx b/app/admin/questionnaires/page.tsx index 0ccab5b7..efcfaec3 100644 --- a/app/admin/questionnaires/page.tsx +++ b/app/admin/questionnaires/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { QuestionnairesTable } from '@/components/admin/questionnaires/questionnaires-table'; +import { UploadQuestionnaireDialog } from '@/components/admin/questionnaires/upload-questionnaire-dialog'; import { FieldHelp } from '@/components/ui/field-help'; import { API } from '@/lib/api/endpoints'; import { parseApiResponse, serverFetch } from '@/lib/api/server-fetch'; @@ -51,28 +52,31 @@ export default async function QuestionnairesListPage() { return (
-
-

- Questionnaires{' '} - -

- A questionnaire is a structured set of sections and questions an end user completes - through a streaming conversation rather than a form. An admin ingests a source - document (PDF / DOCX / MD / TXT) and an agent extracts its structure. -

-

This page

-

- Browse every ingested questionnaire with its latest version and structure counts. - Click a row to view its versions and full section / question graph. -

-
-

-

- Ingest, review, and edit conversational questionnaires. -

+
+
+

+ Questionnaires{' '} + +

+ A questionnaire is a structured set of sections and questions an end user completes + through a streaming conversation rather than a form. An admin ingests a source + document (PDF / DOCX / MD / TXT) and an agent extracts its structure. +

+

This page

+

+ Browse every ingested questionnaire with its latest version and structure counts. + Click a row to view its versions and full section / question graph. +

+
+

+

+ Ingest, review, and edit conversational questionnaires. +

+
+
diff --git a/components/admin/questionnaires/questionnaires-table.tsx b/components/admin/questionnaires/questionnaires-table.tsx index e8cd1de1..28077c53 100644 --- a/components/admin/questionnaires/questionnaires-table.tsx +++ b/components/admin/questionnaires/questionnaires-table.tsx @@ -6,8 +6,8 @@ * Read-only admin list of questionnaires. Modelled on the orchestration * `AgentsTable` but deliberately lean for the read-surface PR: debounced title * search, a status filter, prev/next pagination, and click-through to the detail - * page. No mutations — create is the existing ingestion endpoint (no UI yet) and - * edit affordances land in F2.1b (PR2). + * page. Create is the ingestion endpoint, driven by the `UploadQuestionnaireDialog` + * (header button + empty-state CTA); edit affordances live on the detail page. * * Hydrates from server-fetched `initialItems` / `initialMeta`, then re-fetches * `GET /api/v1/app/questionnaires` on filter/page changes. Fetch failures keep @@ -18,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronLeft, ChevronRight, Loader2, Search } from 'lucide-react'; +import { UploadQuestionnaireDialog } from '@/components/admin/questionnaires/upload-questionnaire-dialog'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { @@ -183,9 +184,13 @@ export function QuestionnairesTable({ initialItems, initialMeta }: Questionnaire {items.length === 0 ? ( - - No questionnaires yet. Ingest a document via{' '} - POST /api/v1/app/questionnaires to create one. + +
+

+ No questionnaires yet. Upload a document to create your first one. +

+ +
) : ( diff --git a/components/admin/questionnaires/upload-questionnaire-dialog.tsx b/components/admin/questionnaires/upload-questionnaire-dialog.tsx new file mode 100644 index 00000000..959b9bba --- /dev/null +++ b/components/admin/questionnaires/upload-questionnaire-dialog.tsx @@ -0,0 +1,400 @@ +'use client'; + +/** + * UploadQuestionnaireDialog — the admin trigger for ingesting a *new* questionnaire. + * + * Uploads a source document to `POST /api/v1/app/questionnaires`, which extracts + * its structure and creates a draft questionnaire. Optional admin metadata (goal + + * audience) overrides what the extractor would otherwise infer — every override + * field left blank is inferred. On success the dialog routes to the new + * questionnaire's detail page so the admin lands on the freshly extracted draft. + * + * Multipart, so it `fetch`es a `FormData` body directly (the JSON authoring runner + * doesn't fit). Mirrors the structure of {@link file://./reingest-dialog.tsx} but + * creates rather than replaces — so no destructive warning, and it captures full + * metadata up front. + */ + +import { useId, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2, Upload } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { FieldHelp } from '@/components/ui/field-help'; +import { API } from '@/lib/api/endpoints'; +import { parseApiResponse } from '@/lib/api/parse-response'; +import { + AUDIENCE_EXPERTISE_LEVELS, + AUDIENCE_SENSITIVITY_LEVELS, +} from '@/lib/app/questionnaire/types'; + +/** Allowed upload extensions — mirrors the server's `ALLOWED_EXTENSIONS`. */ +const ACCEPT = '.pdf,.docx,.md,.txt'; + +/** + * Sentinel for "let the extractor infer" on the enum selects. Radix Select forbids + * an empty-string item value, so a non-empty placeholder stands in for "unset"; + * it's never sent to the server (those keys are simply omitted from the FormData). + */ +const INFER = '__infer__'; + +interface UploadResult { + questionnaireId: string; + versionId: string; + sectionCount: number; + questionCount: number; + changeCount: number; +} + +export interface UploadQuestionnaireDialogProps { + /** Trigger button style — defaults to a primary "Upload questionnaire" button. */ + size?: React.ComponentProps['size']; + variant?: React.ComponentProps['variant']; + className?: string; +} + +export function UploadQuestionnaireDialog({ + size = 'default', + variant = 'default', + className, +}: UploadQuestionnaireDialogProps) { + const router = useRouter(); + const fileInputId = useId(); + const goalId = useId(); + const descriptionId = useId(); + const roleId = useId(); + const expertiseId = useId(); + const durationId = useId(); + const localeId = useId(); + const sensitivityId = useId(); + const notesId = useId(); + const tablesId = useId(); + const fileRef = useRef(null); + + const [open, setOpen] = useState(false); + const [goal, setGoal] = useState(''); + const [description, setDescription] = useState(''); + const [role, setRole] = useState(''); + const [expertiseLevel, setExpertiseLevel] = useState(INFER); + const [duration, setDuration] = useState(''); + const [locale, setLocale] = useState(''); + const [sensitivity, setSensitivity] = useState(INFER); + const [notes, setNotes] = useState(''); + const [extractTables, setExtractTables] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function reset() { + setGoal(''); + setDescription(''); + setRole(''); + setExpertiseLevel(INFER); + setDuration(''); + setLocale(''); + setSensitivity(INFER); + setNotes(''); + setExtractTables(false); + setError(null); + setBusy(false); + if (fileRef.current) fileRef.current.value = ''; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const file = fileRef.current?.files?.[0]; + if (!file) { + setError('Choose a document to upload.'); + return; + } + + setBusy(true); + setError(null); + + try { + const body = new FormData(); + body.set('file', file); + + // Only send non-empty overrides — the server treats blank/whitespace as + // "infer this", and unset enum selects must be omitted entirely. + const setIfPresent = (key: string, value: string) => { + const trimmed = value.trim(); + if (trimmed.length > 0) body.set(key, trimmed); + }; + setIfPresent('goal', goal); + setIfPresent('audience.description', description); + setIfPresent('audience.role', role); + if (expertiseLevel !== INFER) body.set('audience.expertiseLevel', expertiseLevel); + setIfPresent('audience.estimatedDurationMinutes', duration); + setIfPresent('audience.locale', locale); + if (sensitivity !== INFER) body.set('audience.sensitivity', sensitivity); + setIfPresent('audience.notes', notes); + if (extractTables) body.set('extractTables', 'true'); + + // Multipart — do NOT set Content-Type; the browser adds the boundary. + const res = await fetch(API.APP.QUESTIONNAIRES.ROOT, { + method: 'POST', + credentials: 'same-origin', + body, + }); + const parsed = await parseApiResponse(res); + if (!parsed.success) { + setError(parsed.error.message); + return; + } + // Land on the freshly extracted draft. Keep busy=true so the form stays + // disabled during navigation rather than flashing re-enabled. + setOpen(false); + router.push(`/admin/questionnaires/${parsed.data.questionnaireId}`); + } catch { + setError('Upload failed. Please try again.'); + setBusy(false); + } + } + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Upload questionnaire + + Upload a source document and an agent extracts its sections and questions into a new + draft. The audience and goal fields below are optional overrides — leave any blank to + let the extractor infer it. + + + +
void handleSubmit(e)} className="space-y-4"> +
+ + +
+ +
+ +