Public/Import-Entity.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Import-Entity { <# .SYNOPSIS Curation write path for entity records (ent-*) — machine-proposes / human-disposes (t/1804, mirrors Import-Organization). Batch-capped at ~20 proposals (design §4). .DESCRIPTION Upserts a batch of proposed/approved/deprecated entity records into entities.json, minting never-reused ent-NNN ids for new records via New-EntityId. Records are only ever added, updated, tombstoned (`merged_into`), or `deprecated` — NEVER hard-deleted. That is the invariant the monotonic id allocator depends on (design §3, TL t/1804#2 Q3). Person exception (owner decision, design §4/§9.3): a `person` record cannot be `approved` without a human-authored `description` — approving one with an empty description throws an ActionableError (no LLM ever drafts a person description). Approved records get one all-MiniLM-L6-v2 embedding (name + genus-differentia line) written to a SEPARATE entity_embeddings.json via the existing Get-TextEmbedding path (Shared Utility Rule). Only `approved` entities carry vectors (design §3). The Entity shape is the contract in lib/entities/types.ts — not restated here; a parity test guards drift (TL t/1804#2 Q1). .PARAMETER Proposal 1-20 proposal records (hashtable or PSCustomObject). New records require `name`, `entity_type`, `dolce_category`; optional `description`, `aliases`, `source_refs`, `external_refs`, `discovered_by`, `confidence`, `status` (default 'proposed'), `id` (to update an existing record), `merged_into` (to tombstone/merge into a canonical id). .PARAMETER Path Override entities.json path (fixtures/tests). Defaults to Get-EntitiesFilePath. .PARAMETER EmbeddingsPath Override entity_embeddings.json path (fixtures/tests). Defaults to Get-EntityEmbeddingsFilePath. .PARAMETER SkipEmbedding Do not compute/write embeddings for approved records (tests / offline curation). .EXAMPLE Import-Entity -Proposal @(@{ name = 'GPT-4'; entity_type = 'artifact'; dolce_category = 'non-agentive-functional-artifact'; description = 'A model that ...' }) .LINK Get-Entity .LINK Import-Organization #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0)] [ValidateCount(1, 20)] [object[]]$Proposal, [string]$Path, [string]$EmbeddingsPath, [switch]$SkipEmbedding ) Set-StrictMode -Version Latest $entPath = if ($Path) { $Path } else { Get-EntitiesFilePath } $embPath = if ($EmbeddingsPath) { $EmbeddingsPath } else { Get-EntityEmbeddingsFilePath } $store = Get-EntitiesStore -Path $entPath -Force -InitIfMissing $existing = [System.Collections.Generic.List[object]]::new() if ($store.PSObject.Properties['entities']) { foreach ($e in @($store.entities)) { $existing.Add($e) } } # Embeddings store loaded lazily only if an approval needs a vector. $embStore = $null $embDirty = $false $now = (Get-Date).ToString('yyyy-MM-dd') # Small helpers over the working list (match/replace/append; never remove). $findIndex = { param($id) for ($i = 0; $i -lt $existing.Count; $i++) { if ($existing[$i].PSObject.Properties['id'] -and [string]$existing[$i].id -eq $id) { return $i } } return -1 } $prop = { param($obj, $name, $default) if ($obj -is [hashtable]) { if ($obj.ContainsKey($name)) { return $obj[$name] } else { return $default } } if ($obj.PSObject.Properties[$name]) { return $obj.$name } else { return $default } } $results = [System.Collections.Generic.List[object]]::new() foreach ($p in $Proposal) { $propId = [string](& $prop $p 'id' '') $name = [string](& $prop $p 'name' '') $entityType = [string](& $prop $p 'entity_type' '') $dolce = [string](& $prop $p 'dolce_category' '') $description = [string](& $prop $p 'description' '') $status = [string](& $prop $p 'status' 'proposed') $mergedInto = [string](& $prop $p 'merged_into' '') $idx = if ($propId) { & $findIndex $propId } else { -1 } $isUpdate = ($idx -ge 0) # For updates, resolve the effective type against the existing record so the # person gate can't be dodged by omitting entity_type on an approval update. if ($isUpdate -and -not $entityType -and $existing[$idx].PSObject.Properties['entity_type']) { $entityType = [string]$existing[$idx].entity_type } if ($isUpdate -and -not $description -and $existing[$idx].PSObject.Properties['description']) { $description = [string]$existing[$idx].description } # Person-approval gate (design §4/§9.3): no approval without a human description. if ($entityType -eq 'person' -and $status -eq 'approved' -and [string]::IsNullOrWhiteSpace($description)) { throw (New-ActionableError -PassThru ` -Goal 'Approve a person entity' ` -Problem "Person entity '$(if ($propId) { $propId } else { $name })' cannot be approved without a human-authored description" ` -Location 'Import-Entity' ` -NextSteps @( 'Author a genus-differentia description ("A person who ...") for the record', 'The LLM never drafts person descriptions — a human writes every one (design §4)', 'Re-run Import-Entity with the description populated' )) } if ($isUpdate) { $rec = $existing[$idx] $newId = [string]$rec.id } else { if (-not $name -or -not $entityType -or -not $dolce) { throw (New-ActionableError -PassThru ` -Goal 'Create an entity record' ` -Problem 'A new proposal requires name, entity_type, and dolce_category' ` -Location 'Import-Entity' ` -NextSteps @('Add the missing field(s) to the proposal record')) } # Mint against the WORKING list so ids stay monotonic + collision-free within # this batch (each append feeds the next mint's max). $newId = @(New-EntityId -Store ([PSCustomObject]@{ entities = @($existing) }))[0] $rec = [PSCustomObject]@{ id = $newId name = $name aliases = @() entity_type = $entityType dolce_category = $dolce description = $description external_refs = @() source_refs = @() status = $status created_at = $now last_modified = $now } } # Apply mutable fields (shape-matched to the Entity contract; optionals only when present). $rec.name = if ($name) { $name } else { $rec.name } $rec.entity_type = if ($entityType) { $entityType } else { $rec.entity_type } if ($dolce) { $rec.dolce_category = $dolce } $rec.description = $description $rec.status = $status $rec.last_modified = $now foreach ($fld in @( @{ k = 'aliases'; d = @() }, @{ k = 'source_refs'; d = @() }, @{ k = 'external_refs'; d = @() }, @{ k = 'discovered_by'; d = $null }, @{ k = 'confidence'; d = $null } )) { $v = & $prop $p $fld.k '__ABSENT__' if ($v -ne '__ABSENT__') { if ($rec.PSObject.Properties[$fld.k]) { $rec.$($fld.k) = $v } else { Add-Member -InputObject $rec -MemberType NoteProperty -Name $fld.k -Value $v } } } # Merge: set the tombstone pointer, then PATH-COMPRESS (design §7) — rewrite any # X.merged_into = thisId to the canonical target in the same pass so stored chains # never form. The defensive walk in Get-Entity remains as the safety net. if ($mergedInto) { if ($rec.PSObject.Properties['merged_into']) { $rec.merged_into = $mergedInto } else { Add-Member -InputObject $rec -MemberType NoteProperty -Name 'merged_into' -Value $mergedInto } foreach ($other in $existing) { if ($other.PSObject.Properties['merged_into'] -and $null -ne $other.merged_into -and [string]$other.merged_into -eq $newId) { $other.merged_into = $mergedInto } } } if ($isUpdate) { $existing[$idx] = $rec } else { [void]$existing.Add($rec) } # Approved records carry exactly one vector (design §3). Proposed/deprecated do not. $embedded = $false if ($status -eq 'approved' -and -not $SkipEmbedding) { if ($null -eq $embStore) { $embStore = if (Test-Path $embPath) { Get-Content -Raw -Path $embPath -Encoding utf8 | ConvertFrom-Json } else { New-EmptyEntityEmbeddingsStore } } $text = if ($description) { "$name`n$description" } else { $name } $vecMap = Get-TextEmbedding -Texts @($text) -Ids @($newId) if ($vecMap -and $vecMap.ContainsKey($newId)) { if (-not $embStore.PSObject.Properties['vectors']) { Add-Member -InputObject $embStore -MemberType NoteProperty -Name 'vectors' -Value ([PSCustomObject]@{}) } if ($embStore.vectors.PSObject.Properties[$newId]) { $embStore.vectors.$newId = @($vecMap[$newId]) } else { Add-Member -InputObject $embStore.vectors -MemberType NoteProperty -Name $newId -Value (@($vecMap[$newId])) } $embDirty = $true $embedded = $true } } $results.Add([PSCustomObject]@{ Id = $newId Name = $rec.name Status = $status Action = if ($isUpdate) { 'updated' } else { 'created' } Embedded = $embedded }) } # Refresh envelope counters. if ($store.PSObject.Properties['entities']) { $store.entities = @($existing) } else { Add-Member -InputObject $store -MemberType NoteProperty -Name 'entities' -Value (@($existing)) } if ($store.PSObject.Properties['entity_count']) { $store.entity_count = $existing.Count } else { Add-Member -InputObject $store -MemberType NoteProperty -Name 'entity_count' -Value ($existing.Count) } if ($store.PSObject.Properties['last_modified']) { $store.last_modified = $now } else { Add-Member -InputObject $store -MemberType NoteProperty -Name 'last_modified' -Value $now } if (-not $PSCmdlet.ShouldProcess($entPath, "Upsert $($Proposal.Count) entity record(s)")) { return @($results) } Write-EntityStoreAtomic -Store $store -Path $entPath if ($embDirty) { if ($embStore.PSObject.Properties['last_modified']) { $embStore.last_modified = $now } Write-EntityStoreAtomic -Store $embStore -Path $embPath } Clear-EntitiesCache return @($results) } |