@@ -238,13 +238,32 @@ func (h *Handlers) suggestEscalationTargets(ctx context.Context, geometryJSON st
238238func (d * caseContactDiscoverer ) EnrichTargets (ctx context.Context , reports []models.ReportWithAnalysis , existing []models.CaseEscalationTarget , limit int ) []models.CaseEscalationTarget {
239239 merger := newCaseTargetMerger (limit )
240240 inferredFallback := make ([]models.CaseEscalationTarget , 0 , len (existing ))
241+ visitedWebsites := make (map [string ]struct {})
242+ searchedQueries := make (map [string ]struct {})
241243 for _ , target := range existing {
242244 if strings .EqualFold (strings .TrimSpace (target .TargetSource ), "inferred_contact" ) {
243245 inferredFallback = append (inferredFallback , target )
244246 continue
245247 }
246248 merger .Add (target )
247249 }
250+ for _ , seed := range collectExistingWebsiteSeeds (existing ) {
251+ if ctx .Err () != nil {
252+ break
253+ }
254+ d .addWebsiteTargets (
255+ ctx ,
256+ seed .RoleType ,
257+ seed .Organization ,
258+ seed .Website ,
259+ seed .ContactURL ,
260+ seed .Source ,
261+ seed .BaseConfidence ,
262+ seed .Rationale ,
263+ merger ,
264+ visitedWebsites ,
265+ )
266+ }
248267 if len (reports ) == 0 {
249268 if countPreferredCaseTargets (merger .Targets ()) == 0 {
250269 for _ , target := range inferredFallback {
@@ -254,8 +273,6 @@ func (d *caseContactDiscoverer) EnrichTargets(ctx context.Context, reports []mod
254273 return merger .Targets ()
255274 }
256275
257- visitedWebsites := make (map [string ]struct {})
258- searchedQueries := make (map [string ]struct {})
259276 hazardProfile := analyzeCaseHazardProfile (reports )
260277 for _ , seed := range buildCaseDiscoverySeeds (reports , 2 ) {
261278 if ctx .Err () != nil {
@@ -322,6 +339,86 @@ func (d *caseContactDiscoverer) EnrichTargets(ctx context.Context, reports []mod
322339 return merger .Targets ()
323340}
324341
342+ type existingWebsiteSeed struct {
343+ RoleType string
344+ Organization string
345+ Website string
346+ ContactURL string
347+ Source string
348+ BaseConfidence float64
349+ Rationale string
350+ }
351+
352+ func collectExistingWebsiteSeeds (existing []models.CaseEscalationTarget ) []existingWebsiteSeed {
353+ seeds := make ([]existingWebsiteSeed , 0 , len (existing ))
354+ seen := make (map [string ]struct {})
355+ for _ , target := range existing {
356+ website , contactURL := existingTargetWebsiteSeed (target )
357+ if website == "" && contactURL == "" {
358+ continue
359+ }
360+ roleType := emptyDefault (strings .TrimSpace (target .RoleType ), "operator" )
361+ organization := firstNonEmpty (strings .TrimSpace (target .Organization ), strings .TrimSpace (target .DisplayName ))
362+ if organization == "" {
363+ organization = "Official stakeholder"
364+ }
365+ source := emptyDefault (strings .TrimSpace (target .TargetSource ), "saved_case_target" )
366+ baseConfidence := target .ConfidenceScore
367+ if baseConfidence <= 0 {
368+ baseConfidence = 0.74
369+ }
370+ key := strings .Join ([]string {
371+ strings .ToLower (roleType ),
372+ strings .ToLower (organization ),
373+ canonicalURLKey (firstNonEmpty (contactURL , website )),
374+ }, "|" )
375+ if key == "" {
376+ continue
377+ }
378+ if _ , exists := seen [key ]; exists {
379+ continue
380+ }
381+ seen [key ] = struct {}{}
382+ seeds = append (seeds , existingWebsiteSeed {
383+ RoleType : roleType ,
384+ Organization : organization ,
385+ Website : firstNonEmpty (website , contactURL ),
386+ ContactURL : firstNonEmpty (contactURL , website ),
387+ Source : source ,
388+ BaseConfidence : maxFloat (baseConfidence , 0.72 ),
389+ Rationale : fmt .Sprintf ("Official website previously associated with %s." , organization ),
390+ })
391+ }
392+ return seeds
393+ }
394+
395+ func existingTargetWebsiteSeed (target models.CaseEscalationTarget ) (string , string ) {
396+ candidates := []string {
397+ strings .TrimSpace (target .Website ),
398+ strings .TrimSpace (target .ContactURL ),
399+ strings .TrimSpace (target .SourceURL ),
400+ }
401+ legacyEmail := strings .TrimSpace (target .Email )
402+ if legacyEmail != "" && normalizeEmail (legacyEmail ) == "" {
403+ candidates = append (candidates , legacyEmail )
404+ }
405+
406+ var website string
407+ var contactURL string
408+ for _ , raw := range candidates {
409+ if raw == "" {
410+ continue
411+ }
412+ if contactURL == "" {
413+ contactURL = normalizeFlexibleURL (raw )
414+ }
415+ if website == "" {
416+ website = normalizeWebsiteURL (raw )
417+ }
418+ }
419+ return website , firstNonEmpty (contactURL , website )
420+ }
421+
325422func buildCaseDiscoverySeeds (reports []models.ReportWithAnalysis , maxSeeds int ) []caseDiscoverySeed {
326423 if maxSeeds <= 0 {
327424 maxSeeds = 1
@@ -350,8 +447,10 @@ func buildCaseDiscoverySeeds(reports []models.ReportWithAnalysis, maxSeeds int)
350447 names = appendUniqueStrings (names ,
351448 strings .TrimSpace (analysis .BrandDisplayName ),
352449 strings .TrimSpace (analysis .BrandName ),
353- strings .TrimSpace (analysis .Title ),
354450 )
451+ if preferredClassification (& report ) == "digital" {
452+ names = appendUniqueStrings (names , strings .TrimSpace (analysis .Title ))
453+ }
355454 }
356455 seeds = append (seeds , caseDiscoverySeed {
357456 Latitude : report .Report .Latitude ,
@@ -441,9 +540,12 @@ func countPreferredCaseTargets(targets []models.CaseEscalationTarget) int {
441540}
442541
443542func buildCaseStakeholderSearchQueries (candidateNames []string , locCtx * caseLocationContext , hazard caseHazardProfile ) []caseStakeholderSearchQuery {
444- primaryName := firstNonEmpty ( candidateNames ... )
543+ primaryName := ""
445544 if locCtx != nil {
446- primaryName = firstNonEmpty (primaryName , locCtx .PrimaryName , locCtx .ParentOrg , locCtx .Operator )
545+ primaryName = firstNonEmpty (locCtx .PrimaryName , locCtx .ParentOrg , locCtx .Operator )
546+ }
547+ if primaryName == "" {
548+ primaryName = bestStakeholderNameCandidate (candidateNames )
447549 }
448550 primaryName = strings .TrimSpace (primaryName )
449551 if primaryName == "" {
@@ -578,6 +680,37 @@ func buildCaseStakeholderSearchQueries(candidateNames []string, locCtx *caseLoca
578680 return queries
579681}
580682
683+ func bestStakeholderNameCandidate (candidateNames []string ) string {
684+ for _ , candidate := range candidateNames {
685+ candidate = strings .TrimSpace (candidate )
686+ if candidate == "" || isLikelyIncidentDescriptorName (candidate ) {
687+ continue
688+ }
689+ return candidate
690+ }
691+ return firstNonEmpty (candidateNames ... )
692+ }
693+
694+ func isLikelyIncidentDescriptorName (candidate string ) bool {
695+ lower := strings .ToLower (strings .TrimSpace (candidate ))
696+ if lower == "" {
697+ return false
698+ }
699+ incidentTokens := []string {
700+ "hazard" , "incident" , "defect" , "damage" , "failure" , "crack" , "cracking" ,
701+ "structural" , "unsafe" , "danger" , "collapse" , "debris" , "falling" ,
702+ "obstruction" , "leak" , "litter" , "trash" , "graffiti" , "pothole" , "bug" ,
703+ "vulnerability" , "outage" , "exposed" , "deterioration" , "anomaly" ,
704+ }
705+ hits := 0
706+ for _ , token := range incidentTokens {
707+ if strings .Contains (lower , token ) {
708+ hits ++
709+ }
710+ }
711+ return hits >= 2
712+ }
713+
581714func webSearchRegionForLocation (locCtx * caseLocationContext ) string {
582715 if locCtx == nil {
583716 return ""
@@ -1609,6 +1742,7 @@ func normalizeCaseEscalationTarget(target models.CaseEscalationTarget) (models.C
16091742 target .RoleType = emptyDefault (strings .TrimSpace (target .RoleType ), "contact" )
16101743 target .Organization = strings .TrimSpace (target .Organization )
16111744 target .DisplayName = strings .TrimSpace (target .DisplayName )
1745+ rawEmail := strings .TrimSpace (target .Email )
16121746 target .Email = normalizeEmail (target .Email )
16131747 target .Phone = normalizePhone (target .Phone )
16141748 target .Website = normalizeWebsiteURL (target .Website )
@@ -1620,6 +1754,15 @@ func normalizeCaseEscalationTarget(target models.CaseEscalationTarget) (models.C
16201754 target .SocialHandle = normalizeSocialHandle (target .SocialHandle )
16211755 target .TargetSource = emptyDefault (strings .TrimSpace (target .TargetSource ), "suggested" )
16221756 target .Rationale = strings .TrimSpace (target .Rationale )
1757+ if target .Email == "" && rawEmail != "" {
1758+ if recoveredWebsite := normalizeWebsiteURL (rawEmail ); recoveredWebsite != "" {
1759+ target .Website = firstNonEmpty (target .Website , recoveredWebsite )
1760+ target .ContactURL = firstNonEmpty (target .ContactURL , normalizeFlexibleURL (rawEmail ), recoveredWebsite )
1761+ if strings .TrimSpace (target .Channel ) == "" || strings .EqualFold (strings .TrimSpace (target .Channel ), "email" ) {
1762+ target .Channel = "website"
1763+ }
1764+ }
1765+ }
16231766 if target .DisplayName == "" {
16241767 target .DisplayName = target .Organization
16251768 }
@@ -2373,6 +2516,13 @@ func compactWhitespace(value string) string {
23732516 return strings .Join (strings .Fields (strings .TrimSpace (value )), " " )
23742517}
23752518
2519+ func maxFloat (left , right float64 ) float64 {
2520+ if left > right {
2521+ return left
2522+ }
2523+ return right
2524+ }
2525+
23762526func normalizeEmail (email string ) string {
23772527 email = strings .ToLower (strings .TrimSpace (email ))
23782528 if email == "" || ! strings .Contains (email , "@" ) {
0 commit comments