Compare commits

...

7 Commits

Author SHA1 Message Date
Khalid Abdi f1c9f8af55 chore(release): v0.2.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:37:23 +03:00
Khalid Abdi 1c65f72ccf backend: render patient card from searchPatients on a unique match
"Show me <name>'s medical record" relied on the model chaining
searchPatients → getPatient, but Gemini Flash often calls searchPatients
then emits the canned closing line ("Here's the record.") without the
second tool call, so no data-patientCard part is ever written and no card
renders. (The prior Gemini fix only covered the empty-schema list tools.)

searchPatients now writes the record card (data-patientCard, or
data-recordGraph in graph mode) and a source directly when EXACTLY ONE
patient matches — mirroring getPatient — so the common name-lookup flow no
longer depends on a second tool call. Multiple/zero matches keep returning
the disambiguation list. The tool description and system prompt tell the
model the card is already shown on a unique match so it doesn't double-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:36:49 +03:00
Khalid Abdi f12285b8c6 chore(release): v0.2.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:53:13 +03:00
Khalid Abdi 75910f7fb0 backend: fix AI chat cards not rendering on Gemini (empty tool schemas)
The card-emitting chat tools (listAppointments, listTasks,
listPrescriptions, getClinicInfo, getAnalytics, listInventory) used an
empty `z.object({})` parameter schema. Google Gemini can't emit a
function call for a tool whose JSON schema has no properties — it prints
the call as `tool_code` text instead of invoking it — so the tool never
ran and the frontend never received the data part to render a card.

Give those tools a shared `emptyToolArgs` schema with one optional,
ignored field so the schema is non-empty and Gemini calls them. execute
bodies are unchanged; other providers ignore the extra field. This is the
same fix already documented for previewImport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:52:22 +03:00
Khalid Abdi 3767c1689b chore(release): v0.2.1
Bump root, backend, and frontend to 0.2.1 and add the 0.2.1 changelog section.
Patch release: pagination styling, patient-sheet header reflow, message
avatars, and CHANGELOG-based GitHub Release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:22 +03:00
Khalid Abdi edf42e9741 ci: publish the CHANGELOG section as the GitHub Release body
The release workflow only set generate_release_notes, so releases carried just
the auto "Full Changelog" link. Extract this version's section from CHANGELOG.md
into release-notes.md and pass it via body_path (the generated link is still
appended), falling back to a generic line when there's no entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:12 +03:00
Khalid Abdi 31adbab87b frontend: fix pagination styling, patient-sheet header, message avatars
- Patients pagination: render the prev/next + page-number controls as COSS
  Buttons inside the Pagination structure. PaginationLink drops its
  buttonVariants styling when given a `render` prop, so the controls were
  unstyled and wrapping; using Button restores proper pill/number buttons.
- Patient detail sheet: move the action buttons (Download summary / Transfer /
  Edit / Delete) to their own wrapping row beneath the identity block so the
  patient name is no longer truncated on the narrow sheet.
- Messages thread: add sender/recipient avatars (MessageAvatar) at the bottom
  of each message group, with a spacer to keep stacked bubbles aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:12 +03:00
