-
Notifications
You must be signed in to change notification settings - Fork 0
[VW-344] Parsing Work Orders From Emails #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a71d7d4
7570785
0a2e6ea
06ebd8a
91539ea
d8a2a38
0e3c550
990c023
5d47c38
ae1d7cc
aee3790
6e567d5
8bf7450
c7ff3b8
83f71cd
b41c0b8
a11da9e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The branch in the email classifier. 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 }), | ||
| ]); | ||
| } |
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pulls any relevant work order info from an email if found Fields |
| 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 }), | ||
| ]); | ||
| } |
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renaming
TrackingtoWork Orderspurely in the UI.Might be worthy as it's own chore ticket to rename all instances of
TrackingtoWork Ordersin the actual filename, code & comments.