Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const mainItems = [
url: "/advisories",
},
{
title: "Tracking",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming Tracking to Work Orders purely in the UI.

Might be worthy as it's own chore ticket to rename all instances of Tracking to Work Orders in the actual filename, code & comments.

title: "Work Orders",
icon: ListChecksIcon,
url: "/tracking",
},
Expand Down
63 changes: 63 additions & 0 deletions src/features/inbox/agent/classify-kind.ts

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch in the email classifier.
work order -> Actionable Requests
notification -> Informational emails

leans towards notif if unsure

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Routes a relevant inbound email to the right entity: an informational
// Notification, or an actionable Work Order ticket.

import "server-only";
import { ChatAnthropic } from "@langchain/anthropic";
import { HumanMessage } from "@langchain/core/messages";
import { emailKindSchema } from "../types";
import { fetchPdfAttachments } from "../utils";

const MODEL = "claude-haiku-4-5-20251001";

const SYSTEM_PROMPT = `You route inbound emails for a hospital security & operations platform.

Decide whether an email is a NOTIFICATION or a WORK ORDER:
- "work_order": the email is an ACTIONABLE request directed at the hospital — something a person needs to DO. Examples: a vendor service request or work order, a maintenance/patch/firmware request, a request to schedule or perform work on a device, an RMA, a manual task assignment.
- "notification": the email is INFORMATIONAL — a security advisory, vulnerability disclosure, device recall/safety communication, vendor update announcement, threat bulletin, or other FYI with no specific task asked of the hospital.

When in doubt between the two, prefer "notification" unless the email clearly asks the hospital to perform an action.

Always give a concise reasonWhy.`;

function buildTextPrompt(email: {
from: string;
subject: string | null;
markdown: string;
}): string {
return `From: ${email.from}
Subject: ${email.subject ?? "(no subject)"}

--- EMAIL BODY ---
${email.markdown}`;
}

export async function classifyEmailKind(
sourceId: string,
email: { from: string; subject: string | null; markdown: string },
) {
const pdfAttachments = await fetchPdfAttachments(sourceId);

const model = new ChatAnthropic({
model: MODEL,
maxTokens: 256,
}).withStructuredOutput(emailKindSchema);

const userContent = [
{ type: "text" as const, text: buildTextPrompt(email) },
...pdfAttachments.map((pdf) => ({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: pdf.base64,
},
title: pdf.filename ?? "attachment.pdf",
})),
];

return model.invoke([
{ role: "system", content: SYSTEM_PROMPT },
// biome-ignore lint/suspicious/noExplicitAny: cast userContent type for langchain
new HumanMessage({ content: userContent as any }),
]);
}
77 changes: 77 additions & 0 deletions src/features/inbox/agent/extract-work-order.ts

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulls any relevant work order info from an email if found

Fields
Summary
Category
ScheduleAt
Assignee
Department

Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Extracts the fields needed to populate a WorkOrderTicket from an actionable
// (work-order) email. The ticket body is taken from the email markdown
// directly; this agent only produces the structured fields around it.

import "server-only";
import { ChatAnthropic } from "@langchain/anthropic";
import { HumanMessage } from "@langchain/core/messages";
import prisma from "@/lib/db";
import { type WorkOrderPayload, workOrderPayloadSchema } from "../types";
import { fetchPdfAttachments } from "../utils";

const MODEL = "claude-haiku-4-5-20251001";

const SYSTEM_PROMPT = `You extract structured fields for a hospital work-order ticket from an actionable email.

Produce:
- summary: a concise, action-oriented title (prefer device/vendor specifics over generic phrasing)
- category: the best-fit work category from the allowed set; use OTHER when unsure
- scheduledAt: an ISO 8601 date (YYYY-MM-DD or full timestamp) ONLY if the email states a due/scheduled date; otherwise null
- suggestedAssignee: the responsible party if the email names one (often an external vendor or a person/team); otherwise null
- departmentNames: EVERY hospital department mentioned or implied anywhere in the email — including ones named only in a coordination/aside note (e.g. "have Biomed stage the units"). Choose names ONLY from the "Valid departments" list provided below, copying the spelling exactly; return [] if none apply.

Only include values you are reasonably confident about. Do not invent dates or assignees, and never invent a department that isn't in the provided list.`;

function buildTextPrompt(email: {
from: string;
subject: string | null;
markdown: string;
}): string {
return `From: ${email.from}
Subject: ${email.subject ?? "(no subject)"}

--- EMAIL BODY ---
${email.markdown}`;
}

