tool/Analysis/functions/Invoke-ScoreOpportunities.ps1
|
function Invoke-ScoreOpportunities { <# .SYNOPSIS Weighted scoring of opportunities with AI-generated recommended actions. .DESCRIPTION Reads email-analyses.json and contact-research.json, applies deterministic weighted scoring per the profile, then calls an LLM to generate a specific recommended action for each scored contact. Results are written to scoring-results.json. .PARAMETER AnalysesPath Path to email-analyses.json from the analyse stage. .PARAMETER ResearchPath Path to contact-research.json from the research stage. .PARAMETER ProfilePath Path to scoring profile JSON. .PARAMETER OutputPath Path to data/ directory where scoring-results.json will be written. .PARAMETER Model Override model for AI-generated actions. .PARAMETER Provider Override provider. .PARAMETER DelayMs Delay between API calls in milliseconds. Default: 800. .OUTPUTS PSCustomObject with total_scored. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AnalysesPath, [Parameter(Mandatory)] [string]$ResearchPath, [Parameter(Mandatory)] [string]$ProfilePath, [Parameter(Mandatory)] [string]$OutputPath, [string]$Model, [string]$Provider, [string]$CachePath, [int]$DelayMs = 800 ) # Load inputs $analyses = Get-Content $AnalysesPath -Raw | ConvertFrom-Json $research = Get-Content $ResearchPath -Raw | ConvertFrom-Json $profile = Get-Content $ProfilePath -Raw | ConvertFrom-Json # Build research lookup by email $researchLookup = @{} foreach ($contact in $research.contacts) { if ($contact.email) { $researchLookup[$contact.email.ToLowerInvariant()] = $contact } } # Score each email/opportunity $results = @() $scoredCount = 0 foreach ($analysis in $analyses.analyses) { if ($analysis.analysis_error) { continue } # Find primary external contact $primaryContact = $analysis.participants | Where-Object { $_.type -eq 'external' -and $_.email -and $_.email -ne 'Unknown' } | Select-Object -First 1 if (-not $primaryContact) { continue } $scoredCount++ $contactEmail = $primaryContact.email.ToLowerInvariant() $contactResearch = $researchLookup[$contactEmail] # --- Deterministic scoring --- # Formula: weighted = raw / 5 × weight, so composite = Σ weighted is 0-100 $subScores = [ordered]@{} # 1. Strategic Fit (from opportunity category + stage) $strategicRaw = Get-StrategicFitScore -Opportunity $analysis.opportunity $subScores.strategic_fit = [ordered]@{ raw = $strategicRaw weighted = [math]::Round($strategicRaw / 5 * $profile.weights.strategic_fit, 2) } # 2. Seniority (from role) $seniorityRaw = Get-SeniorityScore -Role $primaryContact.role $subScores.seniority = [ordered]@{ raw = $seniorityRaw weighted = [math]::Round($seniorityRaw / 5 * $profile.weights.seniority, 2) } # 3. Engagement Warmth (from sentiment) $engagementRaw = Get-EngagementScore -Sentiment $analysis.sentiment $subScores.engagement_warmth = [ordered]@{ raw = $engagementRaw weighted = [math]::Round($engagementRaw / 5 * $profile.weights.engagement_warmth, 2) } # 4. Market Activity (from research) $marketRaw = Get-MarketActivityScore -Research $contactResearch $subScores.market_activity = [ordered]@{ raw = $marketRaw weighted = [math]::Round($marketRaw / 5 * $profile.weights.market_activity, 2) } # 5. Conversation Stage $stageRaw = Get-ConversationStageScore -Stage $analysis.opportunity.stage $subScores.conversation_stage = [ordered]@{ raw = $stageRaw weighted = [math]::Round($stageRaw / 5 * $profile.weights.conversation_stage, 2) } # 6. Recency (from email date) $recencyRaw = Get-RecencyRawScore -EmailDate $analysis.date $subScores.recency = [ordered]@{ raw = $recencyRaw weighted = [math]::Round($recencyRaw / 5 * $profile.weights.recency, 2) } # 7. Research Confidence $confidenceRaw = Get-ResearchConfidenceScore -Research $contactResearch $subScores.research_confidence = [ordered]@{ raw = $confidenceRaw weighted = [math]::Round($confidenceRaw / 5 * $profile.weights.research_confidence, 2) } # Composite score $compositeScore = [math]::Round( ($subScores.Values | ForEach-Object { $_.weighted } | Measure-Object -Sum).Sum, 2 ) # Recency penalty $recencyPenalty = Get-RecencyPenalty -EmailDate $analysis.date -Penalties $profile.recency_penalty $finalScore = [math]::Round([math]::Max(0, $compositeScore - $recencyPenalty), 2) # Band assignment $band = Get-Band -Score $finalScore -Thresholds $profile.thresholds $urgency = switch ($band) { 1 { 'Immediate' } 2 { 'High' } 3 { 'Medium' } 4 { 'Low' } 5 { 'Archive' } } $results += [ordered]@{ file_path = $analysis.file_path contact_name = $primaryContact.name contact_email = $primaryContact.email organisation = $primaryContact.organisation category = $analysis.opportunity.category sub_scores = $subScores composite_score = $compositeScore recency_penalty_applied = $recencyPenalty final_score = $finalScore band = $band urgency = $urgency recommended_action = $null action_rationale = $null } } # Sort by band asc, then final_score desc $results = @($results | Sort-Object { $_.band }, { - $_.final_score }) # --- AI-generated recommended actions --- Write-Host " Generating recommended actions for $($results.Count) contacts..." -ForegroundColor Gray $actionSystemPrompt = @" You are a strategic business development advisor generating specific, actionable re-engagement recommendations for scored opportunities. RULES: - Return valid JSON only — no markdown fences, no preamble - Recommendations must be SPECIFIC to this contact and situation - Reference the contact's current role/org if research found it - Consider the original sentiment and conversation stage - Higher-band contacts get more ambitious recommendations - Lower-band contacts may get "archive" or "monitor" recommendations - Keep recommended_action under 2 sentences - Keep action_rationale under 2 sentences "@ # Inject owner context if available in profile if ($profile.owner) { $actionSystemPrompt += "`n`nLEAD-MINING COMPANY CONTEXT:`n- Company: $($profile.owner.name)`n- Industry: $($profile.owner.industry)" if ($profile.owner.verticals) { $actionSystemPrompt += "`n- Target verticals: $($profile.owner.verticals -join '; ')" } if ($profile.owner.products) { $actionSystemPrompt += "`n- Products: $($profile.owner.products -join ', ')" } if ($profile.owner.value_propositions) { $actionSystemPrompt += "`n- Value propositions: $($profile.owner.value_propositions -join '; ')" } $actionSystemPrompt += "`n`nTailor re-engagement recommendations to highlight how the lead-mining company's products and capabilities address this contact's likely needs." } for ($i = 0; $i -lt $results.Count; $i++) { $result = $results[$i] # Find the matching analysis for context $matchAnalysis = $analyses.analyses | Where-Object { $_.file_path -eq $result.file_path } | Select-Object -First 1 $matchResearch = $researchLookup[$result.contact_email.ToLowerInvariant()] $researchSummary = if ($matchResearch -and $matchResearch.research) { "Current: $($matchResearch.research.current_title) at $($matchResearch.research.current_organisation). Still in role: $($matchResearch.research.still_in_role). Telematics activity: $($matchResearch.research.telematics_activity.summary). Confidence: $($matchResearch.research.confidence)" } else { "No research data available" } $sentiment = if ($matchAnalysis.sentiment) { "$($matchAnalysis.sentiment.overall_tone) tone, $($matchAnalysis.sentiment.engagement_level) engagement, $($matchAnalysis.sentiment.conclusion)" } else { "Unknown" } $actionPrompt = @" Generate a re-engagement recommendation for this scored opportunity: ## Contact - Name: $($result.contact_name) - Original Role: $($result.organisation) - Category: $(($result.category) -join ', ') ## Opportunity Context - Summary: $($matchAnalysis.opportunity.summary) - Sentiment: $sentiment ## Research Findings $researchSummary ## Score - Composite Score: $($result.composite_score) - Final Score (after recency penalty): $($result.final_score) - Band: $($result.band) ($($result.urgency)) ## Required Output (JSON) { "recommended_action": "Specific re-engagement suggestion", "action_rationale": "Why this action fits this contact's situation" } "@ try { $aiParams = @{ SystemPrompt = $actionSystemPrompt UserPrompt = $actionPrompt JsonMode = $true MaxTokens = 800 Temperature = 0.3 } if ($Model) { $aiParams.Model = $Model } if ($Provider) { $aiParams.Provider = $Provider } # Use cached variant if cache path provided if ($CachePath) { $cacheKey = ($result.contact_email -replace '[^\w\-\.]', '_') $aiParams.CachePath = $CachePath $aiParams.CacheKey = $cacheKey $actionResponse = Invoke-PCCompletionCached @aiParams } else { $actionResponse = Invoke-PCCompletion @aiParams } if ($actionResponse) { $actionParsed = Repair-JsonResponse -Response $actionResponse $results[$i].recommended_action = $actionParsed.recommended_action $results[$i].action_rationale = $actionParsed.action_rationale } } catch { Write-Verbose "Action generation failed for $($result.contact_email): $($_.Exception.Message)" } if ($i -lt $results.Count - 1) { Start-Sleep -Milliseconds $DelayMs } } # Write output $output = [ordered]@{ metadata = [ordered]@{ generated_at = (Get-Date).ToString('o') profile_used = $profile.profile_name total_scored = $results.Count } profile = [ordered]@{ weights = $profile.weights thresholds = $profile.thresholds recency_penalty = $profile.recency_penalty } results = $results } $outputFile = Join-Path $OutputPath 'scoring-results.json' $output | ConvertTo-Json -Depth 10 | Set-Content -Path $outputFile -Encoding UTF8 [PSCustomObject]@{ total_scored = $results.Count output_path = $outputFile } } #region Scoring Helper Functions function Get-StrategicFitScore { param([object]$Opportunity) if (-not $Opportunity) { return 1 } $score = 2 $categories = @($Opportunity.category) if ($categories -contains 'Partnership') { $score += 1 } if ($categories -contains 'Sales/BD') { $score += 1 } if ($Opportunity.stage -in @('active', 'near-close')) { $score += 1 } return [math]::Min(5, $score) } function Get-SeniorityScore { param([string]$Role) if (-not $Role -or $Role -eq 'Unknown') { return 2 } $roleLower = $Role.ToLowerInvariant() if ($roleLower -match 'ceo|cto|cfo|coo|chief|president|founder|owner|managing director') { return 5 } if ($roleLower -match 'vp|vice president|director|head of|general manager') { return 4 } if ($roleLower -match 'manager|senior|lead|principal') { return 3 } if ($roleLower -match 'analyst|engineer|specialist|consultant|coordinator') { return 2 } return 2 } function Get-EngagementScore { param([object]$Sentiment) if (-not $Sentiment) { return 2 } # Stricter scoring: require multiple strong signals to exceed 3 # One-sided engagement caps at 3 regardless of tone $score = 2 if ($Sentiment.overall_tone -eq 'Warm') { $score += 1 } if ($Sentiment.overall_tone -eq 'Cool') { $score -= 1 } # Active mutual only adds if tone is also warm (both signals needed for 4) if ($Sentiment.engagement_level -eq 'Active mutual' -and $Sentiment.overall_tone -eq 'Warm') { $score += 1 } # Near-close conclusion is the only way to reach 5 if ($Sentiment.conclusion -eq 'near-close') { $score += 1 } # One-sided engagement caps at 3 if ($Sentiment.engagement_level -eq 'One-sided') { $score = [math]::Min($score, 3) } return [math]::Max(1, [math]::Min(5, $score)) } function Get-MarketActivityScore { param([object]$Research) if (-not $Research -or -not $Research.research) { return 2 } $r = $Research.research $score = 2 if ($r.telematics_activity.found -eq $true) { $score += 1 } if ($r.telematics_activity.since_email_date -eq $true) { $score += 1 } if ($r.linkedin_active -eq 'Active') { $score += 1 } return [math]::Min(5, $score) } function Get-ConversationStageScore { param([string]$Stage) # Tighter mapping: 'active' is common (12/23 emails get it from LLM), # so it maps to 3 not 4. Only near-close warrants 4+. switch ($Stage) { 'near-close' { return 5 } 'active' { return 3 } 'initial' { return 2 } 'stalled' { return 1 } default { return 2 } } } function Get-RecencyRawScore { param([string]$EmailDate) if (-not $EmailDate) { return 1 } try { $date = [DateTime]::Parse($EmailDate) $ageMonths = [math]::Round(((Get-Date) - $date).TotalDays / 30.44) } catch { return 1 } if ($ageMonths -lt 6) { return 5 } if ($ageMonths -lt 12) { return 4 } if ($ageMonths -lt 24) { return 3 } if ($ageMonths -lt 48) { return 2 } return 1 } function Get-ResearchConfidenceScore { param([object]$Research) if (-not $Research -or -not $Research.research) { return 1 } switch ($Research.research.confidence) { 'High' { return 5 } 'Medium' { return 3 } 'Low' { return 2 } default { return 1 } } } function Get-RecencyPenalty { param( [string]$EmailDate, [object]$Penalties ) if (-not $EmailDate -or -not $Penalties) { return 0 } try { $date = [DateTime]::Parse($EmailDate) $ageMonths = [math]::Round(((Get-Date) - $date).TotalDays / 30.44) } catch { return $Penalties.over_4y } if ($ageMonths -lt 6) { return $Penalties.under_6m } if ($ageMonths -lt 12) { return $Penalties.'6m_to_1y' } if ($ageMonths -lt 24) { return $Penalties.'1y_to_2y' } if ($ageMonths -lt 48) { return $Penalties.'2y_to_4y' } return $Penalties.over_4y } function Get-Band { param( [double]$Score, [object]$Thresholds ) if ($Score -ge $Thresholds.band_1_min) { return 1 } if ($Score -ge $Thresholds.band_2_min) { return 2 } if ($Score -ge $Thresholds.band_3_min) { return 3 } if ($Score -ge $Thresholds.band_4_min) { return 4 } return 5 } #endregion |