Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-24 03:33:02 +00:00
co-authored by renee-png
parent 5924f3f4b7
commit 66194febdf
+246
View File
@@ -0,0 +1,246 @@
// Pure email-to-case matching logic.
//
// Given an incoming email and lookup tables (cases, contacts, etc.), this
// returns scored case suggestions and a final match decision:
// - "matched" → high-confidence single match, attach automatically
// - "suggested" → one or more plausible matches, needs user confirmation
// - "unmatched" → no signal, leave for manual triage in the inbox
//
// Signal weights (higher = stronger):
// subject tag match 100
// reply-thread parent match 90
// single contact email match 70
// client contact email match 60
// homeowner email match 55
// opposing counsel email match 55
// body case-number match 45
//
// Decision:
// - Top score >= 90 and clear leader → matched
// - Otherwise, top results >= 30 → suggested (top 3)
// - Otherwise → unmatched
export interface CaseLookup {
id: string;
case_number: string;
court_case_number: string | null;
case_caption: string | null;
title: string;
client_id: string | null;
opposing_counsel_email: string | null;
status: string;
updated_at: string;
}
export interface ContactCaseLink {
contact_id: string;
email: string | null;
case_id: string;
}
export interface ClientCaseLink {
client_id: string;
primary_contact_email: string | null;
case_ids: string[];
}
export interface HomeownerCaseLink {
homeowner_id: string;
email: string | null;
case_ids: string[];
}
export interface EmailLike {
message_id: string | null;
in_reply_to: string | null;
email_references: string[];
from_address: string | null;
to_addresses: string[];
cc_addresses: string[];
subject: string | null;
body_text: string | null;
}
export interface PriorEmail {
message_id: string | null;
case_id: string | null;
}
export interface ScoredMatch {
case_id: string;
score: number;
reasons: string[];
}
export interface MatchResult {
status: "matched" | "suggested" | "unmatched";
case_id: string | null;
reason: string | null;
suggestions: ScoredMatch[];
}
const SUBJECT_TAG_RE = /\[\s*(?:case[\s#:-]*)?([A-Za-z0-9._/-]{2,40})\s*\]/i;
function norm(s: string | null | undefined): string {
return (s ?? "").trim().toLowerCase();
}
function uniq<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}
export function extractSubjectTag(subject: string | null): string | null {
if (!subject) return null;
const m = subject.match(SUBJECT_TAG_RE);
return m ? m[1].trim() : null;
}
export function matchEmailToCases(
email: EmailLike,
cases: CaseLookup[],
contactLinks: ContactCaseLink[],
clientLinks: ClientCaseLink[],
homeownerLinks: HomeownerCaseLink[],
priorThreadEmails: PriorEmail[],
): MatchResult {
const scores = new Map<string, ScoredMatch>();
const bump = (caseId: string, points: number, reason: string) => {
const cur = scores.get(caseId);
if (cur) {
cur.score += points;
if (!cur.reasons.includes(reason)) cur.reasons.push(reason);
} else {
scores.set(caseId, { case_id: caseId, score: points, reasons: [reason] });
}
};
// 1. Subject tag → match against case_number / court_case_number
const tag = extractSubjectTag(email.subject);
if (tag) {
const tagN = norm(tag);
for (const c of cases) {
if (norm(c.case_number) === tagN || norm(c.court_case_number) === tagN) {
bump(c.id, 100, `Subject tag matches case number ${c.case_number}`);
}
}
}
// 2. Reply threading via In-Reply-To / References
const threadIds = uniq(
[email.in_reply_to, ...(email.email_references ?? [])].filter(
(s): s is string => !!s,
),
);
if (threadIds.length > 0) {
const threadMap = new Map<string, string>();
for (const p of priorThreadEmails) {
if (p.message_id && p.case_id) threadMap.set(p.message_id, p.case_id);
}
for (const id of threadIds) {
const cid = threadMap.get(id);
if (cid) bump(cid, 90, "Reply to a previously matched email");
}
}
// 3. Contact email lookup (sender + any recipient)
const allAddrs = uniq(
[email.from_address, ...email.to_addresses, ...email.cc_addresses]
.map(norm)
.filter(Boolean),
);
if (allAddrs.length > 0) {
// Direct contact → case_contacts
for (const link of contactLinks) {
const e = norm(link.email);
if (e && allAddrs.includes(e)) {
bump(link.case_id, 70, `Contact ${link.email} is linked to this case`);
}
}
// Client primary contact → all cases for that client
for (const link of clientLinks) {
const e = norm(link.primary_contact_email);
if (e && allAddrs.includes(e)) {
for (const cid of link.case_ids) {
bump(cid, 60, `Client primary contact ${link.primary_contact_email}`);
}
}
}
// Homeowner → cases for that homeowner
for (const link of homeownerLinks) {
const e = norm(link.email);
if (e && allAddrs.includes(e)) {
for (const cid of link.case_ids) {
bump(cid, 55, `Homeowner ${link.email}`);
}
}
}
// Opposing counsel
for (const c of cases) {
const e = norm(c.opposing_counsel_email);
if (e && allAddrs.includes(e)) {
bump(c.id, 55, `Opposing counsel ${c.opposing_counsel_email}`);
}
}
}
// 4. Body keyword scan: case numbers
const body = norm(email.body_text);
if (body) {
for (const c of cases) {
const cn = norm(c.case_number);
const ccn = norm(c.court_case_number);
if (cn && cn.length >= 3 && body.includes(cn)) {
bump(c.id, 45, `Case number ${c.case_number} mentioned in body`);
}
if (ccn && ccn !== cn && ccn.length >= 3 && body.includes(ccn)) {
bump(c.id, 45, `Court case number ${c.court_case_number} in body`);
}
}
}
// Decide
const ranked = Array.from(scores.values()).sort((a, b) => b.score - a.score);
if (ranked.length === 0) {
return { status: "unmatched", case_id: null, reason: null, suggestions: [] };
}
const top = ranked[0];
const second = ranked[1];
const clearLead = !second || top.score - second.score >= 30;
if (top.score >= 90 && clearLead) {
return {
status: "matched",
case_id: top.case_id,
reason: top.reasons.join("; "),
suggestions: ranked.slice(0, 3),
};
}
// Single high-confidence contact match with no competition → matched
if (top.score >= 70 && !second) {
return {
status: "matched",
case_id: top.case_id,
reason: top.reasons.join("; "),
suggestions: ranked.slice(0, 3),
};
}
// Below auto-match threshold but above noise → suggest
const meaningful = ranked.filter((r) => r.score >= 30).slice(0, 3);
if (meaningful.length > 0) {
return {
status: "suggested",
case_id: null,
reason: null,
suggestions: meaningful,
};
}
return { status: "unmatched", case_id: null, reason: null, suggestions: [] };
}