sentinelayer-cli 0.46.4 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW05 — Compile source snapshots into the canonical capability graph.
|
|
3
|
+
*
|
|
4
|
+
* Reads a site's snapshots through the EW03 graph store and publishes ONE validated
|
|
5
|
+
* generation atomically. Implements mandatory constraints:
|
|
6
|
+
*
|
|
7
|
+
* • Stable tenant/source IDs; pages link to entities rather than replacing all
|
|
8
|
+
* business entities with URLs.
|
|
9
|
+
* • Resolve identifiers deterministically first; ambiguous entity links remain
|
|
10
|
+
* candidates.
|
|
11
|
+
* • Inferred claims cannot overwrite authoritative facts. Preserve validity time,
|
|
12
|
+
* locale and source precedence.
|
|
13
|
+
* • No action generated from a guessed button coordinate, guessed SKU or arbitrary
|
|
14
|
+
* executable text.
|
|
15
|
+
*
|
|
16
|
+
* Adds what a top professional would add:
|
|
17
|
+
* • Candidate/published generations with contradiction records.
|
|
18
|
+
* • Deterministic rebuild: same snapshots → same graph identities.
|
|
19
|
+
* • Graph cycles/bounds detection.
|
|
20
|
+
*
|
|
21
|
+
* Library only. Node builtins + existing EW02/EW03 contracts. No new dependency.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
import { graphSchema, isMoneyFieldName, isPluralMoneyFieldName, quantitySchema } from './contracts/graph.js';
|
|
27
|
+
import { moneySchema, currencyExponent, KNOWN_CURRENCIES, toMinorUnits } from './contracts/money.js';
|
|
28
|
+
import { GRAPH_SCHEMA_VERSION } from './contracts/versions.js';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Canonical predicate vocabulary (from the EW05 proposed solution)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export const EW05_PREDICATES = Object.freeze({
|
|
35
|
+
CONTAINS: 'contains',
|
|
36
|
+
DESCRIBES: 'describes',
|
|
37
|
+
VARIANT_OF: 'variant_of',
|
|
38
|
+
HAS_MEDIA: 'has_media',
|
|
39
|
+
SUPPORTED_BY: 'supported_by',
|
|
40
|
+
REFERENCES: 'references',
|
|
41
|
+
SAME_AS_CANDIDATE: 'same_as_candidate',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const EW05_PREDICATE_SET = new Set(Object.values(EW05_PREDICATES));
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Entity kinds (deterministic, stable identifiers)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export const ENTITY_KIND = Object.freeze({
|
|
51
|
+
PAGE: 'page',
|
|
52
|
+
PRODUCT: 'product',
|
|
53
|
+
OFFERING: 'offering',
|
|
54
|
+
DOCUMENT: 'document',
|
|
55
|
+
CAPABILITY: 'capability',
|
|
56
|
+
MEDIA: 'media',
|
|
57
|
+
ORGANISATION: 'organisation',
|
|
58
|
+
UNKNOWN: 'unknown',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Generation status
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export const GENERATION_STATUS = Object.freeze({
|
|
66
|
+
CANDIDATE: 'candidate',
|
|
67
|
+
PUBLISHED: 'published',
|
|
68
|
+
SUPERSEDED: 'superseded',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Identity helpers — deterministic, source-normalised
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const ID_SEPARATOR = ':';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic entity id from tenant scope + kind + normalised key.
|
|
79
|
+
*/
|
|
80
|
+
function entityId(tenantScope, kind, key) {
|
|
81
|
+
// Identifier-first: identity is derived from the normalised key first.
|
|
82
|
+
// The kind is included in the final id only for human-readability but
|
|
83
|
+
// the digest is key-only so the same name resolves to the same identity
|
|
84
|
+
// regardless of how different sources classify the @type.
|
|
85
|
+
const normalised = String(key ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
86
|
+
if (!normalised) return null;
|
|
87
|
+
const digest = createHash('sha256').update(`${tenantScope}|${normalised}`, 'utf-8').digest('hex').slice(0, 16);
|
|
88
|
+
return `${tenantScope}${ID_SEPARATOR}${kind}${ID_SEPARATOR}${digest}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Deterministic assertion id scoped to a source snapshot.
|
|
93
|
+
*/
|
|
94
|
+
function assertionId(sourceId, index) {
|
|
95
|
+
return `${sourceId}${ID_SEPARATOR}assertion${ID_SEPARATOR}${index}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Normalisation: canonical URL → page identity
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalise a URL for deterministic identity.
|
|
104
|
+
*/
|
|
105
|
+
function normaliseUrl(raw) {
|
|
106
|
+
if (typeof raw !== 'string' || !raw) return null;
|
|
107
|
+
let u;
|
|
108
|
+
try {
|
|
109
|
+
u = new URL(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
114
|
+
u.hash = '';
|
|
115
|
+
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
|
|
116
|
+
'fbclid', 'gclid', 'msclkid', 'ref', 'source'];
|
|
117
|
+
for (const p of trackingParams) {
|
|
118
|
+
u.searchParams.delete(p);
|
|
119
|
+
}
|
|
120
|
+
u.searchParams.sort();
|
|
121
|
+
u.pathname = u.pathname.replace(/\/+$/, '');
|
|
122
|
+
return u.toString();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deterministic page entity id from a normalised URL.
|
|
127
|
+
*/
|
|
128
|
+
function pageEntityId(tenantScope, url) {
|
|
129
|
+
const normalised = normaliseUrl(url);
|
|
130
|
+
if (!normalised) return null;
|
|
131
|
+
return entityId(tenantScope, ENTITY_KIND.PAGE, normalised);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Fact extraction from snapshots — never guesses, always cites source
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function extractEntities(tenantScope, sourceId, sourceVersion, capturedAt, facts) {
|
|
139
|
+
const unresolvedFacts = [];
|
|
140
|
+
const entities = [];
|
|
141
|
+
if (!facts) return entities;
|
|
142
|
+
|
|
143
|
+
for (const item of facts.jsonLd || []) {
|
|
144
|
+
if (!item || typeof item !== 'object') continue;
|
|
145
|
+
const type = Array.isArray(item['@type'])
|
|
146
|
+
? item['@type'][0]
|
|
147
|
+
: item['@type'];
|
|
148
|
+
if (!type || typeof type !== 'string') continue;
|
|
149
|
+
|
|
150
|
+
let kind = ENTITY_KIND.UNKNOWN;
|
|
151
|
+
const typeLower = type.toLowerCase();
|
|
152
|
+
if (typeLower === 'product') kind = ENTITY_KIND.PRODUCT;
|
|
153
|
+
else if (typeLower === 'offering' || typeLower === 'offer') kind = ENTITY_KIND.OFFERING;
|
|
154
|
+
else if (typeLower === 'organization' || typeLower === 'organisation') kind = ENTITY_KIND.ORGANISATION;
|
|
155
|
+
else if (typeLower === 'mediaobject' || typeLower === 'imageobject' || typeLower === 'videoobject') kind = ENTITY_KIND.MEDIA;
|
|
156
|
+
else if (typeLower === 'webpage' || typeLower === 'website') continue;
|
|
157
|
+
|
|
158
|
+
// Identifier-first resolution order
|
|
159
|
+
let identityKey = null;
|
|
160
|
+
let keyType = 'name';
|
|
161
|
+
if (item['@id']) { identityKey = item['@id']; keyType = '@id'; }
|
|
162
|
+
else if (item.sku) { identityKey = item.sku; keyType = 'sku'; }
|
|
163
|
+
else if (item.gtin) { identityKey = item.gtin; keyType = 'gtin'; }
|
|
164
|
+
else if (item.productID) { identityKey = item.productID; keyType = 'productID'; }
|
|
165
|
+
else if (item.identifier) { identityKey = item.identifier; keyType = 'identifier'; }
|
|
166
|
+
else if (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) { identityKey = String(item.name); keyType = 'name'; }
|
|
167
|
+
|
|
168
|
+
if (!identityKey) continue;
|
|
169
|
+
|
|
170
|
+
// Name-only entities get source-scoped ids
|
|
171
|
+
const idKey = keyType === 'name'
|
|
172
|
+
? `${sourceId}|${identityKey}`
|
|
173
|
+
: identityKey;
|
|
174
|
+
const id = entityId(tenantScope, kind, idKey);
|
|
175
|
+
if (!id) continue;
|
|
176
|
+
|
|
177
|
+
const attributes = {};
|
|
178
|
+
for (const [k, v] of Object.entries(item)) {
|
|
179
|
+
if (k === '@context' || k === '@type') continue;
|
|
180
|
+
if (v !== undefined && v !== null) attributes[k] = v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const normalized = Object.keys(attributes).length > 0
|
|
184
|
+
? normalizeEntityAttributes(attributes)
|
|
185
|
+
: { attrs: undefined, unresolved: [] };
|
|
186
|
+
if (normalized.unresolved.length > 0) {
|
|
187
|
+
for (const uf of normalized.unresolved) {
|
|
188
|
+
uf.entityLabel = (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) ? String(item.name) : undefined;
|
|
189
|
+
uf.entityKind = kind;
|
|
190
|
+
}
|
|
191
|
+
unresolvedFacts.push(...normalized.unresolved);
|
|
192
|
+
}
|
|
193
|
+
entities.push({
|
|
194
|
+
id,
|
|
195
|
+
type: kind,
|
|
196
|
+
label: (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) ? String(item.name) : undefined,
|
|
197
|
+
attributes: normalized.attrs,
|
|
198
|
+
provenance: 'observed',
|
|
199
|
+
tenantScope,
|
|
200
|
+
sourceId,
|
|
201
|
+
sourceVersion,
|
|
202
|
+
capturedAt,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { entities, unresolvedFacts };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Money normalization — schema.org prices → canonical money objects
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Container keys whose children may carry money values.
|
|
215
|
+
* e.g. offers.price, shippingDetails.shippingCost
|
|
216
|
+
*/
|
|
217
|
+
const MONEY_CONTAINER_KEYS = new Set([
|
|
218
|
+
'offers', 'Offers',
|
|
219
|
+
'shippingDetails', 'ShippingDetails',
|
|
220
|
+
'priceSpecification', 'PriceSpecification',
|
|
221
|
+
'aggregateOffer', 'AggregateOffer',
|
|
222
|
+
'hasVariant', 'HasVariant',
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Keys that declare the currency of the amounts in the same object. */
|
|
226
|
+
const CURRENCY_KEYS = ['priceCurrency', 'currency', 'PriceCurrency', 'Currency'];
|
|
227
|
+
|
|
228
|
+
/** The amounts of a schema.org MonetaryAmount. */
|
|
229
|
+
const MONETARY_AMOUNT_KEYS = new Set(['value', 'minValue', 'maxValue']);
|
|
230
|
+
|
|
231
|
+
/** Amount ranges, from the low end to the high end. */
|
|
232
|
+
const AMOUNT_RANGES = [
|
|
233
|
+
['lowPrice', 'highPrice'], ['minPrice', 'maxPrice'],
|
|
234
|
+
['LowPrice', 'HighPrice'], ['MinPrice', 'MaxPrice'],
|
|
235
|
+
['minValue', 'maxValue'],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/** Identifier fields: numeric values here are stringified, not dropped. */
|
|
239
|
+
const IDENTIFIER_KEYS = new Set([
|
|
240
|
+
'sku', 'gtin', 'gtin8', 'gtin12', 'gtin13', 'gtin14',
|
|
241
|
+
'mpn', 'isbn', 'productID', 'productGroupID', 'identifier', 'id',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
/** schema.org counts. A whole, non-negative value is an explicit count quantity. */
|
|
245
|
+
const COUNT_KEYS = new Set([
|
|
246
|
+
'offerCount', 'reviewCount', 'ratingCount',
|
|
247
|
+
'OfferCount', 'ReviewCount', 'RatingCount',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const DECIMAL_AMOUNT = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/;
|
|
251
|
+
|
|
252
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
253
|
+
const isMoney = (value) => moneySchema.safeParse(value).success;
|
|
254
|
+
|
|
255
|
+
/** Canonical money or an explicit quantity: a value already typed for the graph contract. */
|
|
256
|
+
function isTyped(value) {
|
|
257
|
+
return isMoney(value) || quantitySchema.safeParse(value).success;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* True when the graph contract accepts `value` under the money name `key`:
|
|
262
|
+
* canonical money, or a list of it under a plural name such as prices.
|
|
263
|
+
*/
|
|
264
|
+
function isMoneyFieldValue(key, value) {
|
|
265
|
+
return isMoney(value) ||
|
|
266
|
+
(Array.isArray(value) && isPluralMoneyFieldName(key) && value.every(isMoney));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A schema.org MonetaryAmount: typed so, or untyped with a currency and an
|
|
271
|
+
* amount, e.g. shippingRate: { value: "3.49", currency: "USD" }.
|
|
272
|
+
*/
|
|
273
|
+
function isMonetaryAmount(value) {
|
|
274
|
+
if (!isPlainObject(value)) return false;
|
|
275
|
+
const types = [].concat(value['@type'] ?? []);
|
|
276
|
+
if (types.length > 0) return types.includes('MonetaryAmount');
|
|
277
|
+
return Object.hasOwn(value, 'currency') &&
|
|
278
|
+
[...MONETARY_AMOUNT_KEYS].some((key) => Object.hasOwn(value, key));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A MonetaryAmount that is one amount and its currency: canonical money once typed. */
|
|
282
|
+
function isSingleMonetaryAmount(value) {
|
|
283
|
+
return isMonetaryAmount(value) && Object.hasOwn(value, 'value') &&
|
|
284
|
+
(typeof value.value === 'string' || typeof value.value === 'number') &&
|
|
285
|
+
Object.keys(value).every((key) => key === '@type' || key === 'value' || key === 'currency');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* True when `obj[key]` is an amount: a value under a name the graph contract
|
|
290
|
+
* treats as money, a MonetaryAmount { value, currency }, or a string or
|
|
291
|
+
* number amount of a larger MonetaryAmount.
|
|
292
|
+
*/
|
|
293
|
+
function isAmount(obj, key, value) {
|
|
294
|
+
return isMoneyFieldName(key, value) || isSingleMonetaryAmount(value) ||
|
|
295
|
+
(MONETARY_AMOUNT_KEYS.has(key) && isMonetaryAmount(obj) &&
|
|
296
|
+
(typeof value === 'string' || typeof value === 'number'));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* True when a value holds a number outside canonical money or an explicit
|
|
301
|
+
* quantity, i.e. a number the graph contract rejects as untyped.
|
|
302
|
+
*/
|
|
303
|
+
function holdsBareNumber(value) {
|
|
304
|
+
if (typeof value === 'number') return true;
|
|
305
|
+
if (value === null || typeof value !== 'object') return false;
|
|
306
|
+
if (isTyped(value)) return false;
|
|
307
|
+
return Object.values(value).some(holdsBareNumber);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** True when a value holds something under a money name that the graph contract rejects. */
|
|
311
|
+
function holdsUntypedMoney(value) {
|
|
312
|
+
if (value === null || typeof value !== 'object' || isTyped(value)) return false;
|
|
313
|
+
if (Array.isArray(value)) return value.some(holdsUntypedMoney);
|
|
314
|
+
return Object.entries(value).some(([key, item]) =>
|
|
315
|
+
(isMoneyFieldName(key, item) && !isMoneyFieldValue(key, item)) || holdsUntypedMoney(item));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** True when a value holds NaN or an infinite number, which JSON cannot represent. */
|
|
319
|
+
function holdsNonFiniteNumber(value) {
|
|
320
|
+
if (typeof value === 'number') return !Number.isFinite(value);
|
|
321
|
+
return value !== null && typeof value === 'object' && Object.values(value).some(holdsNonFiniteNumber);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The currency of the amounts in `obj`: its own declared ISO 4217 code, else
|
|
326
|
+
* the enclosing object's. A declared code the money registry does not know
|
|
327
|
+
* ("points", "HKD", "eur"), or two codes that disagree, leave the amounts
|
|
328
|
+
* without a currency; an outer currency never takes their place.
|
|
329
|
+
* An exchange rate takes no currency from its specification or from any
|
|
330
|
+
* enclosing object.
|
|
331
|
+
*/
|
|
332
|
+
function currencyInScope(obj, outer) {
|
|
333
|
+
const declared = CURRENCY_KEYS.filter((key) => Object.hasOwn(obj, key) && obj[key] != null)
|
|
334
|
+
.map((key) => obj[key]);
|
|
335
|
+
if (Object.hasOwn(obj, 'currentExchangeRate')) return null;
|
|
336
|
+
if (declared.length === 0) return outer;
|
|
337
|
+
const [code] = declared;
|
|
338
|
+
return declared.every((other) => other === code) && KNOWN_CURRENCIES.has(code) ? code : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Type the amount `raw` under `key` as canonical money in `currency`.
|
|
343
|
+
* Returns { value } or the reason it cannot be typed: amount_invalid when
|
|
344
|
+
* `raw` is not a decimal amount (text, a date, a flag, an object, a
|
|
345
|
+
* non-finite number) or is a negative amount, else currency_missing.
|
|
346
|
+
*/
|
|
347
|
+
function typeAmount(key, raw, currency) {
|
|
348
|
+
const amount = typeof raw === 'number' ? String(raw) : raw;
|
|
349
|
+
if (typeof amount !== 'string' || !DECIMAL_AMOUNT.test(amount)) return { reason: 'amount_invalid' };
|
|
350
|
+
if (amount.startsWith('-')) return { reason: 'amount_invalid' };
|
|
351
|
+
if (currency == null) return { reason: 'currency_missing' };
|
|
352
|
+
const money = moneySchema.safeParse({
|
|
353
|
+
amount, amountUnit: 'major_decimal', currency, exponent: currencyExponent(currency),
|
|
354
|
+
});
|
|
355
|
+
return money.success ? { value: money.data } : { reason: 'amount_invalid' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The value the graph contract accepts under the amount `key`, as { value },
|
|
360
|
+
* or the reason it cannot be typed. Canonical money passes through; a
|
|
361
|
+
* MonetaryAmount { value, currency } and a decimal string or number become
|
|
362
|
+
* canonical money.
|
|
363
|
+
*/
|
|
364
|
+
function typeMoney(key, raw, currency) {
|
|
365
|
+
if (isMoneyFieldValue(key, raw)) {
|
|
366
|
+
if ([].concat(raw).some((money) => String(money.amount).startsWith('-'))) {
|
|
367
|
+
return { reason: 'amount_invalid' };
|
|
368
|
+
}
|
|
369
|
+
return { value: raw };
|
|
370
|
+
}
|
|
371
|
+
if (isSingleMonetaryAmount(raw)) {
|
|
372
|
+
return typeAmount(key, raw.value, currencyInScope(raw, currency));
|
|
373
|
+
}
|
|
374
|
+
return typeAmount(key, raw, currency);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Walk entity attributes, typing amounts as canonical money and counts as
|
|
379
|
+
* explicit quantities. Returns { attrs, unresolved } and does not mutate the
|
|
380
|
+
* input. Field names decide money exactly as the graph contract does
|
|
381
|
+
* (isMoneyFieldName). A value that cannot be typed honestly is left out of
|
|
382
|
+
* the attributes and recorded as an unresolved fact with its attribute path,
|
|
383
|
+
* e.g. offers[0].priceSpecification[1].price, so the rest of the graph still
|
|
384
|
+
* publishes.
|
|
385
|
+
*
|
|
386
|
+
* - Amounts (price, lowPrice, minPrice, salePrice, returnShippingFeesAmount,
|
|
387
|
+
* ...; a MonetaryAmount { value, currency }; the amounts of a larger
|
|
388
|
+
* MonetaryAmount) become canonical money in the currency in scope: the
|
|
389
|
+
* object's own priceCurrency or currency, else the enclosing object's.
|
|
390
|
+
* Without a known one: currency_missing. Anything that is not a decimal
|
|
391
|
+
* amount, such as a date (priceValidUntil), a flag (valueAddedTaxIncluded)
|
|
392
|
+
* or a negative amount: amount_invalid.
|
|
393
|
+
* - offers, priceSpecification, shippingDetails, aggregateOffer and hasVariant
|
|
394
|
+
* hold objects or lists of them. A bare value there, or in such a list
|
|
395
|
+
* (where it is nulled so later items keep their paths): not_an_object.
|
|
396
|
+
* - offerCount, reviewCount or ratingCount holding a whole, non-negative
|
|
397
|
+
* safe integer becomes an explicit count quantity.
|
|
398
|
+
* - Numeric identifiers (sku, gtin, gtin8, gtin12, gtin13, gtin14, mpn,
|
|
399
|
+
* isbn, productID, productGroupID, identifier, id) that are safe,
|
|
400
|
+
* non-negative integers are stringified in place; any other bare number
|
|
401
|
+
* (ratingValue: 4.6): untyped_number.
|
|
402
|
+
* - A list outside the money containers that holds a bare number or untyped
|
|
403
|
+
* money is recorded whole, never spliced:
|
|
404
|
+
* untyped_money when it holds untyped money, else untyped_number.
|
|
405
|
+
* - A range whose low end exceeds its high end publishes neither end:
|
|
406
|
+
* range_inverted.
|
|
407
|
+
* - Canonical money and explicit quantities pass through unchanged,
|
|
408
|
+
* except negative canonical money (amount_invalid).
|
|
409
|
+
*/
|
|
410
|
+
function normalizeEntityAttributes(attrs) {
|
|
411
|
+
const MAX_DEPTH = 10;
|
|
412
|
+
const unresolved = [];
|
|
413
|
+
const record = (field, path, rawValue, reason) => unresolved.push({ field, path, rawValue, reason });
|
|
414
|
+
const pathOf = (parent, key) => (parent ? `${parent}.${key}` : key);
|
|
415
|
+
|
|
416
|
+
// A list under a money container holds objects. Any other item is recorded
|
|
417
|
+
// and nulled, so later items keep their index and path.
|
|
418
|
+
function walkContainerList(list, key, currency, path, depth) {
|
|
419
|
+
return list.map((item, i) => {
|
|
420
|
+
if (item === null) return item;
|
|
421
|
+
if (isPlainObject(item)) return walk(item, currency, `${path}[${i}]`, depth + 1);
|
|
422
|
+
record(key, `${path}[${i}]`, item, 'not_an_object');
|
|
423
|
+
return null;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function walk(obj, outerCurrency, attrPath, depth) {
|
|
428
|
+
if (depth > MAX_DEPTH || isTyped(obj)) return obj;
|
|
429
|
+
const currency = currencyInScope(obj, outerCurrency);
|
|
430
|
+
const result = {};
|
|
431
|
+
|
|
432
|
+
for (const key of Object.keys(obj)) {
|
|
433
|
+
const val = obj[key];
|
|
434
|
+
const path = pathOf(attrPath, key);
|
|
435
|
+
if (val === undefined) {
|
|
436
|
+
result[key] = val;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (MONEY_CONTAINER_KEYS.has(key)) {
|
|
441
|
+
if (val === null) result[key] = val;
|
|
442
|
+
else if (isPlainObject(val)) result[key] = walk(val, currency, path, depth + 1);
|
|
443
|
+
else if (Array.isArray(val)) result[key] = walkContainerList(val, key, currency, path, depth);
|
|
444
|
+
else record(key, path, val, 'not_an_object');
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (isAmount(obj, key, val)) {
|
|
449
|
+
const typed = typeMoney(key, val, currency);
|
|
450
|
+
if (typed.reason) record(key, path, val, typed.reason);
|
|
451
|
+
else result[key] = typed.value;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (COUNT_KEYS.has(key) && Number.isSafeInteger(val) && val >= 0) {
|
|
456
|
+
result[key] = { kind: 'quantity', value: val, unit: 'count' };
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Any other bare number (or an untrusted numeric identifier) is
|
|
461
|
+
// ambiguous to the graph contract: leave it out of the attributes
|
|
462
|
+
// so the rest of the graph still publishes.
|
|
463
|
+
if (typeof val === 'number') {
|
|
464
|
+
if (IDENTIFIER_KEYS.has(key)) {
|
|
465
|
+
// Only safe, non-negative integers are trustworthy identifiers.
|
|
466
|
+
if (Number.isSafeInteger(val) && val >= 0) {
|
|
467
|
+
result[key] = String(val);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
record(key, path, val, 'untyped_number');
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
record(key, path, val, 'untyped_number');
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Arrays outside the money containers are not walked: one holding a
|
|
478
|
+
// bare number or untyped money is recorded whole rather than spliced.
|
|
479
|
+
if (Array.isArray(val) && (holdsBareNumber(val) || holdsUntypedMoney(val))) {
|
|
480
|
+
record(key, path, val, holdsUntypedMoney(val) ? 'untyped_money' : 'untyped_number');
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
result[key] = isPlainObject(val) ? walk(val, currency, path, depth + 1) : val;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// A range whose low end exceeds its high end publishes neither end.
|
|
488
|
+
for (const [low, high] of AMOUNT_RANGES) {
|
|
489
|
+
const lowMinor = toMinorUnits(result[low]);
|
|
490
|
+
const highMinor = toMinorUnits(result[high]);
|
|
491
|
+
if (lowMinor == null || highMinor == null || lowMinor <= highMinor ||
|
|
492
|
+
result[low].currency !== result[high].currency) continue;
|
|
493
|
+
for (const key of [low, high]) {
|
|
494
|
+
delete result[key];
|
|
495
|
+
record(key, pathOf(attrPath, key), obj[key], 'range_inverted');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const normalized = walk(attrs, null, '', 0);
|
|
503
|
+
// Unresolved facts are published in the JSON graph, which cannot carry NaN
|
|
504
|
+
// or Infinity (JSON.parse reads 1e999 as Infinity). Such a fact keeps its
|
|
505
|
+
// field, path and reason; only the unrepresentable raw value is left out.
|
|
506
|
+
for (const fact of unresolved) {
|
|
507
|
+
if (holdsNonFiniteNumber(fact.rawValue)) delete fact.rawValue;
|
|
508
|
+
}
|
|
509
|
+
return { attrs: normalized, unresolved };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function extractAssertions(tenantScope, sourceId, sourceVersion, capturedAt, facts, entities) {
|
|
513
|
+
const assertions = [];
|
|
514
|
+
if (!facts) return assertions;
|
|
515
|
+
|
|
516
|
+
const add = (subject, predicate, object, opts = {}) => {
|
|
517
|
+
const id = assertionId(sourceId, assertions.length);
|
|
518
|
+
assertions.push({
|
|
519
|
+
id,
|
|
520
|
+
subject,
|
|
521
|
+
predicate,
|
|
522
|
+
object,
|
|
523
|
+
provenance: opts.provenance || 'observed',
|
|
524
|
+
schemaValidity: 'unvalidated',
|
|
525
|
+
modelConfidence: 'unavailable',
|
|
526
|
+
freshness: { asOf: capturedAt },
|
|
527
|
+
source: { sourceId, version: sourceVersion, capturedAt, tenantScope },
|
|
528
|
+
...(opts.evidenceRef ? { evidenceRef: opts.evidenceRef } : {}),
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const canonicalStr = typeof facts.canonical === 'string' ? facts.canonical : null;
|
|
533
|
+
const pageId = canonicalStr
|
|
534
|
+
? pageEntityId(tenantScope, canonicalStr)
|
|
535
|
+
: pageEntityId(tenantScope, sourceId);
|
|
536
|
+
|
|
537
|
+
if (pageId) {
|
|
538
|
+
const pageExists = entities.some((e) => e.id === pageId);
|
|
539
|
+
if (!pageExists) {
|
|
540
|
+
entities.push({
|
|
541
|
+
id: pageId,
|
|
542
|
+
type: ENTITY_KIND.PAGE,
|
|
543
|
+
label: facts.title || undefined,
|
|
544
|
+
provenance: 'observed',
|
|
545
|
+
tenantScope,
|
|
546
|
+
sourceId,
|
|
547
|
+
sourceVersion,
|
|
548
|
+
capturedAt,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (pageId) {
|
|
554
|
+
for (const entity of entities) {
|
|
555
|
+
if (entity.type === ENTITY_KIND.PAGE) continue;
|
|
556
|
+
add(pageId, EW05_PREDICATES.DESCRIBES, { entityId: entity.id }, { provenance: 'derived' });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Entity attribute assertions: create assertions for each entity attribute
|
|
561
|
+
// so that conflicting attribute values from different sources are detectable.
|
|
562
|
+
for (const entity of entities) {
|
|
563
|
+
if (!entity.id || !entity.attributes) continue;
|
|
564
|
+
for (const [attrKey, attrVal] of Object.entries(entity.attributes)) {
|
|
565
|
+
if (attrVal === undefined || attrVal === null) continue;
|
|
566
|
+
const attrPredicate = `${EW05_PREDICATES.DESCRIBES}_${attrKey}`;
|
|
567
|
+
// Canonical money objects pass through as-is; other values are
|
|
568
|
+
// stringified, unless the graph contract would read the string as an
|
|
569
|
+
// untyped price: a price specification or a list of money stays JSON.
|
|
570
|
+
const text = String(attrVal).slice(0, 1000);
|
|
571
|
+
const assertionObject = isMoney(attrVal) ||
|
|
572
|
+
(typeof attrVal === 'object' && isMoneyFieldName(attrPredicate, text))
|
|
573
|
+
? attrVal
|
|
574
|
+
: text;
|
|
575
|
+
add(entity.id, attrPredicate, assertionObject, { provenance: 'observed' });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const heading of facts.headings || []) {
|
|
580
|
+
if (pageId && heading && typeof heading === 'object' && heading !== null && typeof heading.text === 'string' && heading.text) {
|
|
581
|
+
// Heading level encoded in text to avoid numeric type issues
|
|
582
|
+
const level = Number(heading.level);
|
|
583
|
+
if (!Number.isSafeInteger(level) || level < 1 || level > 6) continue;
|
|
584
|
+
const headingLevel = `h${level}`;
|
|
585
|
+
add(pageId, EW05_PREDICATES.CONTAINS, {
|
|
586
|
+
kind: headingLevel,
|
|
587
|
+
text: heading.text,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
for (const link of facts.links || []) {
|
|
593
|
+
if (!link.href) continue;
|
|
594
|
+
const targetUrl = normaliseUrl(link.href);
|
|
595
|
+
if (!targetUrl) continue;
|
|
596
|
+
const targetPageId = pageEntityId(tenantScope, targetUrl);
|
|
597
|
+
if (targetPageId && pageId && targetPageId !== pageId) {
|
|
598
|
+
add(pageId, EW05_PREDICATES.REFERENCES, {
|
|
599
|
+
entityId: targetPageId,
|
|
600
|
+
url: targetUrl,
|
|
601
|
+
text: link.text || undefined,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return assertions;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function mergeEntities(entityLists) {
|
|
610
|
+
const map = new Map();
|
|
611
|
+
for (const list of entityLists) {
|
|
612
|
+
for (const entity of list) {
|
|
613
|
+
const existing = map.get(entity.id);
|
|
614
|
+
if (!existing) {
|
|
615
|
+
map.set(entity.id, { ...entity });
|
|
616
|
+
} else {
|
|
617
|
+
mergeTwoEntities(existing, entity);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Identifier-first: entities with the same @id-based key are already
|
|
623
|
+
// merged by deterministic id in the first pass. No second-pass digest
|
|
624
|
+
// merge is needed — name-only matches remain separate (see §EW05 (3)).
|
|
625
|
+
const idRemap = new Map();
|
|
626
|
+
return { entities: [...map.values()], idRemap };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function mergeTwoEntities(existing, entity) {
|
|
630
|
+
const existingRank = provenanceRank(existing.provenance);
|
|
631
|
+
const newRank = provenanceRank(entity.provenance);
|
|
632
|
+
|
|
633
|
+
// Merge label: keep higher-provenance label, fall back to existing
|
|
634
|
+
if (!existing.label && entity.label) {
|
|
635
|
+
existing.label = entity.label;
|
|
636
|
+
} else if (newRank >= existingRank && entity.label) {
|
|
637
|
+
existing.label = entity.label;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// If new has higher provenance, update scalar fields (type, provenance).
|
|
641
|
+
// Never downgrade a known type to UNKNOWN.
|
|
642
|
+
if (newRank >= existingRank) {
|
|
643
|
+
if (entity.type !== ENTITY_KIND.UNKNOWN || existing.type === ENTITY_KIND.UNKNOWN) {
|
|
644
|
+
existing.type = entity.type;
|
|
645
|
+
}
|
|
646
|
+
existing.provenance = entity.provenance;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Per-attribute merge: merge attributes objects by key
|
|
650
|
+
if (entity.attributes && Object.keys(entity.attributes).length > 0) {
|
|
651
|
+
if (!existing.attributes) existing.attributes = {};
|
|
652
|
+
for (const [attrKey, attrVal] of Object.entries(entity.attributes)) {
|
|
653
|
+
if (attrVal === undefined || attrVal === null) continue;
|
|
654
|
+
if (!(attrKey in existing.attributes)) {
|
|
655
|
+
// New attribute: adopt it
|
|
656
|
+
existing.attributes[attrKey] = attrVal;
|
|
657
|
+
} else if (existing.attributes[attrKey] !== attrVal) {
|
|
658
|
+
// Conflicting value: retain both as an array of source-attributed values
|
|
659
|
+
const prev = existing.attributes[attrKey];
|
|
660
|
+
if (Array.isArray(prev)) {
|
|
661
|
+
if (!prev.some((v) => typeof v === 'object' && v.value === attrVal)) {
|
|
662
|
+
prev.push({ value: attrVal, provenance: entity.provenance, sourceId: entity.sourceId });
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
existing.attributes[attrKey] = [
|
|
666
|
+
{ value: prev, provenance: existing.provenance, sourceId: existing.sourceId },
|
|
667
|
+
{ value: attrVal, provenance: entity.provenance, sourceId: entity.sourceId },
|
|
668
|
+
];
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function provenanceRank(p) {
|
|
676
|
+
if (p === 'authoritative') return 3;
|
|
677
|
+
if (p === 'derived') return 2;
|
|
678
|
+
if (p === 'observed') return 1;
|
|
679
|
+
return 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
// Contradiction detection
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
function detectContradictions(assertions) {
|
|
687
|
+
// Only single-valued attribute assertions can produce contradictions.
|
|
688
|
+
// Multi-valued predicates (CONTAINS, REFERENCES, DESCRIBES) are expected
|
|
689
|
+
// to have multiple values and must not generate false contradictions.
|
|
690
|
+
function isSingleValued(predicate) {
|
|
691
|
+
// Attribute assertions: describes_name, describes_sku, etc.
|
|
692
|
+
return predicate.startsWith(EW05_PREDICATES.DESCRIBES + '_');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const byKey = new Map();
|
|
696
|
+
for (const a of assertions) {
|
|
697
|
+
if (!isSingleValued(a.predicate)) continue;
|
|
698
|
+
const key = `${a.subject}|${a.predicate}`;
|
|
699
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
700
|
+
byKey.get(key).push(a);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const contradictions = [];
|
|
704
|
+
for (const [, group] of byKey) {
|
|
705
|
+
if (group.length < 2) continue;
|
|
706
|
+
const objects = group.map((a) => JSON.stringify(a.object));
|
|
707
|
+
const unique = new Set(objects);
|
|
708
|
+
if (unique.size > 1) {
|
|
709
|
+
contradictions.push({
|
|
710
|
+
key: `${group[0].subject}|${group[0].predicate}`,
|
|
711
|
+
assertionIds: group.map((a) => a.id),
|
|
712
|
+
sourceIds: [...new Set(group.map((a) => a.source.sourceId))],
|
|
713
|
+
objects: group.map((a) => a.object),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return contradictions;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Cycle detection in link graph
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
function detectLinkCycles(links) {
|
|
726
|
+
const adjacency = new Map();
|
|
727
|
+
for (const link of links) {
|
|
728
|
+
if (!adjacency.has(link.source)) adjacency.set(link.source, []);
|
|
729
|
+
adjacency.get(link.source).push(link.target);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const GRAY = 1;
|
|
733
|
+
const BLACK = 2;
|
|
734
|
+
const color = new Map();
|
|
735
|
+
const cycles = [];
|
|
736
|
+
|
|
737
|
+
function dfs(node, path) {
|
|
738
|
+
color.set(node, GRAY);
|
|
739
|
+
const neighbors = adjacency.get(node) || [];
|
|
740
|
+
for (const next of neighbors) {
|
|
741
|
+
if (color.get(next) === GRAY) {
|
|
742
|
+
const cycleStart = path.indexOf(next);
|
|
743
|
+
if (cycleStart !== -1) {
|
|
744
|
+
cycles.push(path.slice(cycleStart).concat(next));
|
|
745
|
+
}
|
|
746
|
+
} else if (color.get(next) !== BLACK) {
|
|
747
|
+
dfs(next, path.concat(next));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
color.set(node, BLACK);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
for (const node of adjacency.keys()) {
|
|
754
|
+
if (!color.has(node)) {
|
|
755
|
+
dfs(node, [node]);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return cycles;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// Link dedup key (internal, not stored on link object)
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
function linkDedupKey(source, target, predicate) {
|
|
767
|
+
return `${source}|${target}|${predicate}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Compilation — main entry point
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Extract all data from snapshots into entities, assertions, and links.
|
|
776
|
+
*/
|
|
777
|
+
async function extractFromSnapshots(snapshots, factsProvider) {
|
|
778
|
+
const allEntities = [];
|
|
779
|
+
const allAssertions = [];
|
|
780
|
+
const allLinks = [];
|
|
781
|
+
const allUnresolvedFacts = [];
|
|
782
|
+
|
|
783
|
+
for (const snap of snapshots) {
|
|
784
|
+
let facts;
|
|
785
|
+
if (typeof factsProvider === 'function') {
|
|
786
|
+
try {
|
|
787
|
+
facts = await factsProvider(snap);
|
|
788
|
+
} catch {
|
|
789
|
+
facts = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const { entities, unresolvedFacts: snapUnresolved } = extractEntities(
|
|
794
|
+
snap.tenantScope, snap.sourceId, snap.version, snap.capturedAt, facts,
|
|
795
|
+
);
|
|
796
|
+
if (snapUnresolved && snapUnresolved.length > 0) {
|
|
797
|
+
for (const uf of snapUnresolved) {
|
|
798
|
+
uf.sourceId = snap.sourceId;
|
|
799
|
+
uf.tenantScope = snap.tenantScope;
|
|
800
|
+
}
|
|
801
|
+
allUnresolvedFacts.push(...snapUnresolved);
|
|
802
|
+
}
|
|
803
|
+
const assertions = extractAssertions(
|
|
804
|
+
snap.tenantScope, snap.sourceId, snap.version, snap.capturedAt, facts, entities,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const canonicalStr2 = facts && typeof facts.canonical === 'string' ? facts.canonical : null;
|
|
808
|
+
const pageId = canonicalStr2
|
|
809
|
+
? pageEntityId(snap.tenantScope, canonicalStr2)
|
|
810
|
+
: pageEntityId(snap.tenantScope, snap.sourceId);
|
|
811
|
+
|
|
812
|
+
// DESCRIBES links: page → entity
|
|
813
|
+
if (pageId) {
|
|
814
|
+
for (const entity of entities) {
|
|
815
|
+
if (entity.type === ENTITY_KIND.PAGE) continue;
|
|
816
|
+
allLinks.push({
|
|
817
|
+
source: pageId,
|
|
818
|
+
target: entity.id,
|
|
819
|
+
predicate: EW05_PREDICATES.DESCRIBES,
|
|
820
|
+
provenance: 'derived',
|
|
821
|
+
confidence: 'medium',
|
|
822
|
+
tenantScope: snap.tenantScope,
|
|
823
|
+
sourceId: snap.sourceId,
|
|
824
|
+
sourceVersion: snap.version,
|
|
825
|
+
capturedAt: snap.capturedAt,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// REFERENCES links: page → other page
|
|
831
|
+
if (pageId) {
|
|
832
|
+
for (const link of facts?.links || []) {
|
|
833
|
+
if (!link.href) continue;
|
|
834
|
+
const targetUrl = normaliseUrl(link.href);
|
|
835
|
+
if (!targetUrl) continue;
|
|
836
|
+
const targetPageId = pageEntityId(snap.tenantScope, targetUrl);
|
|
837
|
+
if (targetPageId && targetPageId !== pageId) {
|
|
838
|
+
// Ensure the target page entity exists (as a placeholder if not yet seen).
|
|
839
|
+
// Placeholders are marked unresolved and must not carry anchor text as label.
|
|
840
|
+
if (!entities.some((e) => e.id === targetPageId)) {
|
|
841
|
+
entities.push({
|
|
842
|
+
id: targetPageId,
|
|
843
|
+
type: ENTITY_KIND.PAGE,
|
|
844
|
+
provenance: 'unresolved',
|
|
845
|
+
tenantScope: snap.tenantScope,
|
|
846
|
+
sourceId: snap.sourceId,
|
|
847
|
+
sourceVersion: snap.version,
|
|
848
|
+
capturedAt: snap.capturedAt,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
allLinks.push({
|
|
852
|
+
source: pageId,
|
|
853
|
+
target: targetPageId,
|
|
854
|
+
predicate: EW05_PREDICATES.REFERENCES,
|
|
855
|
+
provenance: 'observed',
|
|
856
|
+
confidence: 'medium',
|
|
857
|
+
tenantScope: snap.tenantScope,
|
|
858
|
+
sourceId: snap.sourceId,
|
|
859
|
+
sourceVersion: snap.version,
|
|
860
|
+
capturedAt: snap.capturedAt,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
allEntities.push(entities);
|
|
867
|
+
allAssertions.push(assertions);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { allEntities, allAssertions, allLinks, allUnresolvedFacts };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Build the final graph from extracted data.
|
|
875
|
+
*/
|
|
876
|
+
function buildGraph(allEntities, allAssertions, allLinks, allUnresolvedFacts) {
|
|
877
|
+
const { entities: mergedEntities, idRemap } = mergeEntities(allEntities);
|
|
878
|
+
const mergedAssertions = allAssertions.flat();
|
|
879
|
+
|
|
880
|
+
// Deduplicate links and remap targets after identifier-first merge
|
|
881
|
+
const linkMap = new Map();
|
|
882
|
+
for (const link of allLinks) {
|
|
883
|
+
// Remap link source/target after identifier-first entity merge
|
|
884
|
+
const remappedLink = { ...link };
|
|
885
|
+
if (idRemap.has(link.source)) remappedLink.source = idRemap.get(link.source);
|
|
886
|
+
if (idRemap.has(link.target)) remappedLink.target = idRemap.get(link.target);
|
|
887
|
+
const key = linkDedupKey(remappedLink.source, remappedLink.target, remappedLink.predicate);
|
|
888
|
+
if (!linkMap.has(key)) {
|
|
889
|
+
linkMap.set(key, remappedLink);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const mergedLinks = [...linkMap.values()];
|
|
893
|
+
|
|
894
|
+
// same_as_candidate links: entities with matching normalized names
|
|
895
|
+
// but different identity keys (name-only matches across sources)
|
|
896
|
+
const byNormalizedName = new Map();
|
|
897
|
+
for (const entity of mergedEntities) {
|
|
898
|
+
if (entity.type === ENTITY_KIND.PAGE) continue;
|
|
899
|
+
const normalized = String(entity.label ?? '').toLowerCase().trim();
|
|
900
|
+
if (!normalized) continue;
|
|
901
|
+
if (!byNormalizedName.has(normalized)) {
|
|
902
|
+
byNormalizedName.set(normalized, []);
|
|
903
|
+
}
|
|
904
|
+
byNormalizedName.get(normalized).push(entity);
|
|
905
|
+
}
|
|
906
|
+
for (const [, group] of byNormalizedName) {
|
|
907
|
+
if (group.length < 2) continue;
|
|
908
|
+
// Create same_as_candidate links between all pairs
|
|
909
|
+
for (let i = 0; i < group.length; i++) {
|
|
910
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
911
|
+
const key = linkDedupKey(group[i].id, group[j].id, EW05_PREDICATES.SAME_AS_CANDIDATE);
|
|
912
|
+
if (!linkMap.has(key)) {
|
|
913
|
+
linkMap.set(key, {
|
|
914
|
+
source: group[i].id,
|
|
915
|
+
target: group[j].id,
|
|
916
|
+
predicate: EW05_PREDICATES.SAME_AS_CANDIDATE,
|
|
917
|
+
provenance: 'derived',
|
|
918
|
+
confidence: 'low',
|
|
919
|
+
tenantScope: group[i].tenantScope || group[j].tenantScope,
|
|
920
|
+
sourceId: group[i].sourceId,
|
|
921
|
+
sourceVersion: group[i].sourceVersion,
|
|
922
|
+
capturedAt: group[i].capturedAt,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Rebuild mergedLinks after adding same_as_candidate links
|
|
930
|
+
const finalLinks = [...linkMap.values()];
|
|
931
|
+
|
|
932
|
+
const contradictions = detectContradictions(mergedAssertions);
|
|
933
|
+
const cycles = detectLinkCycles(finalLinks);
|
|
934
|
+
|
|
935
|
+
// Deterministic: use latest snapshot timestamp, not new Date()
|
|
936
|
+
let generatedAt = new Date(0).toISOString();
|
|
937
|
+
for (const a of mergedAssertions) {
|
|
938
|
+
if (a.source?.capturedAt && a.source.capturedAt > generatedAt) {
|
|
939
|
+
generatedAt = a.source.capturedAt;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (generatedAt === new Date(0).toISOString()) {
|
|
943
|
+
// Fallback for assertions without timestamps
|
|
944
|
+
for (const e of mergedEntities) {
|
|
945
|
+
if (e.capturedAt && e.capturedAt > generatedAt) generatedAt = e.capturedAt;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
const graph = {
|
|
949
|
+
version: GRAPH_SCHEMA_VERSION,
|
|
950
|
+
entities: mergedEntities.length > 0 ? mergedEntities : undefined,
|
|
951
|
+
links: finalLinks.length > 0 ? finalLinks : undefined,
|
|
952
|
+
assertions: mergedAssertions,
|
|
953
|
+
unresolvedFacts: allUnresolvedFacts && allUnresolvedFacts.length > 0 ? allUnresolvedFacts : undefined,
|
|
954
|
+
coverage: {
|
|
955
|
+
totalAssertions: mergedAssertions.length,
|
|
956
|
+
byProvenance: countBy(mergedAssertions, 'provenance'),
|
|
957
|
+
bySchemaValidity: countBy(mergedAssertions, 'schemaValidity'),
|
|
958
|
+
},
|
|
959
|
+
generatedAt,
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
return { graph, contradictions, cycles, mergedEntities, mergedAssertions, finalLinks, allUnresolvedFacts };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function _compile({
|
|
966
|
+
graphStore,
|
|
967
|
+
caller,
|
|
968
|
+
namespace,
|
|
969
|
+
factsProvider,
|
|
970
|
+
publish = false,
|
|
971
|
+
} = {}) {
|
|
972
|
+
if (!graphStore || typeof graphStore.listSourceSnapshots !== 'function') {
|
|
973
|
+
throw new CompileGraphError('graphStore with listSourceSnapshots is required');
|
|
974
|
+
}
|
|
975
|
+
if (!caller || typeof caller !== 'object') {
|
|
976
|
+
throw new CompileGraphError('caller identity is required');
|
|
977
|
+
}
|
|
978
|
+
if (!namespace || typeof namespace.raw !== 'string') {
|
|
979
|
+
throw new CompileGraphError('namespace is required');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const snapshots = await graphStore.listSourceSnapshots(caller, namespace);
|
|
983
|
+
if (!Array.isArray(snapshots) || snapshots.length === 0) {
|
|
984
|
+
throw new CompileGraphError('no source snapshots to compile');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const { allEntities, allAssertions, allLinks, allUnresolvedFacts } = await extractFromSnapshots(snapshots, factsProvider);
|
|
988
|
+
const { graph, contradictions, cycles, mergedEntities, mergedAssertions, finalLinks } =
|
|
989
|
+
buildGraph(allEntities, allAssertions, allLinks, allUnresolvedFacts);
|
|
990
|
+
|
|
991
|
+
const schemaResult = graphSchema.safeParse(graph);
|
|
992
|
+
if (!schemaResult.success && publish) {
|
|
993
|
+
const messages = schemaResult.error.issues.map((i) => i.path.join('.') + ': ' + i.message);
|
|
994
|
+
throw new CompileGraphError('Compiled graph invalid: ' + messages.join('; '));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (publish) {
|
|
998
|
+
const published = await graphStore.publishGraph(caller, namespace, schemaResult.data);
|
|
999
|
+
return {
|
|
1000
|
+
graph: published.graph,
|
|
1001
|
+
version: published.version,
|
|
1002
|
+
publishedAt: published.publishedAt,
|
|
1003
|
+
contentHash: published.contentHash,
|
|
1004
|
+
entities: mergedEntities,
|
|
1005
|
+
links: finalLinks,
|
|
1006
|
+
assertions: mergedAssertions,
|
|
1007
|
+
contradictions,
|
|
1008
|
+
cycles,
|
|
1009
|
+
unresolvedFacts: allUnresolvedFacts,
|
|
1010
|
+
entityCount: mergedEntities.length,
|
|
1011
|
+
assertionCount: mergedAssertions.length,
|
|
1012
|
+
linkCount: finalLinks.length,
|
|
1013
|
+
sourceCount: snapshots.length,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return {
|
|
1018
|
+
status: schemaResult.success ? GENERATION_STATUS.CANDIDATE : 'invalid',
|
|
1019
|
+
graph: schemaResult.success ? schemaResult.data : graph,
|
|
1020
|
+
schemaErrors: schemaResult.success ? [] : schemaResult.error.issues,
|
|
1021
|
+
contradictions,
|
|
1022
|
+
cycles,
|
|
1023
|
+
unresolvedFacts: allUnresolvedFacts,
|
|
1024
|
+
entityCount: mergedEntities.length,
|
|
1025
|
+
assertionCount: mergedAssertions.length,
|
|
1026
|
+
linkCount: finalLinks.length,
|
|
1027
|
+
sourceCount: snapshots.length,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export async function compileGraph(opts = {}) {
|
|
1032
|
+
return _compile({ ...opts, publish: true });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export async function compileCandidate(opts = {}) {
|
|
1036
|
+
return _compile({ ...opts, publish: false });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
// Helpers
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
function countBy(items, field) {
|
|
1044
|
+
const counts = {};
|
|
1045
|
+
for (const item of items) {
|
|
1046
|
+
const val = item[field];
|
|
1047
|
+
if (val !== undefined) {
|
|
1048
|
+
counts[val] = (counts[val] || 0) + 1;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return counts;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
// Error type
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
|
|
1058
|
+
export class CompileGraphError extends Error {
|
|
1059
|
+
constructor(message) {
|
|
1060
|
+
super(message);
|
|
1061
|
+
this.name = 'CompileGraphError';
|
|
1062
|
+
}
|
|
1063
|
+
}
|