Skip to content

Commit 2110bfb

Browse files
committed
Fix case contact enrichment seeds
1 parent 1f8d412 commit 2110bfb

File tree

3 files changed

+220
-5
lines changed

3 files changed

+220
-5
lines changed

report-listener/handlers/case_contact_discovery.go

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,13 +238,32 @@ func (h *Handlers) suggestEscalationTargets(ctx context.Context, geometryJSON st
238238
func (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+
325422
func 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

443542
func 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+
581714
func 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+
23762526
func normalizeEmail(email string) string {
23772527
email = strings.ToLower(strings.TrimSpace(email))
23782528
if email == "" || !strings.Contains(email, "@") {

report-listener/handlers/case_contact_discovery_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,53 @@ func TestEnrichTargetsDropsInferredFallbackWhenActualTargetsExist(t *testing.T)
186186
}
187187
}
188188

189+
func TestNormalizeCaseEscalationTargetRepairsLegacyWebsiteEmail(t *testing.T) {
190+
target, ok := normalizeCaseEscalationTarget(models.CaseEscalationTarget{
191+
RoleType: "contact",
192+
Organization: "Schulhaus Kopfholz",
193+
Email: "https://www.schule-adliswil.ch",
194+
TargetSource: "area_contact",
195+
ConfidenceScore: 0.9,
196+
})
197+
if !ok {
198+
t.Fatalf("expected legacy website target to normalize")
199+
}
200+
if target.Email != "" {
201+
t.Fatalf("expected legacy website email to be cleared, got %#v", target)
202+
}
203+
if target.Channel != "website" {
204+
t.Fatalf("expected website channel, got %#v", target)
205+
}
206+
if target.Website != "https://www.schule-adliswil.ch/" {
207+
t.Fatalf("unexpected website normalization: %#v", target)
208+
}
209+
if target.ContactURL != "https://www.schule-adliswil.ch/" {
210+
t.Fatalf("unexpected contact url normalization: %#v", target)
211+
}
212+
}
213+
214+
func TestBuildCaseStakeholderSearchQueriesPrefersLocationContextName(t *testing.T) {
215+
queries := buildCaseStakeholderSearchQueries(
216+
[]string{"Extreme Structural Hazard: Bricks Separating from Primary School Facade"},
217+
&caseLocationContext{
218+
PrimaryName: "Schulhaus Kopfholz",
219+
City: "Adliswil",
220+
State: "Zürich",
221+
CountryCode: "ch",
222+
},
223+
caseHazardProfile{Structural: true, Severe: true},
224+
)
225+
if len(queries) == 0 {
226+
t.Fatalf("expected search queries")
227+
}
228+
if queries[0].Organization != "Schulhaus Kopfholz" {
229+
t.Fatalf("expected place name to drive search organization, got %#v", queries[0])
230+
}
231+
if queries[0].Query == "" || queries[0].Query == "\"Extreme Structural Hazard: Bricks Separating from Primary School Facade\" contact" {
232+
t.Fatalf("expected location-aware query, got %#v", queries[0])
233+
}
234+
}
235+
189236
func TestParseDuckDuckGoSearchResults(t *testing.T) {
190237
raw := `
191238
<html><body>

report-listener/handlers/cases.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,24 @@ func (h *Handlers) enrichCaseEscalationTargets(ctx context.Context, detail *mode
682682
}
683683

684684
func filterVisibleCaseTargets(targets []models.CaseEscalationTarget) []models.CaseEscalationTarget {
685+
normalized := make([]models.CaseEscalationTarget, 0, len(targets))
686+
seen := make(map[string]struct{})
687+
for _, target := range targets {
688+
cleaned, ok := normalizeCaseEscalationTarget(target)
689+
if !ok {
690+
continue
691+
}
692+
key := caseEscalationTargetKey(cleaned)
693+
if key != "" {
694+
if _, exists := seen[key]; exists {
695+
continue
696+
}
697+
seen[key] = struct{}{}
698+
}
699+
normalized = append(normalized, cleaned)
700+
}
701+
targets = normalized
702+
685703
hasPreferred := false
686704
for _, target := range targets {
687705
if strings.EqualFold(strings.TrimSpace(target.TargetSource), "inferred_contact") {

0 commit comments

Comments
 (0)