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