mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 01:59:30 +00:00
feat(chat): file attachments as chips, CoT closed, AI add-to-inventory
Chat fixes (#1/#6/#7): - chat-input passes raw File[] up instead of inlining file text - chat-panel sends files as FileUIPart and renders them with the ai-elements Attachments component (no more raw text dumps) - backend extracts text-like file content for the model in routes/chat - Chain-of-Thought now defaults to collapsed - file upload works regardless of whether the input has text AI add-to-inventory (#2): - new proposeInventory tool (validates items, streams an approval card) - system prompt distinguishes stocking inventory vs. billing a patient - ActionPreviewCard commits inventory via POST /api/inventory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,50 @@ export const chatRouter = Router();
|
||||
|
||||
chatRouter.use(requireAuth, requireOrg, requirePermission({ patient: ["read"] }));
|
||||
|
||||
// Text-like uploads (CSV/JSON/TXT/…) the model should read as parseable text
|
||||
// rather than an opaque data URL. Images/PDFs are left as file parts so
|
||||
// vision-capable providers read them directly.
|
||||
const TEXT_LIKE_MEDIA = /^(text\/|application\/(json|xml|csv|x-ndjson))/i;
|
||||
const TEXT_LIKE_EXT = /\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i;
|
||||
|
||||
function decodeDataUrl(url: string): string {
|
||||
const comma = url.indexOf(",");
|
||||
if (comma === -1) return "";
|
||||
const meta = url.slice(0, comma);
|
||||
const data = url.slice(comma + 1);
|
||||
return meta.includes("base64")
|
||||
? Buffer.from(data, "base64").toString("utf8")
|
||||
: decodeURIComponent(data);
|
||||
}
|
||||
|
||||
// Replace text-like file parts with a text part carrying the file's content
|
||||
// (capped) so the agent can parse uploads (e.g. a medications list to add to
|
||||
// inventory, or a database export to import). Display/storage are unaffected —
|
||||
// this only shapes what the model sees.
|
||||
function inlineTextFiles(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.parts)) return message;
|
||||
const parts = message.parts.flatMap((part) => {
|
||||
if (
|
||||
part.type === "file" &&
|
||||
typeof part.url === "string" &&
|
||||
(TEXT_LIKE_MEDIA.test(part.mediaType ?? "") ||
|
||||
TEXT_LIKE_EXT.test(part.filename ?? ""))
|
||||
) {
|
||||
const content = decodeDataUrl(part.url).slice(0, 200_000);
|
||||
return [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `--- File: ${part.filename ?? "file"} ---\n${content}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [part];
|
||||
});
|
||||
return { ...message, parts } as UIMessage;
|
||||
});
|
||||
}
|
||||
|
||||
function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||
return [
|
||||
"You are temetro, a clinical assistant that helps clinicians retrieve,",
|
||||
@@ -55,10 +99,15 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||
" asks to add/book/create one. They show an approval card; the record is only",
|
||||
" written after the clinician clicks Add. NEVER say you added/booked/created",
|
||||
" something — say you've drafted it for their approval.",
|
||||
"- proposeInventory: when the clinician wants to ADD STOCK to the clinic's",
|
||||
" inventory — e.g. they upload a list of medications/supplies with quantities",
|
||||
" (and optionally prices) to stock. Parse it into items {name, form, strength,",
|
||||
" unit, stockQuantity, reorderThreshold, expiresAt} and call proposeInventory.",
|
||||
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
|
||||
" a list of purchased medications/items. Parse it into line items",
|
||||
" {description, quantity, unitPrice} (use the prices in the document) and call",
|
||||
" proposeInvoice with the patient/client name.",
|
||||
" proposeInvoice with the patient/client name. (Stocking inventory vs. billing a",
|
||||
" patient are different — pick proposeInventory for the former.)",
|
||||
"- previewImport: when the clinician wants to import/migrate an existing",
|
||||
" patient database file, or add a single patient. Parse the uploaded content",
|
||||
" into our patient shape and call previewImport.",
|
||||
@@ -113,7 +162,7 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
},
|
||||
};
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
const modelMessages = await convertToModelMessages(inlineTextFiles(messages));
|
||||
const system = systemPrompt(veil.active, resolved.providerLabel);
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { db } from "../../db/index.js";
|
||||
import { organization } from "../../db/schema/auth.js";
|
||||
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 { prescriptionInputSchema } from "../../lib/prescription-validation.js";
|
||||
@@ -474,6 +475,77 @@ export function createChatTools(ctx: ToolContext) {
|
||||
},
|
||||
}),
|
||||
|
||||
proposeInventory: tool({
|
||||
description:
|
||||
"Propose adding one or more items to the clinic's inventory (medications/supplies) for the clinician to approve — e.g. parse an uploaded stock or purchase list into stock items. Does NOT save; it shows an approval card the clinician confirms. Each item needs a name; form/strength/unit/stockQuantity/reorderThreshold/location/expiresAt (YYYY-MM-DD)/notes are optional. Use this for STOCKING inventory; use proposeInvoice instead when billing a patient for purchased items.",
|
||||
inputSchema: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().describe("Item / medication name"),
|
||||
form: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Dosage form, e.g. Tablet, Capsule, Syrup"),
|
||||
strength: z.string().optional().describe("e.g. 500mg"),
|
||||
unit: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Dispensing unit, e.g. box, bottle"),
|
||||
stockQuantity: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Units currently in stock"),
|
||||
reorderThreshold: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Low-stock reorder level"),
|
||||
location: z.string().optional().describe("Storage location"),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe("Expiry date, YYYY-MM-DD"),
|
||||
notes: z.string().nullish(),
|
||||
}),
|
||||
)
|
||||
.describe("Inventory items to add (prices/quantities from the document)"),
|
||||
}),
|
||||
execute: async ({ items }) => {
|
||||
step(`Drafting ${items.length} inventory item(s)`);
|
||||
// Inventory is non-PHI — no Veil resolution needed.
|
||||
const validated: unknown[] = [];
|
||||
const issues: string[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const parsed = inventoryInputSchema.safeParse(item);
|
||||
if (parsed.success) {
|
||||
validated.push(parsed.data);
|
||||
} else {
|
||||
issues.push(
|
||||
...parsed.error.issues.map(
|
||||
(i) =>
|
||||
`item ${index + 1} ${i.path.join(".") || "(root)"}: ${i.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
writer.write({
|
||||
type: "data-actionPreview",
|
||||
data: {
|
||||
token: `inventory-${stepSeq}`,
|
||||
kind: "inventory" as const,
|
||||
record: { items: validated.length ? validated : items },
|
||||
issues,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
count: validated.length,
|
||||
issues,
|
||||
note: "Preview only — awaiting clinician approval before any write.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
proposeInvoice: tool({
|
||||
description:
|
||||
"Propose a new invoice for the clinician to approve — e.g. parse an uploaded list of purchased medications into billable line items. Does NOT save; it shows an approval card the clinician confirms. Provide the patient/client name (a file number if known) and line items {description, quantity, unitPrice}; prices come from the uploaded document.",
|
||||
|
||||
Reference in New Issue
Block a user