From f9615fa74e419f9fd384a1c7218c69c77c4cf4ac Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 18 Jun 2026 21:03:33 +0300 Subject: [PATCH] feat: edit parsed records before importing The import preview only showed counts + skipped-row errors with no way to fix anything. Now every parsed record is editable before import: - backend: shared validatePatientImport() (used by the previewImport tool) + POST /api/ai/import/validate for dry-run re-validation; the import preview payload now carries the original records (and the source record on each invalid row) so the UI can edit them. - frontend: the import card gets a "Review & edit" dialog listing every record with a ready / needs-fixing badge; opening one reuses the full PatientFormDialog in a new non-persisting review mode (editable file number) and re-validates on save, so skipped rows can be fixed and included. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/ai.ts | 25 ++ backend/src/services/ai/import.ts | 33 +++ backend/src/services/ai/tools.ts | 28 +- .../components/chat/import-preview-card.tsx | 267 ++++++++++++++++-- .../components/chat/patient-form-dialog.tsx | 53 +++- frontend/lib/ai-chat.ts | 5 +- frontend/lib/ai-settings.ts | 13 + frontend/lib/i18n/locales/en/translation.json | 11 + 8 files changed, 376 insertions(+), 59 deletions(-) create mode 100644 backend/src/services/ai/import.ts diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts index b19fcd6..bd4310d 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/routes/ai.ts @@ -18,6 +18,7 @@ import { saveAiConfig, toAiConfig, } from "../services/ai/config.js"; +import { validatePatientImport } from "../services/ai/import.js"; import { getPolicy, savePolicy } from "../services/ai/policy.js"; import * as patients from "../services/patients.js"; @@ -189,3 +190,27 @@ aiRouter.post( } }, ); + +// --- Migration import re-validation (dry run) ------------------------------- +// Powers the "review & edit before import" UI: the client edits parsed records +// and calls this to refresh which are ready vs. need fixing. Writes nothing. +aiRouter.post( + "/import/validate", + requireAuth, + requireOrg, + requirePermission({ patient: ["write"] }), + async (req, res, next) => { + try { + const records = (req.body as { records?: unknown[] }).records; + if (!Array.isArray(records)) { + throw new HttpError(400, "records must be an array."); + } + if (records.length > 500) { + throw new HttpError(400, "Too many records (max 500)."); + } + res.json(validatePatientImport(records)); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/ai/import.ts b/backend/src/services/ai/import.ts new file mode 100644 index 0000000..1cc0906 --- /dev/null +++ b/backend/src/services/ai/import.ts @@ -0,0 +1,33 @@ +import { patientInputSchema } from "../../lib/patient-validation.js"; + +// Result of a dry-run validation of parsed patient records. `valid` holds the +// normalized, ready-to-commit records; `invalid` keeps the *original* record +// alongside its errors so the clinician can edit and re-validate it in the UI. +export type ImportValidation = { + valid: unknown[]; + invalid: { index: number; errors: string[]; record: unknown }[]; + total: number; +}; + +// Validate parsed patient records against the (tolerant) patient schema without +// writing anything. Shared by the chat `previewImport` tool and the +// re-validation endpoint the edit-before-import UI calls. +export function validatePatientImport(records: unknown[]): ImportValidation { + const valid: unknown[] = []; + const invalid: ImportValidation["invalid"] = []; + records.forEach((record, index) => { + const parsed = patientInputSchema.safeParse(record); + if (parsed.success) { + valid.push(parsed.data); + } else { + invalid.push({ + index, + errors: parsed.error.issues.map( + (i) => `${i.path.join(".") || "(root)"}: ${i.message}`, + ), + record, + }); + } + }); + return { valid, invalid, total: records.length }; +} diff --git a/backend/src/services/ai/tools.ts b/backend/src/services/ai/tools.ts index a7155b3..8f8d2db 100644 --- a/backend/src/services/ai/tools.ts +++ b/backend/src/services/ai/tools.ts @@ -10,7 +10,7 @@ import { appointmentInputSchema } from "../../lib/appointment-validation.js"; import { initialsFromName } from "../../lib/initials.js"; import { inventoryInputSchema } from "../../lib/inventory-validation.js"; import { invoiceInputSchema } from "../../lib/invoice-validation.js"; -import { patientInputSchema } from "../../lib/patient-validation.js"; +import { validatePatientImport } from "./import.js"; import { prescriptionInputSchema } from "../../lib/prescription-validation.js"; import { taskInputSchema } from "../../lib/task-validation.js"; import * as analytics from "../analytics.js"; @@ -620,30 +620,18 @@ export function createChatTools(ctx: ToolContext) { }), execute: async ({ records }) => { step(`Validating ${records.length} record(s)`); - const valid: unknown[] = []; - const invalid: { index: number; errors: string[] }[] = []; - records.forEach((rec, index) => { - const parsed = patientInputSchema.safeParse(rec); - if (parsed.success) { - valid.push(parsed.data); - } else { - invalid.push({ - index, - errors: parsed.error.issues.map( - (i) => `${i.path.join(".") || "(root)"}: ${i.message}`, - ), - }); - } - }); - // Hand the validated, ready-to-commit set to the UI for an approval - // card. The client posts these back to /api/ai/import on approval. + const { valid, invalid, total } = validatePatientImport(records); + // Hand the validated set + the raw records to the UI for an approval + // card. The client can edit any record, re-validate, and posts the valid + // set back to /api/ai/import on approval. `records` carries the originals + // so invalid rows are editable. writer.write({ type: "data-importPreview", - data: { valid, invalid, total: records.length }, + data: { records, valid, invalid, total }, }); step(`${valid.length} ready, ${invalid.length} skipped`); return { - total: records.length, + total, validCount: valid.length, invalidCount: invalid.length, invalid, diff --git a/frontend/components/chat/import-preview-card.tsx b/frontend/components/chat/import-preview-card.tsx index a924344..1c8dec2 100644 --- a/frontend/components/chat/import-preview-card.tsx +++ b/frontend/components/chat/import-preview-card.tsx @@ -1,33 +1,166 @@ "use client"; -import { AlertTriangle, Check, Database, X } from "lucide-react"; -import { useState } from "react"; +import { AlertTriangle, Check, Database, Pencil, X } from "lucide-react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; import type { ImportPreviewData } from "@/lib/ai-chat"; -import { commitImport } from "@/lib/ai-settings"; +import { commitImport, validateImport } from "@/lib/ai-settings"; +import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients"; import { notify } from "@/lib/toast"; type Status = "pending" | "committing" | "done" | "rejected"; +const str = (v: unknown): string => (v == null ? "" : String(v)); +const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); + +// Coerce an arbitrary parsed record (a normalized valid row, or a raw invalid +// one with bare-string lists / gender words) into a complete Patient the form +// can render and edit. Mirrors the backend's tolerant normalization. +function toPatientDraft(rec: unknown): Patient { + const r = (rec ?? {}) as Record; + const sx = str(r.sex).trim().toLowerCase(); + const sex: Patient["sex"] = sx.startsWith("f") || sx.startsWith("w") ? "F" : "M"; + const status = (["active", "inpatient", "discharged"] as const).includes( + r.status as Patient["status"], + ) + ? (r.status as Patient["status"]) + : "active"; + const obj = (v: unknown) => (v ?? {}) as Record; + + return { + fileNumber: str(r.fileNumber).replace(/\D/g, ""), + name: str(r.name), + age: Number(r.age) || 0, + sex, + pcp: str(r.pcp), + primaryProviderId: (r.primaryProviderId as string | null) ?? null, + status, + initials: str(r.initials), + alerts: arr(r.alerts).map(str), + allergies: arr(r.allergies).map((a) => + typeof a === "string" + ? { substance: a, reaction: "", severity: "mild" as AllergySeverity } + : { + substance: str(obj(a).substance), + reaction: str(obj(a).reaction), + severity: (["mild", "moderate", "severe"].includes( + obj(a).severity as string, + ) + ? obj(a).severity + : "mild") as AllergySeverity, + }, + ), + medications: arr(r.medications).map((m) => + typeof m === "string" + ? { name: m, dose: "", frequency: "" } + : { + name: str(obj(m).name), + dose: str(obj(m).dose), + frequency: str(obj(m).frequency), + }, + ), + problems: arr(r.problems).map((p) => + typeof p === "string" + ? { label: p, since: "" } + : { label: str(obj(p).label), since: str(obj(p).since) }, + ), + vitals: { + bp: str(obj(r.vitals).bp), + hr: str(obj(r.vitals).hr), + temp: str(obj(r.vitals).temp), + spo2: str(obj(r.vitals).spo2), + takenAt: str(obj(r.vitals).takenAt), + }, + vitalsTrend: { label: "", unit: "", points: [] }, + labs: arr(r.labs).map((l) => ({ + name: str(obj(l).name), + value: str(obj(l).value), + flag: (["normal", "high", "low", "critical"].includes( + obj(l).flag as string, + ) + ? obj(l).flag + : "normal") as LabFlag, + takenAt: str(obj(l).takenAt), + })), + labTrend: { label: "", unit: "", points: [] }, + encounters: arr(r.encounters).map((e) => ({ + type: str(obj(e).type) || "Visit", + date: str(obj(e).date), + provider: str(obj(e).provider), + summary: str(obj(e).summary), + })), + }; +} + // The human approval gate for the migration import. The agent proposes records -// (dry run, nothing written); the clinician reviews counts + issues here and -// must approve before anything is inserted via POST /api/ai/import. +// (dry run, nothing written); the clinician reviews counts, can open and edit +// any record (fixing skipped rows), and must approve before anything is +// inserted via POST /api/ai/import. export function ImportPreviewCard({ data }: { data: ImportPreviewData }) { const { t } = useTranslation(); const [status, setStatus] = useState("pending"); const [result, setResult] = useState<{ created: number; failed: number } | null>( null, ); + // Working set of records (editable). Older threads may lack `records`; fall + // back to the valid set so the card still works. + const [records, setRecords] = useState( + () => data.records ?? data.valid ?? [], + ); + // index → errors for rows that still fail validation. + const [invalid, setInvalid] = useState< + { index: number; errors: string[] }[] + >(() => data.invalid ?? []); + const [reviewOpen, setReviewOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + + const invalidByIndex = useMemo( + () => new Map(invalid.map((i) => [i.index, i.errors])), + [invalid], + ); + const validCount = records.length - invalidByIndex.size; + + // Re-validate the working set server-side after an edit. + const revalidate = async (next: unknown[]) => { + try { + const res = await validateImport(next); + setInvalid(res.invalid.map((i) => ({ index: i.index, errors: i.errors }))); + } catch { + /* keep prior validation state */ + } + }; + + const saveEdit = (index: number, record: Patient) => { + const next = records.map((r, i) => (i === index ? record : r)); + setRecords(next); + setEditingIndex(null); + void revalidate(next); + }; const approve = async () => { setStatus("committing"); try { - const res = await commitImport(data.valid); + // Send the whole working set; the backend re-validates and skips any + // still-invalid rows, returning created/failed. + const res = await commitImport(records); setResult({ created: res.created.length, failed: res.failed.length }); setStatus("done"); + setReviewOpen(false); notify.success( t("chat.importCard.importedTitle"), t("chat.importCard.importedBody", { count: res.created.length }), @@ -46,41 +179,41 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
{t("chat.importCard.title")} + {status === "pending" && records.length > 0 ? ( + + ) : null}
{t("chat.importCard.ready")}{" "} - {data.valid.length} + {validCount} - {data.invalid.length > 0 ? ( + {invalidByIndex.size > 0 ? ( {t("chat.importCard.skipped")}{" "} - {data.invalid.length} + {invalidByIndex.size} ) : null} {t("chat.importCard.total")}{" "} - {data.total} + {records.length}
- {data.invalid.length > 0 ? ( -
    - {data.invalid.slice(0, 5).map((issue) => ( -
  • - {t("chat.importCard.row", { index: issue.index + 1 })}:{" "} - {issue.errors[0]} - {issue.errors.length > 1 ? ` (+${issue.errors.length - 1})` : ""} -
  • - ))} - {data.invalid.length > 5 ? ( -
  • - {t("chat.importCard.more", { count: data.invalid.length - 5 })} -
  • - ) : null} -
+ {invalidByIndex.size > 0 && status === "pending" ? ( +

+ {t("chat.importCard.fixHint")} +

) : null} {status === "done" && result ? ( @@ -98,13 +231,13 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) { ) : (
)} + + {/* Review list: every parsed record, editable. */} + + + + {t("chat.importCard.reviewTitle")} + + {t("chat.importCard.reviewDescription")} + + + + {records.map((rec, index) => { + const errors = invalidByIndex.get(index); + const name = + str((rec as Record).name) || + t("chat.importCard.unnamed"); + return ( + + ); + })} + + + }> + {t("chat.importCard.reviewClose")} + + + + + + + {/* Edit one record in the full patient form (review mode — no write). */} + {editingIndex !== null ? ( + saveEdit(editingIndex, record)} + onOpenChange={(o) => { + if (!o) setEditingIndex(null); + }} + open={editingIndex !== null} + patient={toPatientDraft(records[editingIndex])} + /> + ) : null} ); } diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx index 23fcf50..30121ed 100644 --- a/frontend/components/chat/patient-form-dialog.tsx +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -46,6 +46,10 @@ type PatientFormDialogProps = { patient?: Patient; onCreated?: (fileNumber: string) => void; onSaved?: (patient: Patient) => void; + // Review mode: when provided, the form does NOT persist — it emits the edited + // record so a caller (e.g. the import review dialog) can stage it. The file + // number becomes editable so a clinician can fix an import row. + onDraft?: (record: Patient) => void; }; type AllergyDraft = { substance: string; reaction: string; severity: AllergySeverity }; @@ -206,9 +210,12 @@ export function PatientFormDialog({ patient, onCreated, onSaved, + onDraft, }: PatientFormDialogProps) { const { t } = useTranslation(); const isEdit = mode === "edit"; + // Review mode stages an edited record instead of writing it (import flow). + const isReview = Boolean(onDraft); // Reception registers demographics only — clinical sections are hidden (the // backend also redacts/ignores clinical data for this role). Show everything // while the role is still loading to avoid a flash for clinical users. @@ -333,6 +340,13 @@ export function PatientFormDialog({ })), }; + // Review mode: hand the edited record back to the caller, don't persist. + if (onDraft) { + onDraft(built); + onOpenChange(false); + return; + } + setSubmitting(true); setError(null); try { @@ -386,14 +400,20 @@ export function PatientFormDialog({ - {isEdit ? t("patientForm.editTitle") : t("patientForm.createTitle")} + {isReview + ? t("patientForm.reviewTitle") + : isEdit + ? t("patientForm.editTitle") + : t("patientForm.createTitle")} - {isEdit - ? t("patientForm.editDescription", { - name: patient?.name ?? "this", - }) - : t("patientForm.createDescription")} + {isReview + ? t("patientForm.reviewDescription") + : isEdit + ? t("patientForm.editDescription", { + name: patient?.name ?? "this", + }) + : t("patientForm.createDescription")} @@ -404,8 +424,17 @@ export function PatientFormDialog({ >
- - {!isEdit && ( + + setFileNumber(event.target.value.replace(/\D/g, "")) + : undefined + } + readOnly={!isReview} + value={fileNumber} + /> + {!isEdit && !isReview && ( diff --git a/frontend/lib/ai-chat.ts b/frontend/lib/ai-chat.ts index 060c974..31c0455 100644 --- a/frontend/lib/ai-chat.ts +++ b/frontend/lib/ai-chat.ts @@ -19,9 +19,12 @@ export type LabCardData = { }; export type ImportPreviewData = { + // Every parsed record (originals), so the clinician can edit any row. + records: unknown[]; // Validated, ready-to-commit records (server re-validates on commit). valid: unknown[]; - invalid: { index: number; errors: string[] }[]; + // Skipped rows, with their errors and the original record (for editing). + invalid: { index: number; errors: string[]; record: unknown }[]; total: number; }; diff --git a/frontend/lib/ai-settings.ts b/frontend/lib/ai-settings.ts index cd4dc07..10079bd 100644 --- a/frontend/lib/ai-settings.ts +++ b/frontend/lib/ai-settings.ts @@ -61,3 +61,16 @@ export async function commitImport( body: JSON.stringify({ records }), }); } + +// Re-validate edited import records (dry run) so the review UI can refresh which +// rows are ready vs. need fixing. Writes nothing. +export async function validateImport(records: unknown[]): Promise<{ + valid: unknown[]; + invalid: { index: number; errors: string[]; record: unknown }[]; + total: number; +}> { + return apiFetch("/api/ai/import/validate", { + method: "POST", + body: JSON.stringify({ records }), + }); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index f92a07c..f14732a 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -1075,6 +1075,14 @@ "approve": "Import {{count}} record(s)", "approve_one": "Import 1 record", "reject": "Discard", + "reviewEdit": "Review & edit", + "reviewTitle": "Review records", + "reviewDescription": "Open any record to edit it before importing. Fix the skipped ones to include them.", + "reviewClose": "Close", + "fixHint": "Open Review & edit to fix the skipped rows, or import the ready ones.", + "rowReady": "Ready to import", + "needsFix": "Needs fixing", + "unnamed": "Unnamed record", "importing": "Importing…", "rejectedNote": "Import discarded. Nothing was written.", "importedTitle": "Records imported", @@ -1293,6 +1301,9 @@ "saving": "Saving…", "saveChanges": "Save changes", "savePatient": "Save patient", + "saveDraft": "Save changes", + "reviewTitle": "Review record before import", + "reviewDescription": "Edit any field, then save to update this record in the import. Nothing is written until you import.", "saveError": "Could not save the patient.", "updatedTitle": "Record updated", "updatedBody": "{{name}}'s chart was saved.",