Files
DentalManagementMH07/apps/Backend/src/services/visionLocate.ts
Gitead 65dc795eb2 fix: locate patient row by real text position, drop unreliable AI row-verify
Row selection previously computed the target row's y arithmetically
(header.y + rowHeight * rowIndex), which drifted further off-row the
deeper the target patient was in the filtered list, and an AI
"onTarget" verification step added to catch drift instead confirmed a
click point that was visibly in blank space below the grid.

locateAllOnScreenshot now reports each text match's top/bottom edges
instead of a self-estimated center, and the patient row step locates
the target's first and last name directly, pairing whichever
occurrences land on the same row instead of trusting arithmetic or a
qualitative AI judgment call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:53:12 -04:00

472 lines
20 KiB
TypeScript

import sharp from "sharp";
import { storage } from "../storage";
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
// Side length (px) of the debug crop saved around a click point — big enough to show
// surrounding context (e.g. neighboring grid rows) when reviewing what a run actually clicked.
const DEBUG_CROP_SIZE = 440;
interface ParsedLocation {
found: boolean;
xRatio?: number;
yRatio?: number;
}
// Parses the model's response into {found, xRatio, yRatio}, tolerating near-miss JSON rather
// than only accepting strictly valid JSON. Seen in practice with the old pixel-based format:
// {"found": true, "x": 1023, 187} — a dropped key with the value still present positionally.
// Since this response should only ever contain two numbers (the coordinates), falling back to
// "first two numbers after found" is safe and recovers without burning another AI call.
function parseLocationResponse(raw: string): ParsedLocation | null {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return null;
const block = match[0];
try {
return JSON.parse(block) as ParsedLocation;
} catch {
// fall through to lenient repair below
}
const foundMatch = block.match(/"found"\s*:\s*(true|false)/i);
if (!foundMatch) return null;
const found = foundMatch[1]!.toLowerCase() === "true";
if (!found) return { found: false };
const afterFound = block.slice(foundMatch.index! + foundMatch[0].length);
const numbers = afterFound.match(/-?\d+(\.\d+)?/g);
if (!numbers || numbers.length < 2) return null;
return { found: true, xRatio: Number(numbers[0]), yRatio: Number(numbers[1]) };
}
// Asks the configured vision AI to find a described UI element in a screenshot and return
// its position — the core primitive the Type Agent step runner clicks/types through.
// Reuses the same Claude-vision pattern as /api/ai/detect-eligibility-info.
//
// The AI reports position as a 0-1 ratio of image width/height rather than absolute pixels.
// Vision models are more reliable at "this is 4/10 of the way across, 3/10 down" than at
// naming an exact pixel — asking for pixels directly produced coordinates that were close but
// occasionally off by enough to land on a neighboring row or the title bar above it. A ratio
// is resolution-independent by construction, so it's converted to real screen pixels here
// using the actual screenshot dimensions rather than trusting the model to know the image size.
export async function locateOnScreenshot(
userId: number,
imageBase64: string,
goal: string
): Promise<{ x: number; y: number; xRatio: number; yRatio: number } | null> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0, height = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Find this UI element: "${goal}". ` +
"Respond with strict JSON only, no prose, no markdown fences: " +
'{"found": true, "xRatio": <0 to 1>, "yRatio": <0 to 1>} if you can locate it, or ' +
'{"found": false} if it isn\'t visible in this screenshot. xRatio is how far across the ' +
"image the element's center is, as a fraction of the TOTAL image width (0 = left edge, " +
"0.5 = horizontal center, 1 = right edge). yRatio is how far down the element's center is, " +
"as a fraction of the TOTAL image height (0 = top edge, 1 = bottom edge). Use two decimal " +
"places of precision — do not report pixel coordinates.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
// A response that's fully unparseable even after the lenient repair above is worth one
// retry (rare model hiccup); anything the repair can salvage is used immediately, no
// retry needed.
const MAX_ATTEMPTS = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const parsed = parseLocationResponse(raw);
if (!parsed) {
lastError = new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
continue;
}
if (!parsed.found || typeof parsed.xRatio !== "number" || typeof parsed.yRatio !== "number") {
return null;
}
return {
x: Math.round(parsed.xRatio * width),
y: Math.round(parsed.yRatio * height),
xRatio: parsed.xRatio,
yRatio: parsed.yRatio,
};
}
throw lastError ?? new Error("AI returned an unparseable response");
}
// Crops a small square around (x, y) and draws a red crosshair marker at that exact point —
// a debug artifact saved before each click so a person can see afterward exactly where the
// run was about to click. A real screen capture won't reliably include the OS mouse cursor
// (BitBlt-based grabs typically omit it), so this synthetic marker stands in for it.
export async function cropAroundPoint(imageBase64: string, x: number, y: number): Promise<string> {
const source = sharp(Buffer.from(imageBase64, "base64"));
const { width = 0, height = 0 } = await source.metadata();
const cropWidth = Math.min(DEBUG_CROP_SIZE, width);
const cropHeight = Math.min(DEBUG_CROP_SIZE, height);
const left = Math.round(Math.max(0, Math.min(x - cropWidth / 2, width - cropWidth)));
const top = Math.round(Math.max(0, Math.min(y - cropHeight / 2, height - cropHeight)));
const markerX = x - left;
const markerY = y - top;
const crosshair = Buffer.from(
`<svg width="${cropWidth}" height="${cropHeight}">` +
`<line x1="${markerX - 12}" y1="${markerY}" x2="${markerX + 12}" y2="${markerY}" stroke="red" stroke-width="2"/>` +
`<line x1="${markerX}" y1="${markerY - 12}" x2="${markerX}" y2="${markerY + 12}" stroke="red" stroke-width="2"/>` +
`<circle cx="${markerX}" cy="${markerY}" r="7" fill="none" stroke="red" stroke-width="2"/>` +
`</svg>`
);
const cropped = await source
.extract({ left, top, width: cropWidth, height: cropHeight })
.composite([{ input: crosshair }])
.png()
.toBuffer();
return cropped.toString("base64");
}
interface RawMatch {
xRatio: number;
yTopRatio: number;
yBottomRatio: number;
}
// Parses {"matches": [{"xRatio":.., "yTopRatio":.., "yBottomRatio":..}, ...]}, tolerating
// near-miss JSON the same way parseLocationResponse does — falls back to regex-extracting every
// triple in the response if strict parsing fails, rather than treating a malformed-but-salvageable
// response as zero matches.
function parseMatchesResponse(raw: string): RawMatch[] | null {
const block = raw.match(/\{[\s\S]*\}/)?.[0];
if (!block) return null;
try {
const parsed = JSON.parse(block);
if (Array.isArray(parsed.matches)) return parsed.matches;
} catch {
// fall through to lenient repair below
}
const triples: RawMatch[] = [];
const tripleRegex =
/"xRatio"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"yTopRatio"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"yBottomRatio"\s*:\s*(-?\d+(?:\.\d+)?)/g;
let match: RegExpExecArray | null;
while ((match = tripleRegex.exec(block))) {
triples.push({ xRatio: Number(match[1]), yTopRatio: Number(match[2]), yBottomRatio: Number(match[3]) });
}
return triples.length > 0 ? triples : null;
}
// Like locateOnScreenshot, but finds every occurrence of an exact piece of text rather than
// one best guess for a described element. Used to disambiguate by position instead of by
// asking the AI to judge which occurrence is "the right one" — e.g. a last name shows up once
// in the search box and once per matching patient row, so the caller pairs it against another
// text's occurrences (a first name) that share the same row instead of relying on a single
// fuzzy vision judgment call.
//
// Reports each match's TOP and BOTTOM pixel edge rather than asking the model to self-estimate a
// "center" — a center is an abstract judgment call, whereas the top/bottom of a glyph is a
// concrete, visible thing to point at. The vertical midpoint used for x/y below is then computed
// here in code from those two real edges, not trusted as a direct AI estimate.
export async function locateAllOnScreenshot(
userId: number,
imageBase64: string,
text: string
): Promise<{ x: number; y: number }[]> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0, height = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Find EVERY occurrence of the text ` +
`"${text}" visible anywhere in this screenshot (case-insensitive) — there may be zero, one, ` +
"or several. Respond with strict JSON only, no prose, no markdown fences: " +
'{"matches": [{"xRatio": <0 to 1>, "yTopRatio": <0 to 1>, "yBottomRatio": <0 to 1>}, ...]} — ' +
"one entry per occurrence. xRatio is how far across the image the occurrence's horizontal " +
"center is, as a fraction of the TOTAL image width. yTopRatio is where the TOP edge of that " +
"occurrence's text (the top of its tallest letters) is, and yBottomRatio is where its BOTTOM " +
"edge (the bottom of its lowest letters, including descenders) is — both as a fraction of " +
"the TOTAL image height. Use an empty array if there are no occurrences.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const MAX_ATTEMPTS = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const matches = parseMatchesResponse(raw);
if (!matches) {
lastError = new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
continue;
}
return matches.map((m) => ({
x: Math.round(m.xRatio * width),
y: Math.round(((m.yTopRatio + m.yBottomRatio) / 2) * height),
}));
}
throw lastError ?? new Error("AI returned an unparseable response");
}
// Asks the AI to measure the height of one row in a results-grid list, as a ratio of the
// image's total height — used to build a grid of valid row-center y-positions so a click can be
// snapped toward the middle of whichever row it's closest to, rather than trusting a single
// direct locate call's y-coordinate on its own (which tends to land near a row's top edge).
export async function detectRowHeightRatio(userId: number, imageBase64: string): Promise<number> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content = [
{
type: "text",
text:
"This is a screenshot containing a results-grid list of rows (e.g. a patient list) with a " +
"header row followed by data rows, all data rows the same height. IGNORE the header row — " +
"it is taller than a data row and would skew the measurement. Look at ALL the visible data " +
"rows (not just one) and measure their AVERAGE height. Respond with strict JSON only, no " +
'prose, no markdown fences: {"rowHeightRatio": <0 to 1>} — the average data row height as ' +
"a fraction of the TOTAL image height. Use three decimal places of precision.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const match = raw.match(/"rowHeightRatio"\s*:\s*(-?\d+(?:\.\d+)?)/);
if (!match) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
return Number(match[1]);
}
// Asks the AI to find the x-positions of the MAJOR vertical dividers that split a window into
// its big panels/columns (e.g. a "Patient Info" panel, a middle fields+list panel, a right-side
// grid panel) — as opposed to minor lines inside a single table's own columns. Used to tell
// apart same-text matches that live in different panels (e.g. "Exam" appearing both in a
// clickable procedures list and, separately, in an already-added-procedures grid) by which
// column they actually fall in, rather than guessing from y-position alone.
export async function detectColumnBoundaries(userId: number, imageBase64: string): Promise<number[]> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
"This screenshot shows a Windows dialog laid out in a small number of large vertical " +
"sections (columns) — e.g. a distinct panel on the left, a middle section with fields " +
'and/or a list, and another panel or grid on the right. Find the x-position of each MAJOR ' +
"vertical divider between these big sections (a visible line, border, or clear gap — NOT " +
"the minor column lines inside a single table). Respond with strict JSON only, no prose, " +
'no markdown fences: {"dividerXRatios": [<0 to 1>, ...]} — one entry per major divider, ' +
"sorted left to right, as a fraction of the TOTAL image width. Use two decimal places of " +
"precision.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const block = raw.match(/\{[\s\S]*\}/)?.[0];
if (!block) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
let ratios: number[];
try {
const parsed = JSON.parse(block);
if (!Array.isArray(parsed.dividerXRatios)) throw new Error("not an array");
ratios = parsed.dividerXRatios;
} catch {
const arrayMatch = block.match(/"dividerXRatios"\s*:\s*\[([^\]]*)\]/);
if (!arrayMatch) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
ratios = arrayMatch[1]!
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => !Number.isNaN(n));
}
return ratios.map((r) => Math.round(r * width)).sort((a, b) => a - b);
}
export interface WindowBounds {
left: number;
top: number;
width: number;
height: number;
}
// Compares two full screenshots and returns the bounding box of whatever changed
// significantly between them — used to find a newly-opened dialog's exact position/size with
// pixel math instead of an AI call, so later steps can crop every screenshot down to just that
// window and never even show the AI anything outside it (the earlier bug where a name prefix
// happened to also match unrelated text elsewhere on screen, e.g. "All Received" in a side
// panel, is structurally impossible once matching only ever sees the cropped window).
//
// Rather than a raw min/max over any differing pixel, this counts changed pixels per row and
// per column and only counts a row/column as part of the new window once a meaningful fraction
// of it changed — a real window opening changes a large contiguous block, whereas incidental
// noise elsewhere (a blinking clock, a flickering tray icon) only touches a handful of pixels
// and would otherwise blow the bounding box out to include it.
export async function diffBoundingBox(
beforeBase64: string,
afterBase64: string
): Promise<WindowBounds | null> {
const [before, after] = await Promise.all([
sharp(Buffer.from(beforeBase64, "base64")).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
sharp(Buffer.from(afterBase64, "base64")).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
]);
const { data: b, info } = before;
const { data: a, info: infoAfter } = after;
if (info.width !== infoAfter.width || info.height !== infoAfter.height) return null;
const { width, height, channels } = info;
const PIXEL_DIFF_THRESHOLD = 40; // sum of |dR| + |dG| + |dB| to count a pixel as "changed"
const rowCounts = new Uint32Array(height);
const colCounts = new Uint32Array(width);
for (let y = 0; y < height; y++) {
const rowBase = y * width * channels;
for (let x = 0; x < width; x++) {
const idx = rowBase + x * channels;
const diff = Math.abs(a[idx]! - b[idx]!) + Math.abs(a[idx + 1]! - b[idx + 1]!) + Math.abs(a[idx + 2]! - b[idx + 2]!);
if (diff > PIXEL_DIFF_THRESHOLD) {
rowCounts[y] = (rowCounts[y] ?? 0) + 1;
colCounts[x] = (colCounts[x] ?? 0) + 1;
}
}
}
const rowThreshold = width * 0.05;
const colThreshold = height * 0.05;
let top = -1;
let bottom = -1;
for (let y = 0; y < height; y++) {
if (rowCounts[y]! > rowThreshold) {
if (top === -1) top = y;
bottom = y;
}
}
let left = -1;
let right = -1;
for (let x = 0; x < width; x++) {
if (colCounts[x]! > colThreshold) {
if (left === -1) left = x;
right = x;
}
}
if (top === -1 || left === -1) return null;
return { left, top, width: right - left + 1, height: bottom - top + 1 };
}
// Plain crop, no marker — used to restrict a screenshot to a previously-detected window's
// bounds before handing it to any locate call.
export async function cropToRegion(imageBase64: string, region: WindowBounds): Promise<string> {
const cropped = await sharp(Buffer.from(imageBase64, "base64"))
.extract({ left: region.left, top: region.top, width: region.width, height: region.height })
.png()
.toBuffer();
return cropped.toString("base64");
}
// Sanity check for steps that type without first vision-clicking into a field (relying on
// whatever already has focus) — confirms the text actually landed somewhere visible on
// screen before moving on, catching the case where focus wasn't where it was assumed to be.
export async function confirmTextTyped(
userId: number,
fullScreenshotBase64: string,
expectedText: string
): Promise<boolean> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Is the text "${expectedText}" ` +
"visible typed into some text input field on screen? Respond with strict JSON only, no " +
'prose, no markdown fences: {"confirmed": true} if that text is visible in a field, or ' +
'{"confirmed": false} if it is not visible anywhere.',
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${fullScreenshotBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const match = raw.match(/"confirmed"\s*:\s*(true|false)/i);
return match?.[1]?.toLowerCase() === "true";
}