Setting the file. One moment. Extraction Detectors · Wix Headless Replatform · wix/skills · Skills Docs28.10
Workflow
This file
- Number
- 28.43
- Position
- 43 of 89
- Type
- JavaScript
- Size
- 11 KB
- Lines
- 243
scripts/lib/extraction-detectors.mjs
JavaScript·243 lines·11 KB
"banner"
,
"button"
,
"button-group"
,
"card"
,
"card-collection"
,
9 "category-strip", "cta-strip", "footer", "form", "gallery", "header", "hero",
10 "marquee", "menu", "navigation", "promo-band", "repeater", "reviews", "rich-text",
11 "stat-group", "static-content", "tabs", "text-media",
12]);
13
14export function detectUnitCandidates(value = {}, { unitKind = "section", evidenceRef = "" } = {}) {
15 const candidates = [];
16 const add = (detectorId, candidate) => {
17 if (!candidate?.kind) return;
18 const artifact = {
19 schemaVersion: DETECTOR_ENSEMBLE_VERSION,
20 detectorId,
21 detectorVersion: DETECTOR_ENSEMBLE_VERSION,
22 unitKind,
23 evidenceRefs: evidenceRef ? [evidenceRef] : [],
24 variant: candidate.variant || "default",
25 score: clamp(candidate.score),
26 signals: [...new Set(candidate.signals || [])].sort(),
27 kind: normalizeKind(candidate.kind),
28 };
29 const validation = validateGeneratedArtifact(artifact, "candidate");
30 if (!validation.ok) throw new Error(`Candidate validation failed: ${validation.errors.join("; ")}`);
31 candidates.push(artifact);
32 };
33
34 const declaredKind = value.kind || value.classification?.kind;
35 const declaredVariant = value.variant || value.classification?.variant;
36 if (declaredKind) {
37 const declaredConfidence = normalizeConfidenceValue(value.classification?.confidence ?? value.confidence);
38 add("declared-observation", {
39 kind: declaredKind,
40 variant: declaredVariant,
41 score: declaredConfidence ?? (KNOWN_KINDS.has(normalizeKind(declaredKind)) ? 0.82 : 0.58),
42 signals: ["observed-kind", declaredVariant ? "observed-variant" : ""].filter(Boolean),
43 });
44 }
45
46 const semantic = semanticCandidate(value);
47 if (semantic) add("semantic-aria", semantic);
48 const structural = structuralCandidate(value);
49 if (structural) add("structure-cardinality", structural);
50 const behavior = behaviorCandidate(value);
51 if (behavior) add("behavior-state", behavior);
52 const visual = visualCandidate(value);
53 if (visual) add("layout-visual", visual);
54
55 if (!candidates.length) add("fallback-static", {
56 kind: "static-content",
57 variant: "default",
58 score: 0.35,
59 signals: ["no-qualified-detector"],
60 });
61 return candidates.sort((a, b) => b.score - a.score || a.detectorId.localeCompare(b.detectorId));
62}
63
64export function resolveCandidateEnsemble(candidates, calibration = {}) {
65 if (!Array.isArray(candidates) || !candidates.length) throw new Error("Candidate ensemble requires at least one candidate");
66 const groups = new Map();
67 for (const candidate of candidates) {
68 const key = candidate.kind;
69 const group = groups.get(key) || { kind: candidate.kind, variant: candidate.variant, candidates: [], detectors: new Set(), scoreTotal: 0 };
70 group.candidates.push(candidate);
71 group.detectors.add(candidate.detectorId);
72 group.scoreTotal += candidate.score;
73 if (candidate.score > Math.max(...group.candidates.slice(0, -1).map((entry) => entry.score), -1)) group.variant = candidate.variant;
74 groups.set(key, group);
75 }
76 const ranked = [...groups.values()].map((group) => {
77 const max = Math.max(...group.candidates.map((candidate) => candidate.score));
78 const agreementBonus = Math.min(0.12, Math.max(0, group.detectors.size - 1) * 0.04);
79 const mean = group.scoreTotal / group.candidates.length;
80 return { ...group, aggregateScore: clamp(max * 0.68 + mean * 0.32 + agreementBonus) };
81 }).sort((a, b) => b.aggregateScore - a.aggregateScore || a.kind.localeCompare(b.kind));
82 const winner = ranked[0];
83 const runnerUp = ranked[1];
84 const margin = winner.aggregateScore - (runnerUp?.aggregateScore || 0);
85 const detectorCalibration = calibration[winner.kind] || {};
86 const highThreshold = detectorCalibration.highThreshold ?? 0.85;
87 const mediumThreshold = detectorCalibration.mediumThreshold ?? 0.60;
88 const highPrecision = detectorCalibration.highPrecision ?? (winner.detectors.size >= 2 ? HIGH_CONFIDENCE_MIN_PRECISION : 0);
89 const explicitHigh = winner.candidates.some((candidate) => candidate.detectorId === "declared-observation" && candidate.score >= 0.85);
90 const confidence = winner.aggregateScore >= highThreshold
91 && margin >= 0.08
92 && (highPrecision >= HIGH_CONFIDENCE_MIN_PRECISION || explicitHigh)
93 ? "high"
94 : winner.aggregateScore >= mediumThreshold && margin >= 0.03 ? "medium" : "low";
95 return {
96 kind: winner.kind,
97 variant: winner.variant,
98 confidence,
99 score: Number(winner.aggregateScore.toFixed(4)),
100 margin: Number(margin.toFixed(4)),
101 signals: [...new Set(winner.candidates.flatMap((candidate) => candidate.signals))].sort(),
102 candidates: candidates.map((candidate) => ({
103 schemaVersion: candidate.schemaVersion,
104 detectorId: candidate.detectorId,
105 detectorVersion: candidate.detectorVersion,
106 kind: candidate.kind,
107 variant: candidate.variant,
108 score: candidate.score,
109 signals: candidate.signals,
110 evidenceRefs: candidate.evidenceRefs,
111 })),
112 detectorVersions: Object.fromEntries(candidates.map((candidate) => [candidate.detectorId, candidate.detectorVersion])),
113 };
114}
115
116export function classifyObservedUnit(value, options = {}) {
117 return resolveCandidateEnsemble(detectUnitCandidates(value, options), options.calibration || {});
118}
119
120export function planRecursiveUnitJobs(sections = []) {
121 const jobs = [];
122 const evidenceOwners = new Map();
123 const ownershipConflicts = [];
124 const walk = (value, { id, parentUnitId, unitKind, order, evidenceRef, depth }) => {
125 if (depth > 12) throw new Error(`Recursive extraction depth exceeded for ${id}`);
126 const children = childValues(value);
127 const childIds = children.map((entry, index) => `${id}:component-${String(index + 1).padStart(3, "0")}`);
128 const job = { id, parentUnitId, unitKind, order, evidenceRef, depth, value, childIds };
129 jobs.push(job);
130 if (evidenceRef) {
131 const existing = evidenceOwners.get(evidenceRef);
132 if (existing && existing !== id) ownershipConflicts.push({ evidenceRef, owners: [existing, id].sort() });
133 else evidenceOwners.set(evidenceRef, id);
134 }
135 children.forEach((entry, index) => walk(entry.value, {
136 id: childIds[index],
137 parentUnitId: id,
138 unitKind: "component",
139 order: index + 1,
140 evidenceRef: `${evidenceRef}/${entry.key}/${entry.index}`,
141 depth: depth + 1,
142 }));
143 };
144 sections.forEach((section, index) => walk(section, {
145 id: section.id || `section-${String(index + 1).padStart(3, "0")}`,
146 parentUnitId: null,
147 unitKind: "section",
148 order: index + 1,
149 evidenceRef: `observations/page.json#/sections/${index}`,
150 depth: 0,
151 }));
152 return {
153 version: DETECTOR_ENSEMBLE_VERSION,
154 jobs,
155 ownership: [...evidenceOwners.entries()].map(([evidenceRef, ownerUnitId]) => ({ evidenceRef, ownerUnitId })).sort((a, b) => a.evidenceRef.localeCompare(b.evidenceRef)),
156 conflicts: dedupeConflicts(ownershipConflicts),
157 hash: sha256({ jobs: jobs.map(({ value, ...job }) => job), ownership: [...evidenceOwners.entries()] }),
158 };
159}
160
161function childValues(value) {
162 for (const key of ["modules", "components", "children"]) {
163 if (Array.isArray(value?.[key]) && value[key].some((item) => item && typeof item === "object")) {
164 return value[key].map((item, index) => ({ key, index, value: item })).filter((entry) => entry.value && typeof entry.value === "object");
165 }
166 }
167 return [];
168}
169
170function semanticCandidate(value) {
171 const role = String(value?.a11y?.role || value?.role || "").toLowerCase();
172 const tag = String(value?.tag || value?.domRef?.tag || "").toLowerCase();
173 if (tag === "header" || role === "banner") return candidate("header", "generic", 0.94, ["semantic-header"]);
174 if (tag === "footer" || role === "contentinfo") return candidate("footer", "site-footer", 0.96, ["semantic-footer"]);
175 if (tag === "nav" || role === "navigation") return candidate("navigation", "site-navigation", 0.95, ["semantic-navigation"]);
176 if (role === "tablist") return candidate("tabs", "tablist", 0.96, ["aria-tablist"]);
177 if (/button/.test(role) || tag === "button") return candidate("button", "native", 0.96, ["native-button"]);
178 return null;
179}
180
181function structuralCandidate(value) {
182 const counts = value?.counts || {};
183 const itemCount = value?.itemCount ?? value?.items?.length ?? value?.modules?.length ?? 0;
184 const imageCount = counts.images ?? value?.images?.length ?? 0;
185 const headingCount = counts.headings ?? (value?.heading ? 1 : 0);
186 const text = `${value?.heading || ""} ${value?.text || ""}`;
187 if (itemCount >= 3 && imageCount >= 3) return candidate("card-collection", value?.capabilities?.isCarouselLike ? "card-carousel" : "card-grid", 0.84, ["repeated-items", "repeated-media"]);
188 if (itemCount >= 2) return candidate("repeater", "collection", 0.72, ["repeated-items"]);
189 if (imageCount >= 1 && headingCount >= 1 && text.trim().length >= 20) return candidate("text-media", "media-left", 0.74, ["media", "heading", "copy"]);
190 return null;
191}
192
193function behaviorCandidate(value) {
194 const behavior = value?.behavior || value?.interaction || {};
195 const text = canonicalizeJson(behavior).toLowerCase();
196 if (/aria-?expanded|disclosure|accordion/.test(text)) return candidate("accordion", "disclosure", 0.92, ["expanded-state", "toggle-trigger"]);
197 if (/aria-selected|tablist|tabpanel/.test(text)) return candidate("tabs", "tablist", 0.93, ["selected-state", "tabpanel"]);
198 if (/carousel|slide|scroll-snap/.test(text)) return candidate("gallery", "carousel", 0.82, ["slide-state"]);
199 if (/menuitem|aria-haspopup|dropdown/.test(text)) return candidate("menu", "dropdown", 0.88, ["menu-state"]);
200 return null;
201}
202
203function visualCandidate(value) {
204 const classText = `${value?.className || ""} ${value?.idAttr || ""} ${value?.variant || ""}`.toLowerCase();
205 const layout = value?.layoutEvidence || value?.layout || {};
206 const layers = value?.layers || layout?.background?.layers || [];
207 if (/hero|masthead|billboard/.test(classText) || value?.a11y?.headingLevels?.includes?.(1) && layers.length) {
208 return candidate("hero", /carousel|slider/.test(classText) ? "carousel-hero" : "generic-hero", 0.80, ["first-view-dominance", layers.length ? "layered-background" : ""]);
209 }
210 if (/marquee|ticker/.test(classText)) return candidate("marquee", "horizontal", 0.83, ["visual-marquee"]);
211 if (/gallery/.test(classText)) return candidate("gallery", "grid", 0.78, ["visual-gallery"]);
212 return null;
213}
214
215function candidate(kind, variant, score, signals) {
216 return { kind, variant, score, signals: signals.filter(Boolean) };
217}
218
219function normalizeKind(value) {
220 return String(value || "static-content").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "static-content";
221}
222
223function normalizeConfidenceValue(value) {
224 if (typeof value === "number") return clamp(value);
225 if (value === "high") return 0.96;
226 if (value === "medium") return 0.72;
227 if (value === "low") return 0.40;
228 return null;
229}
230
231function clamp(value) {
232 return Number(Math.max(0, Math.min(1, Number(value) || 0)).toFixed(4));
233}
234
235function dedupeConflicts(conflicts) {
236 const seen = new Set();
237 return conflicts.filter((conflict) => {
238 const key = canonicalizeJson(conflict);
239 if (seen.has(key)) return false;
240 seen.add(key);
241 return true;
242 });
243}