export async function extractWorkOrder(
sourceId: string,
email: { from: string; subject: string | null; markdown: string },
): Promise<WorkOrderPayload> {
const [pdfAttachments, departments] = await Promise.all([
fetchPdfAttachments(sourceId),
prisma.department.findMany({
select: { name: true },
orderBy: { name: "asc" },
}),
]);
const validDepartments =
departments.map((d) => d.name).join(", ") || "(none configured)";

const model = new ChatAnthropic({
model: MODEL,
maxTokens: 1024,
}).withStructuredOutput(workOrderPayloadSchema);

const userContent = [
{
type: "text" as const,
text: `${buildTextPrompt(email)}\n\nValid departments (choose only from these): ${validDepartments}`,
},
...pdfAttachments.map((pdf) => ({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: pdf.base64,
},
title: pdf.filename ?? "attachment.pdf",
})),
];

return model.invoke([
{ role: "system", content: SYSTEM_PROMPT },
// biome-ignore lint/suspicious/noExplicitAny: cast userContent type for langchain
new HumanMessage({ content: userContent as any }),
]);
}
133 changes: 133 additions & 0 deletions src/features/inbox/agent/match.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("server-only", () => ({}));

const { mockPrisma } = vi.hoisted(() => {
const prisma = {
notificationDeviceGroupMapping: { upsert: vi.fn() },
deviceGroupMatching: { findUnique: vi.fn(), update: vi.fn() },
// applyDecisions runs inside prisma.$transaction(async (tx) => …) — invoke
// the callback with the same mock so call assertions still work.
// biome-ignore lint/suspicious/noExplicitAny: callback shape varies
$transaction: vi.fn(async (cb: (tx: any) => Promise<unknown>) =>
cb(prisma),
),
};
return { mockPrisma: prisma };
});

vi.mock("@/lib/db", () => ({ default: mockPrisma }));
vi.mock("@/lib/router-utils", () => ({ resolveMatchingId: vi.fn() }));

import type { Candidates } from "./candidate-search";
import { applyDecisions, type Decision } from "./match";

const candidates: Candidates = {
deviceGroups: [
{
extracted: {
cpe: null,
manufacturer: "Acme",
modelName: "X1",
version: null,
},
matches: [
{
id: "dg-1",
manufacturer: "Acme",
modelName: "X1",
version: null,
versionRange: null,
},
],
},
],
vulnerabilities: [],
remediations: [],
assets: [],
};

const linkDecision: Decision = {
kind: "deviceGroupMatching",
op: "link",
targetId: "dg-1",
confidence: "Matched",
reasonWhy: "same manufacturer + model",
fields: null,
};

beforeEach(() => {
vi.clearAllMocks();
});

describe("applyDecisions — device-group mapping owner", () => {
it("links to a work order via the workOrderTicketId compound key", async () => {
const summary = await applyDecisions(
{ workOrderTicketId: "wo-1" },
[linkDecision],
candidates,
);

expect(summary.linked).toBe(1);
expect(
mockPrisma.notificationDeviceGroupMapping.upsert,
).toHaveBeenCalledTimes(1);
expect(
mockPrisma.notificationDeviceGroupMapping.upsert,
).toHaveBeenCalledWith(
expect.objectContaining({
where: {
workOrderTicketId_deviceGroupMatchingId: {
workOrderTicketId: "wo-1",
deviceGroupMatchingId: "dg-1",
},
},
create: expect.objectContaining({
workOrderTicketId: "wo-1",
deviceGroupMatchingId: "dg-1",
confidence: "Matched",
}),
}),
);
});

it("links to a notification via the notificationId compound key", async () => {
const summary = await applyDecisions(
{ notificationId: "n-1" },
[linkDecision],
candidates,
);

expect(summary.linked).toBe(1);
expect(
mockPrisma.notificationDeviceGroupMapping.upsert,
).toHaveBeenCalledWith(
expect.objectContaining({
where: {
notificationId_deviceGroupMatchingId: {
notificationId: "n-1",
deviceGroupMatchingId: "dg-1",
},
},
create: expect.objectContaining({
notificationId: "n-1",
deviceGroupMatchingId: "dg-1",
}),
}),
);
});

it("skips link decisions whose targetId wasn't a surfaced candidate", async () => {
const summary = await applyDecisions(
{ workOrderTicketId: "wo-1" },
[{ ...linkDecision, targetId: "hallucinated" }],
candidates,
);

expect(summary).toMatchObject({ linked: 0, skipped: 1 });
expect(
mockPrisma.notificationDeviceGroupMapping.upsert,
).not.toHaveBeenCalled();
});
});
Loading
Loading