|
1 | 1 | // Semantic enrichment — deterministic text analysis for evidence events |
2 | 2 | // No model. No API. Byte-reproducible on every run. |
3 | 3 |
|
4 | | -import type { CertaintyProfile, DirectionSignal, QuantitativeFinding } from "@refract-org/evidence-graph"; |
| 4 | +import { |
| 5 | + type CertaintyProfile, |
| 6 | + type ContentChange, |
| 7 | + type DirectionSignal, |
| 8 | + type EditMagnitude, |
| 9 | + EVENT_SCHEMA_VERSION, |
| 10 | + type EvidenceEvent, |
| 11 | + type FactProvenance, |
| 12 | + type QuantitativeFinding, |
| 13 | +} from "@refract-org/evidence-graph"; |
| 14 | +import { buildMerkleRoot, hashLeaf } from "./merkle-tree.js"; |
5 | 15 |
|
6 | 16 | const CERTAINTY_PATTERNS = { |
7 | 17 | high: [/demonstrat\w*/i, /prove\w*/i, /confirm\w*/i, /establish\w*/i, /significantly/i, /robust/i, /definitive/i], |
@@ -106,3 +116,133 @@ export function extractKeyTerms(text: string): string[] { |
106 | 116 | } |
107 | 117 | return [...terms].slice(0, 15); // Limit to top 15 |
108 | 118 | } |
| 119 | + |
| 120 | +/** |
| 121 | + * Deterministic JSON serialization for hash inputs. |
| 122 | + * Sorts object keys recursively so equivalent values always serialize |
| 123 | + * to the same byte string. |
| 124 | + */ |
| 125 | +function stableStringify(value: unknown): string { |
| 126 | + if (value === undefined) return "undefined"; |
| 127 | + if (value === null) return "null"; |
| 128 | + |
| 129 | + switch (typeof value) { |
| 130 | + case "boolean": |
| 131 | + case "number": |
| 132 | + return String(value); |
| 133 | + case "string": |
| 134 | + return JSON.stringify(value); |
| 135 | + case "object": { |
| 136 | + if (Array.isArray(value)) { |
| 137 | + return `[${value.map(stableStringify).join(",")}]`; |
| 138 | + } |
| 139 | + const record = value as Record<string, unknown>; |
| 140 | + const keys = Object.keys(record).sort(); |
| 141 | + const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`); |
| 142 | + return `{${pairs.join(",")}}`; |
| 143 | + } |
| 144 | + default: |
| 145 | + return "null"; |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +function parameterFootprintToLegacyParameters( |
| 150 | + footprint: Record<string, unknown>, |
| 151 | +): Record<string, string | number | boolean> | undefined { |
| 152 | + const out: Record<string, string | number | boolean> = {}; |
| 153 | + let hasAny = false; |
| 154 | + for (const [key, value] of Object.entries(footprint)) { |
| 155 | + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { |
| 156 | + out[key] = value; |
| 157 | + hasAny = true; |
| 158 | + } |
| 159 | + } |
| 160 | + return hasAny ? out : undefined; |
| 161 | +} |
| 162 | + |
| 163 | +/** The result of applying semantic enrichment to a single evidence event. */ |
| 164 | +export interface SemanticEnrichmentResult { |
| 165 | + editMagnitude: EditMagnitude; |
| 166 | + contentChange: ContentChange; |
| 167 | + keyTerms: string[]; |
| 168 | + certaintyProfile: CertaintyProfile; |
| 169 | + directionSignal: DirectionSignal; |
| 170 | + quantitativeFindings: QuantitativeFinding[]; |
| 171 | + /** Strictly typed provenance block carrying the Merkle root of source + parameters. */ |
| 172 | + provenance: FactProvenance; |
| 173 | +} |
| 174 | + |
| 175 | +/** Build a deterministic source snapshot hash from the raw event state. */ |
| 176 | +export function computeSourceSnapshotHash(event: EvidenceEvent): string { |
| 177 | + const snapshot = `${event.fromRevisionId}|${event.toRevisionId}|${event.section}|${event.before}|${event.after}`; |
| 178 | + return hashLeaf(snapshot); |
| 179 | +} |
| 180 | + |
| 181 | +/** Build a FactProvenance block for semantic enrichment output. */ |
| 182 | +export function buildSemanticEnrichmentProvenance(params: { |
| 183 | + sourceSnapshotHash: string; |
| 184 | + parameterFootprint: Record<string, unknown>; |
| 185 | + analyzer?: string; |
| 186 | + version?: string; |
| 187 | + inputHashes?: string[]; |
| 188 | + effectiveAt?: string; |
| 189 | +}): FactProvenance { |
| 190 | + const parameterHash = hashLeaf(stableStringify(params.parameterFootprint)); |
| 191 | + const merkleRoot = buildMerkleRoot([params.sourceSnapshotHash, parameterHash]); |
| 192 | + return { |
| 193 | + analyzer: params.analyzer ?? "@refract-org/analyzers/semantic-enrichment", |
| 194 | + version: params.version ?? EVENT_SCHEMA_VERSION, |
| 195 | + inputHashes: params.inputHashes ?? [params.sourceSnapshotHash], |
| 196 | + parameters: parameterFootprintToLegacyParameters(params.parameterFootprint), |
| 197 | + parameterFootprint: params.parameterFootprint, |
| 198 | + sourceSnapshotHash: params.sourceSnapshotHash, |
| 199 | + effectiveAt: params.effectiveAt ?? new Date().toISOString(), |
| 200 | + merkleRoot, |
| 201 | + }; |
| 202 | +} |
| 203 | + |
| 204 | +/** |
| 205 | + * Run the full deterministic semantic enrichment pipeline for an evidence event |
| 206 | + * and emit a strictly typed FactProvenance block alongside the enrichment fields. |
| 207 | + */ |
| 208 | +export function enrichEvidenceEvent( |
| 209 | + event: EvidenceEvent, |
| 210 | + options?: { |
| 211 | + analyzer?: string; |
| 212 | + version?: string; |
| 213 | + parameters?: Record<string, unknown>; |
| 214 | + effectiveAt?: string; |
| 215 | + }, |
| 216 | +): SemanticEnrichmentResult { |
| 217 | + const before = event.before || ""; |
| 218 | + const after = event.after || ""; |
| 219 | + const text = after || before; |
| 220 | + |
| 221 | + const editMagnitude = computeEditMagnitude(before.length, after.length); |
| 222 | + const contentChange = computeContentChange(event.eventType, before, after); |
| 223 | + const keyTerms = extractKeyTerms(text); |
| 224 | + const certaintyProfile = computeCertaintyProfile(text); |
| 225 | + const directionSignal = computeDirectionSignal(computeCertaintyProfile(before), computeCertaintyProfile(after)); |
| 226 | + const quantitativeFindings = extractQuantitativeFindings(text); |
| 227 | + |
| 228 | + const sourceSnapshotHash = computeSourceSnapshotHash(event); |
| 229 | + const parameterFootprint = options?.parameters ?? {}; |
| 230 | + |
| 231 | + const provenance = buildSemanticEnrichmentProvenance({ |
| 232 | + sourceSnapshotHash, |
| 233 | + parameterFootprint, |
| 234 | + analyzer: options?.analyzer, |
| 235 | + version: options?.version, |
| 236 | + effectiveAt: options?.effectiveAt, |
| 237 | + }); |
| 238 | + |
| 239 | + return { |
| 240 | + editMagnitude, |
| 241 | + contentChange, |
| 242 | + keyTerms, |
| 243 | + certaintyProfile, |
| 244 | + directionSignal, |
| 245 | + quantitativeFindings, |
| 246 | + provenance, |
| 247 | + }; |
| 248 | +} |
0 commit comments