11 changed files with 181 additions and 66 deletions
+18
View File
@@ -62,7 +62,25 @@ jobs:
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
# Pull this version's section out of CHANGELOG.md so the release has real,
# human-written notes (the auto "Full Changelog" link is still appended
# below via generate_release_notes). Falls back to a generic line if the
# version has no CHANGELOG entry.
- name: Extract changelog notes
id: notes
run: |
version="${{ steps.meta.outputs.version }}"
awk -v v="$version" '
$0 ~ "^## \\[" v "\\]" {flag=1; next}
/^## \[/ {flag=0}
flag {print}
' CHANGELOG.md | sed '/./,$!d' > release-notes.md
if [ ! -s release-notes.md ]; then
echo "Release $version. See the changelog for details." > release-notes.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release-notes.md
generate_release_notes: true
+39
View File
@@ -7,6 +7,45 @@ for how releases are cut and published.
## [Unreleased]
## [0.2.3] — 2026-06-29
### Fixed
- **AI chat patient record cards** now render on Google Gemini for name
lookups. "Show me <name>'s medical record" relied on the model chaining
`searchPatients``getPatient`, but Gemini often called `searchPatients`
and then emitted only a canned closing line ("Here's the record.") without
the second tool call — so no card was ever drawn. `searchPatients` now
displays the record card directly when exactly one patient matches, so the
flow no longer depends on a follow-up tool call. (The previous Gemini fix in
0.2.2 only covered the empty-schema list tools.)
## [0.2.2] — 2026-06-28
### Fixed
- **AI chat record cards** now render on Google Gemini. The card-emitting
chat tools (`listAppointments`, `listTasks`, `listPrescriptions`,
`getClinicInfo`, `getAnalytics`, `listInventory`) used an empty parameter
schema; Gemini can't emit a function call for a schema with no properties,
so it printed the call as `tool_code` text instead of invoking the tool —
leaving replies as plain text (e.g. "Show today's schedule" leaked a raw
`<tool_code>` block) with no cards. The tools now share a non-empty schema
so Gemini calls them; other providers are unaffected.
## [0.2.1] — 2026-06-27
### Fixed
- **Patients pagination** controls now render as proper buttons — the prev/next
and page-number controls were unstyled and wrapping (the COSS `PaginationLink`
drops its button styling when given a `render` prop).
- **Patient detail sheet header** reflowed: actions (Download summary / Transfer
/ Edit / Delete) moved to their own wrapping row so the patient name is no
longer truncated.
- **Messages thread** now shows **sender and recipient avatars** alongside the
chat bubbles.
- **Release notes** — the `release` workflow now publishes the matching
`CHANGELOG.md` section as the GitHub Release body (instead of only the
auto-generated "Full Changelog" link).
## [0.2.0] — 2026-06-27
### Added
+1 -1
View File
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.2.0-success)](./CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/changelog-0.2.1-success)](./CHANGELOG.md)
![temetro AI chat](./.github/assets/screenshot-chat.png)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+4 -1
View File
@@ -107,7 +107,10 @@ function systemPrompt(
"",
"Display tools (read-only):",
"- getPatient: when asked about a specific patient by file number / MRN.",
"- searchPatients: when given a name; then getPatient on the match.",
"- searchPatients: when given a name. If exactly one patient matches it",
" already shows that patient's record card — don't call getPatient again,",
" just confirm. Only call getPatient yourself for a direct file number / MRN,",
" or to pick one of several matches it returns.",
"- getPatientLabs: when asked about labs/results/trends.",
"- listAppointments: when asked to see the schedule / upcoming visits.",
"- listTasks: when asked to see open tasks / to-dos.",
+53 -14
View File
@@ -40,6 +40,18 @@ export type ToolContext = {
writer: UIMessageStreamWriter;
};
// Shared schema for tools that take no real arguments. Google Gemini cannot
// emit a function call when a tool's parameter schema has no properties — it
// prints the call as `tool_code` text instead of invoking it (see the
// previewImport note below). One optional, ignored field keeps the schema
// non-empty so Gemini calls the tool; other providers ignore the extra field.
const emptyToolArgs = z.object({
filter: z
.string()
.optional()
.describe("Optional free-text filter (ignored — the full list is shown)"),
});
// Compact, model-facing projection of a patient (Veil-redacted upstream). Keeps
// clinical signal, drops bulky arrays the model rarely needs verbatim.
function forModel(p: Patient) {
@@ -177,10 +189,12 @@ export function createChatTools(ctx: ToolContext) {
},
}),
// Search the clinic's patients by name or file number.
// Search the clinic's patients by name or file number. On a unique match this
// also displays that patient's record card directly (see below), so the common
// "show me <name>'s record" flow doesn't depend on a follow-up getPatient call.
searchPatients: tool({
description:
"Search the clinic's patients by name fragment. Returns matches with file numbers so you can then call getPatient.",
"Search the clinic's patients by name fragment. If EXACTLY ONE patient matches, this already displays their full record card — do NOT call getPatient again; just confirm in one sentence. If multiple match, it returns the matches (with file numbers) so you can disambiguate or call getPatient on the right one.",
inputSchema: z.object({
query: z.string().describe("Name or file-number fragment to match"),
}),
@@ -192,17 +206,42 @@ export function createChatTools(ctx: ToolContext) {
scopeProviderId,
);
const q = query.trim().toLowerCase();
const matches = all
// Keep the full Patient objects so a unique match can render a card.
const matched = all
.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.fileNumber.toLowerCase().includes(q),
)
.slice(0, 10)
.map((p) => {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
.slice(0, 10);
// Unique match → show the record card right here. Gemini often skips the
// search→getPatient hand-off and just emits a canned sentence, leaving the
// clinician with no card; rendering it directly removes that dependency.
if (matched.length === 1) {
const patient = matched[0]!;
step(`Looking up patient ${patient.fileNumber}`);
writer.write(
mode === "graph"
? { type: "data-recordGraph", data: patient }
: { type: "data-patientCard", data: patient },
);
const sourceId = addSource(
`${patient.name} · MRN ${patient.fileNumber}`,
"patient",
);
return {
count: 1,
shownCard: true,
sourceId,
patient: forModel(veil.redactPatient(patient)),
};
}
const matches = matched.map((p) => {
const r = veil.redactPatient(p);
return { fileNumber: r.fileNumber, name: r.name, status: p.status };
});
step(`Found ${matches.length} match(es)`);
return { count: matches.length, matches };
},
@@ -213,7 +252,7 @@ export function createChatTools(ctx: ToolContext) {
listAppointments: tool({
description:
"Display the clinic's appointments. Use when the clinician asks to see the schedule, upcoming visits, or today's appointments.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading appointments");
const all = await appointments.listAppointments(orgId);
@@ -235,7 +274,7 @@ export function createChatTools(ctx: ToolContext) {
listTasks: tool({
description:
"Display the care-team task list. Use when the clinician asks to see open tasks, to-dos, or what's assigned.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading tasks");
const all = await tasks.listTasks(orgId, {
@@ -258,7 +297,7 @@ export function createChatTools(ctx: ToolContext) {
listPrescriptions: tool({
description:
"Display prescriptions for the clinic. Use when the clinician asks to see prescriptions or medications prescribed.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
if (demographicsOnly) {
return { found: false as const, reason: "not_authorized" as const };
@@ -454,7 +493,7 @@ export function createChatTools(ctx: ToolContext) {
getClinicInfo: tool({
description:
"Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading clinic info");
const [org] = await db
@@ -479,7 +518,7 @@ export function createChatTools(ctx: ToolContext) {
getAnalytics: tool({
description:
"Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading clinic analytics");
const data = await analytics.getAnalytics(orgId);
@@ -492,7 +531,7 @@ export function createChatTools(ctx: ToolContext) {
listInventory: tool({
description:
"List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.",
inputSchema: z.object({}),
inputSchema: emptyToolArgs,
execute: async () => {
step("Loading inventory");
const items = await inventory.listInventory(orgId);
@@ -42,6 +42,7 @@ import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
@@ -200,6 +201,7 @@ export function MessagesView() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
const myInitials = initials(session?.user?.name ?? "");
const router = useRouter();
@@ -694,6 +696,19 @@ export function MessagesView() {
!newDay && (startsGroup ? "mt-4" : "mt-1"),
)}
>
{/* Avatar at the bottom of each run (messenger-style); a
spacer keeps stacked bubbles aligned otherwise. */}
{endsGroup ? (
<MessageAvatar>
<Avatar className="size-8">
<AvatarFallback className="text-[11px]">
{out ? myInitials : initials(m.senderName)}
</AvatarFallback>
</Avatar>
</MessageAvatar>
) : (
<div className="w-8 shrink-0" />
)}
<MessageContent>
{selected.isGroup && !out && startsGroup && (
<MessageHeader>{m.senderName}</MessageHeader>
+28 -23
View File
@@ -238,31 +238,35 @@ export function PatientDetail({
return (
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Avatar className="size-12">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-semibold text-base text-foreground">
{patient.name}
</span>
<Badge variant={statusVariant[patient.status]}>
{t(`patients.status.${patient.status}`)}
</Badge>
</div>
<span className="text-muted-foreground text-sm">{idLine}</span>
{patient.alerts.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1.5">
{patient.alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
<div className="flex flex-col gap-3">
{/* Identity — full width so the name never gets squeezed by the actions. */}
<div className="flex items-start gap-3">
<Avatar className="size-12">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-base text-foreground">
{patient.name}
</span>
<Badge variant={statusVariant[patient.status]}>
{t(`patients.status.${patient.status}`)}
</Badge>
</div>
)}
<span className="text-muted-foreground text-sm">{idLine}</span>
{patient.alerts.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1.5">
{patient.alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Actions — their own wrapping row beneath the identity. */}
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => printPatientSummary(patient, t)}
size="sm"
@@ -292,6 +296,7 @@ export function PatientDetail({
{onDelete && (
<Button
aria-label={t("patients.delete.action")}
className="ml-auto"
onClick={onDelete}
size="sm"
type="button"
+20 -24
View File
@@ -17,10 +17,8 @@ import {
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
import { listPatients, type Patient } from "@/lib/patients";
import { cn } from "@/lib/utils";
// Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10;
@@ -290,22 +288,20 @@ export function PatientsView() {
>
<PaginationContent>
<PaginationItem>
<PaginationLink
aria-disabled={safePage === 1}
<Button
aria-label={t("patients.pagination.previous")}
className={cn(
"gap-1 px-2.5",
safePage === 1 && "pointer-events-none opacity-50"
)}
className="gap-1"
disabled={safePage === 1}
onClick={() => setPage(Math.max(1, safePage - 1))}
render={<button type="button" />}
size="default"
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("patients.pagination.previous")}
</span>
</PaginationLink>
</Button>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
@@ -314,34 +310,34 @@ export function PatientsView() {
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
<Button
aria-current={p === safePage ? "page" : undefined}
aria-label={t("patients.pagination.page", { page: p })}
isActive={p === safePage}
onClick={() => setPage(p)}
render={<button type="button" />}
size="icon-sm"
type="button"
variant={p === safePage ? "outline" : "ghost"}
>
{p}
</PaginationLink>
</Button>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationLink
aria-disabled={safePage === totalPages}
<Button
aria-label={t("patients.pagination.next")}
className={cn(
"gap-1 px-2.5",
safePage === totalPages && "pointer-events-none opacity-50"
)}
className="gap-1"
disabled={safePage === totalPages}
onClick={() => setPage(Math.min(totalPages, safePage + 1))}
render={<button type="button" />}
size="default"
size="sm"
type="button"
variant="ghost"
>
<span className="max-sm:hidden">
{t("patients.pagination.next")}
</span>
<ChevronRight className="size-4" />
</PaginationLink>
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"