Public/Invoke-OrgPublishedSeeding.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Invoke-OrgPublishedSeeding {
    <#
    .SYNOPSIS
        Stage 0 of t/1553 — proposes PUBLISHED edges (org → source) via URL/domain match.
    .DESCRIPTION
        Walks the ingested source corpus and, for each source, matches its
        canonical url + resolved_url against the org registry's external_links.
        Two match tiers, most precise first:
 
          1. exact_url — a source's URL or resolved_url is a member of an
                          org's external_links.url set. High precision:
                          this is a curated link, so the org indexes the
                          document as its own.
          2. domain — a source's URL host matches the host of any
                          org.external_links.url. Corporate/lab orgs are
                          high precision here; academic / civil-society
                          orgs with broad hosts (e.g. universities) will
                          need CL review.
 
        Every match becomes a PROPOSED PUBLISHED edge — nothing is
        auto-approved (t/1553 AC #5). Existing edges (any status) for the
        same (org, source) are treated as already-covered and skipped,
        so the cmdlet is idempotent.
 
        Machine-proposes / human-disposes: reviewer sees the match_basis
        in the edge rationale and decides.
 
        Author-affiliation matching is a Stage 0b follow-up — not covered
        here (requires the rolodex mapping).
    .PARAMETER SourcesPath
        Source corpus directory. Defaults to Get-SourcesDir.
    .PARAMETER Org
        Restrict to specific org id(s). Useful for CL first-batch review.
    .PARAMETER MaxProposalsPerOrg
        Safety cap per org for this batch. Default: no cap.
    .EXAMPLE
        Invoke-OrgPublishedSeeding -WhatIf
    .EXAMPLE
        Invoke-OrgPublishedSeeding -Org org-001,org-002 -WhatIf
    .LINK
        Show-AITriadHelp
    .LINK
        Invoke-OrgClaimMatching
    .LINK
        Invoke-OrgDerivedCampScores
    .LINK
        Invoke-OrgStanceExtraction
    .LINK
        Get-Organization
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [string]$SourcesPath,

        [Parameter()]
        [string[]]$Org,

        [Parameter()]
        [ValidateRange(1, 1000)]
        [int]$MaxProposalsPerOrg
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if (-not $SourcesPath) { $SourcesPath = Get-SourcesDir }
    if (-not (Test-Path $SourcesPath)) {
        throw (New-ActionableError `
            -Goal 'Seed PUBLISHED edges' `
            -Problem "Sources directory not found: $SourcesPath" `
            -Location 'Invoke-OrgPublishedSeeding' `
            -NextSteps @('Verify AI_TRIAD_SOURCES_ROOT or .aitriad.json',
                         'Fall back to -SourcesPath explicit override'))
    }

    # ── Build org indexes (exact URL set + domain set) ───────────────────
    $orgStore = Get-OrganizationsStore
    $orgs = if ($orgStore.PSObject.Properties['organizations']) { @($orgStore.organizations) } else { @() }
    if ($Org -and $Org.Count -gt 0) {
        $wanted = @($Org)
        $orgs = @($orgs | Where-Object { $_.id -in $wanted })
    }

    $orgUrlIndex    = @{}   # url → @(org-id, ...)
    $orgDomainIndex = @{}   # host → @(org-id, ...)
    foreach ($o in $orgs) {
        if (-not $o.PSObject.Properties['external_links']) { continue }
        $links = @($o.external_links)
        foreach ($link in $links) {
            if (-not $link.PSObject.Properties['url']) { continue }
            $url = [string]$link.url
            if ([string]::IsNullOrWhiteSpace($url)) { continue }

            $u = ConvertTo-EdgeSeedUrl -Url $url
            if ($u.Url) {
                if (-not $orgUrlIndex.ContainsKey($u.Url))    { $orgUrlIndex[$u.Url]    = [System.Collections.Generic.List[string]]::new() }
                if (-not $orgUrlIndex[$u.Url].Contains($o.id)) { $orgUrlIndex[$u.Url].Add([string]$o.id) }
            }
            if ($u.Host) {
                if (-not $orgDomainIndex.ContainsKey($u.Host))    { $orgDomainIndex[$u.Host]    = [System.Collections.Generic.List[string]]::new() }
                if (-not $orgDomainIndex[$u.Host].Contains($o.id)) { $orgDomainIndex[$u.Host].Add([string]$o.id) }
            }
        }
    }

    # ── Existing edges (any status) so we don't double-propose ───────────
    $existingEdges = @{}    # "<src>::<tgt>::PUBLISHED" → status
    try {
        $edgeStore = Get-OrganizationEdgesStore
        if ($edgeStore -and $edgeStore.PSObject.Properties['edges']) {
            foreach ($e in @($edgeStore.edges)) {
                if (-not $e.PSObject.Properties['type']) { continue }
                if ([string]$e.type -ne 'PUBLISHED') { continue }
                $k = "$([string]$e.source)::$([string]$e.target)::PUBLISHED"
                $existingEdges[$k] = if ($e.PSObject.Properties['status']) { [string]$e.status } else { 'approved' }
            }
        }
    } catch {
        Write-Verbose "No existing edges store readable (fresh install?) — proceeding as if empty"
    }

    # ── Walk sources ─────────────────────────────────────────────────────
    $sourceDirs = @(Get-ChildItem -Path $SourcesPath -Directory -ErrorAction SilentlyContinue |
                    Where-Object { $_.Name -notlike '_*' -and $_.Name -notlike '.*' })

    $proposalsPerOrg = @{}
    $proposed = [System.Collections.Generic.List[PSObject]]::new()
    $skippedExisting = 0
    $noMatch = 0

    foreach ($sd in $sourceDirs) {
        $metaPath = Join-Path $sd.FullName 'metadata.json'
        if (-not (Test-Path $metaPath)) { continue }

        $meta = Get-Content $metaPath -Raw | ConvertFrom-Json
        if (-not $meta.PSObject.Properties['id']) { continue }
        $sourceId = [string]$meta.id

        $matches = New-Object 'System.Collections.Generic.Dictionary[string,string]'  # org-id → match_basis

        foreach ($fld in @('url','resolved_url')) {
            if (-not $meta.PSObject.Properties[$fld]) { continue }
            $raw = [string]$meta.$fld
            if ([string]::IsNullOrWhiteSpace($raw)) { continue }
            $u = ConvertTo-EdgeSeedUrl -Url $raw

            # exact_url wins (records exact_url even if a domain match already noted).
            if ($u.Url -and $orgUrlIndex.ContainsKey($u.Url)) {
                foreach ($oid in $orgUrlIndex[$u.Url]) {
                    $matches[$oid] = 'exact_url'
                }
            }
            if ($u.Host -and $orgDomainIndex.ContainsKey($u.Host)) {
                foreach ($oid in $orgDomainIndex[$u.Host]) {
                    if (-not $matches.ContainsKey($oid)) {
                        $matches[$oid] = 'domain'
                    }
                }
            }
        }

        if ($matches.Count -eq 0) { $noMatch++; continue }

        foreach ($oid in @($matches.Keys)) {
            $basis = $matches[$oid]

            # Idempotence: (org, source, PUBLISHED) exists already → skip.
            $key = "$oid::$sourceId::PUBLISHED"
            if ($existingEdges.ContainsKey($key)) {
                $skippedExisting++
                continue
            }

            if (-not $proposalsPerOrg.ContainsKey($oid)) { $proposalsPerOrg[$oid] = 0 }
            if ($MaxProposalsPerOrg -and $proposalsPerOrg[$oid] -ge $MaxProposalsPerOrg) {
                continue
            }
            $proposalsPerOrg[$oid]++

            $proposed.Add([PSCustomObject]@{
                Source     = $oid
                Target     = $sourceId
                Type       = 'PUBLISHED'
                MatchBasis = $basis
                SourceUrl  = $meta.url
            })
        }
    }

    # ── Emit proposals via Import-OrganizationEdge ───────────────────────
    $written = 0
    $failed  = [System.Collections.Generic.List[string]]::new()
    foreach ($p in $proposed) {
        $rationale = "match_basis=$($p.MatchBasis) (t/1553 Stage 0)"
        $target = "$($p.Source)->$($p.Target) [$($p.MatchBasis)]"
        if ($PSCmdlet.ShouldProcess($target, 'Propose PUBLISHED edge')) {
            try {
                # Import-OrganizationEdge validates -InputObject via PSObject.Properties[key];
                # a hashtable's PSObject reports no keys, so must be a PSCustomObject.
                $null = Import-OrganizationEdge -InputObject ([PSCustomObject]@{
                    source        = $p.Source
                    target        = $p.Target
                    type          = 'PUBLISHED'
                    rationale     = $rationale
                    source_refs   = @($p.Target)
                    status        = 'proposed'
                    discovered_at = (Get-Date).ToString('yyyy-MM-dd')
                }) -Confirm:$false
                $written++
            } catch {
                $failed.Add("$($p.Source)->$($p.Target): $($_.Exception.Message)")
                Write-Warning "Failed to write proposal $($p.Source) -> $($p.Target): $($_.Exception.Message)"
            }
        }
    }

    [PSCustomObject]@{
        Proposed         = $written
        Considered       = $proposed.Count
        SkippedExisting  = $skippedExisting
        SourcesNoMatch   = $noMatch
        SourcesScanned   = $sourceDirs.Count
        Failed           = @($failed)
        PerOrg           = $proposalsPerOrg
    }
}

function ConvertTo-EdgeSeedUrl {
    <#
    .SYNOPSIS
        Canonicalizes a URL for exact/domain matching (t/1553 Stage 0).
    .DESCRIPTION
        Returns {Url, Host} where Url is trimmed and lower-cased, and Host is
        the effective 2LD-plus-TLD (drops leading www.). No protocol
        normalization — https vs http kept as-is so that http-only orgs
        don't spuriously match https sources; the domain fallback covers
        that case.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Url
    )

    Set-StrictMode -Version Latest

    $trimmed = $Url.Trim()
    $lower   = $trimmed.ToLowerInvariant()
    # Strip trailing slash and #fragment for exact-match consistency.
    $stripped = $lower -replace '#.*$', '' -replace '/+$', ''

    $extractedHost = ''
    try {
        $u = [System.Uri]$trimmed
        $extractedHost = $u.Host.ToLowerInvariant()
        if ($extractedHost.StartsWith('www.')) {
            $extractedHost = $extractedHost.Substring(4)
        }
    } catch {
        # Best-effort extraction: split on '/' after protocol.
        $m = [regex]::Match($lower, '^(?:https?:)?//([^/]+)')
        if ($m.Success) {
            $extractedHost = $m.Groups[1].Value
            if ($extractedHost.StartsWith('www.')) {
                $extractedHost = $extractedHost.Substring(4)
            }
        }
    }

    [PSCustomObject]@{
        Url  = $stripped
        Host = $extractedHost
    }
}