mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix: localize document summaries and refine mobile coach
This commit is contained in:
1 parent
f1a1887a01
commit
3aa8ccd541
22 files changed
+434
-417
No files matched your search
@@ -55,11 +55,32 @@ describe("DocumentSummaryBlock — a stored summary", () => {
|
||||
expect(html).toContain("A discharge letter from a city hospital.");
|
||||
expect(html).toContain("14 Feb 2026");
|
||||
expect(html).toContain('data-slot="document-detail-summary"');
|
||||
// Nothing claims it is being made — it already exists.
|
||||
// A stored result stays visible and can be explicitly regenerated.
|
||||
expect(html).not.toContain("being prepared");
|
||||
expect(html).toContain('data-slot="document-detail-summary-regenerate"');
|
||||
expect(html).toContain("Generate again");
|
||||
const header = html.match(
|
||||
/<div[^>]*data-slot="document-detail-summary-header"[^>]*>/,
|
||||
);
|
||||
expect(header?.[0]).toMatch(/\bflex-col\b/);
|
||||
expect(header?.[0]).toMatch(/\bsm:flex-row\b/);
|
||||
expect(html).not.toContain("document-detail-summary-generate");
|
||||
});
|
||||
|
||||
it("disables regeneration while a replacement is being generated", () => {
|
||||
const html = base({
|
||||
summary: "Stored prose.",
|
||||
summaryState: "READY",
|
||||
isGenerating: true,
|
||||
});
|
||||
|
||||
const action = html.match(
|
||||
/<button[^>]*data-slot="document-detail-summary-regenerate"[^>]*>/,
|
||||
);
|
||||
expect(action?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
|
||||
expect(html).toContain("Generating");
|
||||
});
|
||||
|
||||
it("shows a stored summary even if the state disagrees", () => {
|
||||
// The bytes are the truth. A stale state must never hide a real summary.
|
||||
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
|
||||
|
||||
@@ -260,6 +260,9 @@ export function DocumentDetailSheet({
|
||||
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
// Summarise/Show text requests cannot change this block's spinner or result.
|
||||
const storedSummary = useDocumentSummary();
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
@@ -296,6 +299,7 @@ export function DocumentDetailSheet({
|
||||
useEffect(() => {
|
||||
suggest.reset();
|
||||
summary.reset();
|
||||
storedSummary.reset();
|
||||
indexDoc.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentId]);
|
||||
@@ -399,17 +403,29 @@ export function DocumentDetailSheet({
|
||||
summary.mutate({ mode: aiMode, target: aiTarget, output });
|
||||
};
|
||||
|
||||
// Generate the STORED summary from the detail block. Same endpoint as the
|
||||
// action row, but the result is not routed into the transient panel: the
|
||||
// route persists a screened-clean summary, so refetching the document shows
|
||||
// it in place. This runs only on a click — nothing here generates on open.
|
||||
// Generate or regenerate the STORED summary from the detail block. Only the
|
||||
// stored-summary branch requests replacement; the empty-state action keeps
|
||||
// first-write-wins if another worker or tab finishes while AI is running.
|
||||
const generateStoredSummary = () => {
|
||||
if (!aiTarget || !documentId) return;
|
||||
summary.reset();
|
||||
summary.mutate(
|
||||
{ mode: aiMode, target: aiTarget, output: "summary" },
|
||||
storedSummary.reset();
|
||||
storedSummary.mutate(
|
||||
{
|
||||
onSuccess: () => {
|
||||
mode: aiMode,
|
||||
target: aiTarget,
|
||||
output: "summary",
|
||||
persist: true,
|
||||
replace: doc?.summary != null,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if ("summary" in result) {
|
||||
if (result.persistence === "withheld") {
|
||||
toast.error(result.summary);
|
||||
} else if (result.persistence === "failed") {
|
||||
toast.error(t("documents.detail.saveError"));
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
@@ -611,7 +627,7 @@ export function DocumentDetailSheet({
|
||||
: null
|
||||
}
|
||||
aiEnabled={aiEnabled}
|
||||
isGenerating={summary.isPending}
|
||||
isGenerating={storedSummary.isPending}
|
||||
actionsDisabled={capability.isPending}
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* provider call. With no AI provider configured the block renders the state
|
||||
* without an action rather than an button that would 422.
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -63,13 +63,42 @@ export function DocumentSummaryBlock({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A stored summary wins over every state: it is shown from storage.
|
||||
// A stored summary wins over every state: it is shown from storage. The
|
||||
// adjacent action is explicit, so opening the sheet never spends a provider
|
||||
// call; a successful replacement is persisted by the summary route.
|
||||
if (summary) {
|
||||
return (
|
||||
<section className="space-y-1.5" data-slot="document-detail-summary">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
<section className="space-y-2" data-slot="document-detail-summary">
|
||||
<div
|
||||
data-slot="document-detail-summary-header"
|
||||
className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
{aiEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="document-detail-summary-regenerate"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || actionsDisabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
isGenerating
|
||||
? "size-4 animate-spin motion-reduce:animate-none"
|
||||
: "size-4"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
{isGenerating
|
||||
? t("documents.detail.summary.generating")
|
||||
: t("documents.detail.summary.regenerate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-foreground text-sm">{summary}</p>
|
||||
{generatedAtLabel ? (
|
||||
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
|
||||
|
||||
@@ -30,8 +30,13 @@ import {
|
||||
type DocumentAiTarget,
|
||||
} from "./document-ai-transport";
|
||||
|
||||
/** The session-only describe result — a summary XOR the raw extracted text. */
|
||||
export type DocumentDescribeResult = { summary: string } | { text: string };
|
||||
/** The describe result: raw text, or a summary plus its storage outcome. */
|
||||
export type DocumentDescribeResult =
|
||||
| {
|
||||
summary: string;
|
||||
persistence?: "stored" | "withheld" | "failed";
|
||||
}
|
||||
| { text: string };
|
||||
|
||||
/**
|
||||
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
|
||||
@@ -95,8 +100,8 @@ export function useSuggestDetails() {
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand, session-only summary or extracted text. The result is rendered
|
||||
* once and never persisted (P2-D4); closing the panel discards it.
|
||||
* On-demand summary or extracted text. Both stay transient unless the document
|
||||
* summary block explicitly requests persistence or replacement.
|
||||
*/
|
||||
export function useDocumentSummary() {
|
||||
return useMutation<
|
||||
@@ -106,11 +111,13 @@ export function useDocumentSummary() {
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
output: DocumentSummaryMode;
|
||||
persist?: boolean;
|
||||
replace?: boolean;
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ mode, target, output }) =>
|
||||
mutationFn: ({ mode, target, output, persist = false, replace = false }) =>
|
||||
runDocumentAi<DocumentDescribeResult>({
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
|
||||
mode,
|
||||
target,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user