tool/Analysis/functions/Invoke-TriageContacts.ps1
|
function Invoke-TriageContacts { <# .SYNOPSIS Triage contacts to decide which warrant deep web-search research. .DESCRIPTION Reads contact-research-pass1.json + contact-enrichment.json. For each contact, uses Claude Opus to evaluate whether the contact justifies the cost of a deep GPT-5.5-pro web-search research pass. Decision criteria: - Number of "Unknown" fields remaining after Pass 1 - Whether the company domain has usable website content - Seniority level (senior contacts are more findable/valuable) - Whether telematics activity signals exist - Original engagement quality (sentiment, stage) Results are written to contact-triage.json. .PARAMETER Pass1Path Path to contact-research-pass1.json. .PARAMETER EnrichmentPath Path to contact-enrichment.json (optional but recommended). .PARAMETER AnalysesPath Path to email-analyses.json for sentiment context. .PARAMETER OutputPath Path to data/ directory where contact-triage.json will be written. .PARAMETER CachePath Directory for response caching. .PARAMETER DelayMs Delay between API calls in milliseconds. Default: 800. .PARAMETER MaxErrors Circuit breaker: stop after this many consecutive errors. Default: 5. .OUTPUTS PSCustomObject with total_contacts, total_selected, total_skipped, total_errors. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Pass1Path, [string]$EnrichmentPath, [string]$AnalysesPath, [Parameter(Mandatory)] [string]$OutputPath, [string]$CachePath, [PSCustomObject]$OwnerProfile, [int]$DelayMs = 800, [int]$MaxErrors = 5 ) # Load Pass 1 research $pass1 = Get-Content $Pass1Path -Raw | ConvertFrom-Json # Load enrichment if available $enrichment = $null if ($EnrichmentPath -and (Test-Path $EnrichmentPath)) { $enrichment = Get-Content $EnrichmentPath -Raw | ConvertFrom-Json } # Load analyses if available (for sentiment context) $analysesLookup = @{} if ($AnalysesPath -and (Test-Path $AnalysesPath)) { $analyses = Get-Content $AnalysesPath -Raw | ConvertFrom-Json foreach ($a in $analyses.analyses) { if ($a.participants) { foreach ($p in $a.participants) { if ($p.email -and $p.type -eq 'external') { $key = $p.email.ToLowerInvariant() if (-not $analysesLookup.ContainsKey($key)) { $analysesLookup[$key] = $a } } } } } } $systemPrompt = @" You are a strategic research triage analyst for a B2B lead reactivation pipeline. Your job: decide whether each contact warrants an EXPENSIVE deep web-search research pass (using GPT-5.5-pro with live web search, costing ~120-150 seconds per contact). DECISION CRITERIA — select for deep research when: 1. Pass 1 has significant gaps (multiple "Unknown" fields, especially current_title or current_organisation) 2. The company domain has a real website (suggesting the org is findable online) 3. The contact holds or held a senior role (CEO, VP, Director, Head of) 4. There are telematics/IoT signals worth investigating further 5. The original email had warm engagement or was near-close SKIP deep research when: 1. Pass 1 already found current role + organisation with Medium/High confidence 2. The contact is at a free email provider (gmail, outlook) with no company context 3. The role is junior with low engagement 4. All fields are already populated Return ONLY valid JSON — no markdown, no preamble, no explanation. "@ if ($OwnerProfile) { $systemPrompt += "`n`nLEAD-MINING COMPANY CONTEXT:`n- Company: $($OwnerProfile.name)`n- Industry: $($OwnerProfile.industry)" if ($OwnerProfile.verticals) { $systemPrompt += "`n- Target verticals: $($OwnerProfile.verticals -join '; ')" } $systemPrompt += "`n`nPrioritise contacts whose organisations operate in verticals aligned with the lead-mining company's offerings." } $results = @() $selectedCount = 0 $skippedCount = 0 $errors = @() $consecutiveErrors = 0 $contactCount = 0 foreach ($contact in $pass1.contacts) { $contactCount++ if ($consecutiveErrors -ge $MaxErrors) { Write-Host " Circuit breaker: $MaxErrors consecutive errors, stopping triage" -ForegroundColor Red for ($i = $contactCount - 1; $i -lt $pass1.contacts.Count; $i++) { $results += [ordered]@{ email = $pass1.contacts[$i].email name = $pass1.contacts[$i].name selected = $false reason = 'Skipped (circuit breaker)' error = 'Circuit breaker' } $skippedCount++ } break } # Build context summary for triage $unknownCount = 0 $populatedFields = @() if ($contact.research) { foreach ($field in @('current_title', 'current_organisation', 'still_in_role', 'linkedin_active')) { $val = $contact.research.$field if (-not $val -or $val -eq 'Unknown') { $unknownCount++ } else { $populatedFields += "$field=$val" } } } else { $unknownCount = 4 } $researchSummary = if ($contact.research) { @" - Current title: $($contact.research.current_title) - Current organisation: $($contact.research.current_organisation) - Still in role: $($contact.research.still_in_role) - Telematics activity: found=$($contact.research.telematics_activity.found), summary="$($contact.research.telematics_activity.summary)" - LinkedIn: $($contact.research.linkedin_active) - Confidence: $($contact.research.confidence) - Unknown fields: $unknownCount of 4 key fields "@ } else { "No research data (all fields Unknown)" } # Enrichment context $enrichmentContext = "No enrichment data available" if ($enrichment) { $emailKey = $contact.email.ToLowerInvariant() $contactEnrich = $null if ($enrichment.contacts.PSObject.Properties.Name -contains $emailKey) { $contactEnrich = $enrichment.contacts.$emailKey } if ($contactEnrich) { $domain = $contactEnrich.domain $websiteAvail = $contactEnrich.website_available $isFree = $contactEnrich.is_free_provider $enrichmentContext = "Domain: $domain, Free provider: $isFree, Website available: $websiteAvail" if (-not $isFree -and $enrichment.domains.PSObject.Properties.Name -contains $domain) { $domainInfo = $enrichment.domains.$domain if ($domainInfo.pages) { $pageCount = @($domainInfo.pages.PSObject.Properties | Where-Object { $_.Value.success -eq $true }).Count $enrichmentContext += ", Scraped pages with content: $pageCount" } } } } # Sentiment context $sentimentContext = "No email analysis available" $emailKey2 = $contact.email.ToLowerInvariant() if ($analysesLookup.ContainsKey($emailKey2)) { $analysis = $analysesLookup[$emailKey2] if ($analysis.sentiment) { $sentimentContext = "Tone: $($analysis.sentiment.overall_tone), Engagement: $($analysis.sentiment.engagement_level), Conclusion: $($analysis.sentiment.conclusion)" } if ($analysis.opportunity) { $sentimentContext += ", Stage: $($analysis.opportunity.stage), Category: $(($analysis.opportunity.category) -join ', ')" } } $userPrompt = @" Evaluate whether this contact should receive deep web-search research: ## Contact - Name: $($contact.name) - Email: $($contact.email) - Original role: $($contact.original_role) - Original organisation: $($contact.original_organisation) - Email date: $($contact.email_date) ## Pass 1 Research Results $researchSummary ## OSINT Enrichment $enrichmentContext ## Original Email Context $sentimentContext ## Required Output (JSON) { "selected": true or false, "confidence": "High|Medium|Low", "reason": "1-2 sentence explanation of why this contact should or should not receive deep research", "priority": 1-5 (1=highest priority for deep research, 5=lowest) } "@ Write-Host " [$contactCount/$($pass1.contacts.Count)] $($contact.name)" -NoNewline -ForegroundColor Gray try { $aiParams = @{ SystemPrompt = $systemPrompt UserPrompt = $userPrompt JsonMode = $true MaxTokens = 500 Provider = 'anthropic' } if ($CachePath) { $cacheKey = ($contact.email -replace '[^\w\-\.]', '_') $aiParams.CachePath = $CachePath $aiParams.CacheKey = $cacheKey $response = Invoke-PCCompletionCached @aiParams } else { $response = Invoke-PCCompletion @aiParams } if (-not $response) { throw 'Empty response from AI' } $parsed = Repair-JsonResponse -Response $response $isSelected = [bool]$parsed.selected $results += [ordered]@{ email = $contact.email name = $contact.name selected = $isSelected confidence = $parsed.confidence reason = $parsed.reason priority = [int]$parsed.priority error = $null } if ($isSelected) { $selectedCount++ Write-Host " → SELECTED (P$($parsed.priority))" -ForegroundColor Cyan } else { $skippedCount++ Write-Host " → skip" -ForegroundColor DarkGray } $consecutiveErrors = 0 } catch { $consecutiveErrors++ $results += [ordered]@{ email = $contact.email name = $contact.name selected = $false reason = "Triage error: $($_.Exception.Message)" error = $_.Exception.Message } $errors += [ordered]@{ contact = $contact.email; error = $_.Exception.Message } $skippedCount++ Write-Host " ERROR" -ForegroundColor Red } if ($contactCount -lt $pass1.contacts.Count) { Start-Sleep -Milliseconds $DelayMs } } # Sort selected contacts by priority $selectedContacts = @($results | Where-Object { $_.selected } | Sort-Object { $_.priority }) # Write output $output = [ordered]@{ metadata = [ordered]@{ generated_at = (Get-Date).ToString('o') total_contacts = $pass1.contacts.Count total_selected = $selectedCount total_skipped = $skippedCount total_errors = $errors.Count selection_rate = if ($pass1.contacts.Count -gt 0) { [math]::Round($selectedCount / $pass1.contacts.Count * 100, 1) } else { 0 } } selected = $selectedContacts all = $results } $outputFile = Join-Path $OutputPath 'contact-triage.json' $output | ConvertTo-Json -Depth 10 | Set-Content -Path $outputFile -Encoding UTF8 Write-Host " Selected $selectedCount/$($pass1.contacts.Count) for deep research ($($output.metadata.selection_rate)%)" -ForegroundColor $(if ($selectedCount -gt 0) { 'Green' } else { 'Yellow' }) [PSCustomObject]@{ total_contacts = $pass1.contacts.Count total_selected = $selectedCount total_skipped = $skippedCount total_errors = $errors.Count output_path = $outputFile } } |