Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
5924f3f4b7
commit
66194febdf
@@ -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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user