Skip to content

Commit 4e767e1

Browse files
committed
Tighten notify recipient selection
1 parent 9119ede commit 4e767e1

File tree

2 files changed

+435
-11
lines changed

2 files changed

+435
-11
lines changed

report-listener/handlers/case_notify_routing.go

Lines changed: 285 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"math"
7+
"net/url"
78
"sort"
89
"strings"
910
"time"
@@ -220,15 +221,31 @@ func buildNotifyPlan(caseID string, profile caseRoutingProfile, targets []models
220221
}
221222
}
222223

224+
sortedTargets := sortTargetsForNotifyPlan(profile, targets)
223225
items := make([]models.CaseNotifyPlanItem, 0, len(targets))
224226
perWaveRank := make(map[int]int)
225227
selectedCountByScope := make(map[string]int)
226-
for _, target := range targets {
228+
selectedCountByOrgWave := make(map[string]int)
229+
selectedCountByDedupKey := make(map[string]int)
230+
for _, target := range sortedTargets {
227231
wave := notifyTierForTarget(profile, target)
228232
perWaveRank[wave]++
229-
selected := shouldSelectTargetForNotifyPlan(profile, target, selectedCountByScope)
233+
selected := shouldSelectTargetForNotifyPlan(
234+
profile,
235+
target,
236+
wave,
237+
selectedCountByScope,
238+
selectedCountByOrgWave,
239+
selectedCountByDedupKey,
240+
)
230241
if selected {
231242
selectedCountByScope[target.DecisionScope]++
243+
if key := orgWaveSelectionKey(wave, target); key != "" {
244+
selectedCountByOrgWave[key]++
245+
}
246+
if key := targetSelectionDedupKey(wave, target); key != "" {
247+
selectedCountByDedupKey[key]++
248+
}
232249
}
233250
observationID := observationIDs[observationKeyForTarget(target)]
234251
var observationPtr *int64
@@ -263,6 +280,117 @@ func buildNotifyPlan(caseID string, profile caseRoutingProfile, targets []models
263280
}
264281
}
265282

283+
func sortTargetsForNotifyPlan(profile caseRoutingProfile, targets []models.CaseEscalationTarget) []models.CaseEscalationTarget {
284+
sorted := append([]models.CaseEscalationTarget(nil), targets...)
285+
sort.SliceStable(sorted, func(i, j int) bool {
286+
left := sorted[i]
287+
right := sorted[j]
288+
leftWave := notifyTierForTarget(profile, left)
289+
rightWave := notifyTierForTarget(profile, right)
290+
if leftWave != rightWave {
291+
return leftWave < rightWave
292+
}
293+
if left.DecisionScope != right.DecisionScope {
294+
return decisionScopePriority(left.DecisionScope) < decisionScopePriority(right.DecisionScope)
295+
}
296+
leftOrg := organizationBucketKey(left)
297+
rightOrg := organizationBucketKey(right)
298+
if leftOrg != "" && leftOrg == rightOrg {
299+
leftEndpointRank := primaryEndpointRank(left)
300+
rightEndpointRank := primaryEndpointRank(right)
301+
if leftEndpointRank != rightEndpointRank {
302+
return leftEndpointRank < rightEndpointRank
303+
}
304+
leftRoleRank := rolePriorityForSelection(left.RoleType)
305+
rightRoleRank := rolePriorityForSelection(right.RoleType)
306+
if leftRoleRank != rightRoleRank {
307+
return leftRoleRank > rightRoleRank
308+
}
309+
}
310+
if !almostEqualFloat(left.ActionabilityScore, right.ActionabilityScore) {
311+
return left.ActionabilityScore > right.ActionabilityScore
312+
}
313+
leftEndpointRank := primaryEndpointRank(left)
314+
rightEndpointRank := primaryEndpointRank(right)
315+
if leftEndpointRank != rightEndpointRank {
316+
return leftEndpointRank < rightEndpointRank
317+
}
318+
if !almostEqualFloat(left.ConfidenceScore, right.ConfidenceScore) {
319+
return left.ConfidenceScore > right.ConfidenceScore
320+
}
321+
return strings.ToLower(strings.TrimSpace(firstNonEmpty(left.DisplayName, left.Organization, left.Email, left.Phone))) <
322+
strings.ToLower(strings.TrimSpace(firstNonEmpty(right.DisplayName, right.Organization, right.Email, right.Phone)))
323+
})
324+
return sorted
325+
}
326+
327+
func decisionScopePriority(scope string) int {
328+
switch scope {
329+
case "site_ops":
330+
return 0
331+
case "asset_owner":
332+
return 1
333+
case "regulator":
334+
return 2
335+
case "project_party":
336+
return 3
337+
default:
338+
return 4
339+
}
340+
}
341+
342+
func rolePriorityForSelection(roleType string) int {
343+
switch strings.ToLower(strings.TrimSpace(roleType)) {
344+
case "operator", "operator_admin", "site_leadership":
345+
return 9
346+
case "facility_manager", "transit_authority", "public_works", "traffic_authority", "infrastructure_authority":
347+
return 8
348+
case "building_authority", "public_safety", "fire_authority", "transit_safety":
349+
return 7
350+
case "owner", "property_owner", "landlord":
351+
return 6
352+
case "support", "engineering", "product_owner", "trust_safety", "security":
353+
return 5
354+
case "architect", "engineer", "contractor":
355+
return 4
356+
default:
357+
return 1
358+
}
359+
}
360+
361+
func primaryEndpointRank(target models.CaseEscalationTarget) int {
362+
switch caseTargetChannel(target) {
363+
case "email":
364+
localPart := strings.ToLower(strings.TrimSpace(target.Email))
365+
if at := strings.Index(localPart, "@"); at >= 0 {
366+
localPart = localPart[:at]
367+
}
368+
switch {
369+
case containsAny(localPart, []string{"noreply", "no-reply", "donotreply", "do-not-reply"}):
370+
return 6
371+
case containsAny(localPart, []string{"press", "media", "marketing", "kommunikation", "communication", "webmaster", "web"}):
372+
return 4
373+
case containsAny(localPart, []string{"info", "contact", "support", "help", "service", "team", "admin", "office", "facilit", "maint", "security", "safety", "inspection", "permit", "planning", "bau", "school", "station", "publicworks", "transit", "operations", "ops"}):
374+
return 0
375+
case strings.Contains(localPart, ".") || strings.Contains(localPart, "_"):
376+
return 2
377+
default:
378+
return 1
379+
}
380+
case "phone":
381+
return 1
382+
case "website":
383+
if strings.TrimSpace(target.ContactURL) != "" {
384+
return 3
385+
}
386+
return 4
387+
case "social":
388+
return 5
389+
default:
390+
return 7
391+
}
392+
}
393+
266394
func buildCaseRoutingProfile(detail *models.CaseDetail) caseRoutingProfile {
267395
textParts := []string{
268396
detail.Case.Title,
@@ -545,29 +673,42 @@ func buildCaseTargetReason(profile caseRoutingProfile, target models.CaseEscalat
545673

546674
func buildCaseNotifyPlanSummary(profile caseRoutingProfile, items []models.CaseNotifyPlanItem) string {
547675
selected := 0
548-
authorities := 0
676+
selectedDirect := 0
677+
backupAuthorities := 0
549678
for _, item := range items {
550679
if item.Selected {
551680
selected++
681+
if item.DecisionScope == "site_ops" || item.DecisionScope == "asset_owner" {
682+
selectedDirect++
683+
}
552684
}
553685
if item.DecisionScope == "regulator" {
554-
authorities++
686+
if !item.Selected {
687+
backupAuthorities++
688+
}
555689
}
556690
}
557691
switch profile.HazardMode {
558692
case "emergency":
559-
return fmt.Sprintf("Immediate-response plan prioritizes %d direct operators/owners first, with %d authority targets ready in the next wave.", selected, authorities)
693+
return fmt.Sprintf("Immediate-response plan selects %d primary recipients now, while holding %d authority contacts in reserve for widening if needed.", selected, backupAuthorities)
560694
case "urgent":
561-
return fmt.Sprintf("Urgent notify plan focuses on %d direct operators/owners now and keeps %d authority stakeholders queued for escalation.", selected, authorities)
695+
return fmt.Sprintf("Urgent notify plan focuses on %d direct operators/owners now and keeps %d authority stakeholders queued for escalation.", selectedDirect, backupAuthorities)
562696
default:
563697
if strings.HasPrefix(profile.DefectClass, "digital_") {
564-
return fmt.Sprintf("Notify plan recommends %d primary product/operator contacts now, with %d authority or oversight stakeholders retained for escalation if the issue persists.", selected, authorities)
698+
return fmt.Sprintf("Notify plan recommends %d primary product/operator contacts now, with %d authority or oversight stakeholders retained for escalation if the issue persists.", selected, backupAuthorities)
565699
}
566-
return fmt.Sprintf("Notify plan recommends %d primary contacts now, with %d authority or oversight stakeholders retained for escalation if needed.", selected, authorities)
700+
return fmt.Sprintf("Notify plan recommends %d primary contacts now, with %d authority or oversight stakeholders retained for escalation if needed.", selected, backupAuthorities)
567701
}
568702
}
569703

570-
func shouldSelectTargetForNotifyPlan(profile caseRoutingProfile, target models.CaseEscalationTarget, selectedCountByScope map[string]int) bool {
704+
func shouldSelectTargetForNotifyPlan(
705+
profile caseRoutingProfile,
706+
target models.CaseEscalationTarget,
707+
wave int,
708+
selectedCountByScope map[string]int,
709+
selectedCountByOrgWave map[string]int,
710+
selectedCountByDedupKey map[string]int,
711+
) bool {
571712
if target.SendEligibility != "auto" {
572713
return false
573714
}
@@ -587,15 +728,148 @@ func shouldSelectTargetForNotifyPlan(profile caseRoutingProfile, target models.C
587728
default:
588729
limit = 1
589730
}
590-
return selectedCountByScope[scope] < limit
731+
if selectedCountByScope[scope] >= limit {
732+
return false
733+
}
734+
if key := orgWaveSelectionKey(wave, target); key != "" {
735+
if selectedCountByOrgWave[key] >= orgWaveSelectionLimit(profile, target, wave) {
736+
return false
737+
}
738+
}
739+
if key := targetSelectionDedupKey(wave, target); key != "" {
740+
if selectedCountByDedupKey[key] > 0 {
741+
return false
742+
}
743+
}
744+
return true
745+
}
746+
747+
func orgWaveSelectionKey(wave int, target models.CaseEscalationTarget) string {
748+
orgKey := organizationBucketKey(target)
749+
if orgKey == "" {
750+
return ""
751+
}
752+
return fmt.Sprintf("%d|%s", wave, orgKey)
753+
}
754+
755+
func orgWaveSelectionLimit(profile caseRoutingProfile, target models.CaseEscalationTarget, wave int) int {
756+
switch target.DecisionScope {
757+
case "site_ops":
758+
if wave == 1 {
759+
return 2
760+
}
761+
return 1
762+
case "regulator":
763+
if profile.HazardMode == "emergency" {
764+
switch roleFamilyForTarget(target) {
765+
case "public_safety", "fire_authority":
766+
return 2
767+
}
768+
}
769+
return 1
770+
default:
771+
return 1
772+
}
773+
}
774+
775+
func targetSelectionDedupKey(wave int, target models.CaseEscalationTarget) string {
776+
orgKey := organizationBucketKey(target)
777+
if orgKey == "" {
778+
return ""
779+
}
780+
return fmt.Sprintf("%d|%s|%s", wave, orgKey, roleFamilyForTarget(target))
781+
}
782+
783+
func roleFamilyForTarget(target models.CaseEscalationTarget) string {
784+
switch strings.ToLower(strings.TrimSpace(target.RoleType)) {
785+
case "operator", "operator_admin", "site_leadership":
786+
return "site_operator"
787+
case "facility_manager":
788+
return "facility_manager"
789+
case "owner", "property_owner", "landlord":
790+
return "asset_owner"
791+
case "building_authority":
792+
return "building_authority"
793+
case "public_safety":
794+
return "public_safety"
795+
case "fire_authority":
796+
return "fire_authority"
797+
case "transit_authority":
798+
return "transit_authority"
799+
case "transit_safety":
800+
return "transit_safety"
801+
case "public_works":
802+
return "public_works"
803+
case "traffic_authority":
804+
return "traffic_authority"
805+
case "infrastructure_authority":
806+
return "infrastructure_authority"
807+
case "support":
808+
return "support"
809+
case "engineering":
810+
return "engineering"
811+
case "product_owner":
812+
return "product_owner"
813+
case "security":
814+
return "security"
815+
case "trust_safety":
816+
return "trust_safety"
817+
case "architect":
818+
return "architect"
819+
case "engineer":
820+
return "engineer"
821+
case "contractor":
822+
return "contractor"
823+
default:
824+
return strings.ToLower(strings.TrimSpace(firstNonEmpty(target.RoleType, target.DecisionScope, "other")))
825+
}
591826
}
592827

593828
func endpointKeyForTarget(target models.CaseEscalationTarget) string {
594829
return caseEscalationTargetKey(target)
595830
}
596831

597832
func organizationKeyForTarget(target models.CaseEscalationTarget) string {
598-
return strings.ToLower(strings.TrimSpace(firstNonEmpty(target.Organization, target.DisplayName)))
833+
return organizationBucketKey(target)
834+
}
835+
836+
func organizationBucketKey(target models.CaseEscalationTarget) string {
837+
if key := strings.ToLower(strings.TrimSpace(target.OrganizationKey)); key != "" {
838+
return key
839+
}
840+
if key := strings.ToLower(strings.TrimSpace(firstNonEmpty(target.Organization, target.DisplayName))); key != "" {
841+
return key
842+
}
843+
for _, raw := range []string{target.ContactURL, target.Website, target.SourceURL} {
844+
if host := normalizedTargetHost(raw); host != "" {
845+
return host
846+
}
847+
}
848+
if target.Email != "" {
849+
normalized := strings.ToLower(strings.TrimSpace(target.Email))
850+
if at := strings.LastIndex(normalized, "@"); at >= 0 {
851+
return normalized[at+1:]
852+
}
853+
}
854+
if target.Phone != "" {
855+
return "phone:" + strings.TrimSpace(target.Phone)
856+
}
857+
return ""
858+
}
859+
860+
func normalizedTargetHost(raw string) string {
861+
if strings.TrimSpace(raw) == "" {
862+
return ""
863+
}
864+
parsed, err := url.Parse(raw)
865+
if err != nil {
866+
return ""
867+
}
868+
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
869+
}
870+
871+
func almostEqualFloat(left, right float64) bool {
872+
return math.Abs(left-right) < 1e-9
599873
}
600874

601875
func scoreSourceQuality(target models.CaseEscalationTarget) float64 {

0 commit comments

Comments
 (0)