|
| 1 | +const WRAPPER_KEYS = new Set([ |
| 2 | + 'artifacts', 'bundle', 'data', 'documents', 'items', 'model', 'models', 'modules', |
| 3 | + 'objects', 'output', 'outputs', 'payload', 'result', 'results', 'response' |
| 4 | +]); |
| 5 | +const STRONG_SINGLETON_WRAPPERS = new Set(['artifacts', 'bundle', 'documents', 'model', 'models', 'modules', 'output', 'outputs', 'payload', 'response', 'result', 'results']); |
| 6 | +const DESCRIPTOR_KEYS = ['modelName', 'model', 'filename', 'fileName', 'path', 'name', 'key', 'type', 'kind', 'id']; |
| 7 | +const PAYLOAD_KEYS = ['value', 'content', 'payload', 'data', 'document', 'object', 'result', 'body', 'model']; |
| 8 | +const GENERIC_NAME_TOKENS = new Set(['artifact', 'document', 'model', 'object', 'output', 'result']); |
| 9 | +const NON_MODEL_SINGLETON_KEYS = new Set(['code', 'error', 'errors', 'message', 'messages', 'note', 'status', 'success', 'warning', 'warnings']); |
| 10 | +const MODEL_SIGNAL_KEYS = new Set(['id', 'name', 'title', 'kind', 'statement', 'summary', 'description', 'classification', 'confidence', 'evidence', 'unknowns', 'items']); |
| 11 | + |
| 12 | +function isPlainObject(value) { |
| 13 | + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); |
| 14 | +} |
| 15 | + |
| 16 | +function parseJsonValue(value) { |
| 17 | + if (typeof value !== 'string') return value; |
| 18 | + const text = value.trim(); |
| 19 | + if (!text || !['{', '['].includes(text[0])) return value; |
| 20 | + try { return JSON.parse(text); } catch { return value; } |
| 21 | +} |
| 22 | + |
| 23 | +function words(value) { |
| 24 | + return String(value ?? '') |
| 25 | + .replace(/\.json$/i, '') |
| 26 | + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') |
| 27 | + .toLowerCase() |
| 28 | + .split(/[^a-z0-9]+/) |
| 29 | + .filter(Boolean); |
| 30 | +} |
| 31 | + |
| 32 | +function singular(word) { |
| 33 | + if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`; |
| 34 | + if (word.endsWith('s') && word.length > 3 && !word.endsWith('ss')) return word.slice(0, -1); |
| 35 | + return word; |
| 36 | +} |
| 37 | + |
| 38 | +export function canonicalModelName(value) { |
| 39 | + const parts = words(value); |
| 40 | + while (parts.length && GENERIC_NAME_TOKENS.has(parts[0])) parts.shift(); |
| 41 | + while (parts.length && GENERIC_NAME_TOKENS.has(parts.at(-1))) parts.pop(); |
| 42 | + if (parts.length) parts[parts.length - 1] = singular(parts.at(-1)); |
| 43 | + return parts.join(''); |
| 44 | +} |
| 45 | + |
| 46 | +function wrapperKey(value) { |
| 47 | + return words(value).join(''); |
| 48 | +} |
| 49 | + |
| 50 | +function matchesExpected(value, expected) { |
| 51 | + const candidate = canonicalModelName(value); |
| 52 | + return Boolean(candidate) && candidate === canonicalModelName(expected); |
| 53 | +} |
| 54 | + |
| 55 | +function asModelObject(value) { |
| 56 | + const parsed = parseJsonValue(value); |
| 57 | + if (isPlainObject(parsed)) return parsed; |
| 58 | + if (Array.isArray(parsed)) return { items: parsed }; |
| 59 | + return null; |
| 60 | +} |
| 61 | + |
| 62 | +function descriptorIdentity(value) { |
| 63 | + for (const key of DESCRIPTOR_KEYS) { |
| 64 | + const candidate = value?.[key]; |
| 65 | + if (typeof candidate === 'string' || typeof candidate === 'number') return String(candidate); |
| 66 | + } |
| 67 | + return null; |
| 68 | +} |
| 69 | + |
| 70 | +function descriptorPayload(value) { |
| 71 | + for (const key of PAYLOAD_KEYS) { |
| 72 | + if (!(key in value)) continue; |
| 73 | + const candidate = asModelObject(value[key]); |
| 74 | + if (candidate) return candidate; |
| 75 | + } |
| 76 | + const ignored = new Set([...DESCRIPTOR_KEYS, ...PAYLOAD_KEYS]); |
| 77 | + const remainder = Object.fromEntries(Object.entries(value).filter(([key]) => !ignored.has(key))); |
| 78 | + return Object.keys(remainder).length ? remainder : null; |
| 79 | +} |
| 80 | + |
| 81 | +function directSingletonCandidate(value, expectedNames) { |
| 82 | + if (expectedNames.length !== 1) return null; |
| 83 | + const root = asModelObject(value); |
| 84 | + if (!root) return null; |
| 85 | + const keys = Object.keys(root); |
| 86 | + if (!keys.length) return root; |
| 87 | + if (keys.length === 1 && keys.some((key) => STRONG_SINGLETON_WRAPPERS.has(wrapperKey(key)))) return null; |
| 88 | + if (keys.every((key) => NON_MODEL_SINGLETON_KEYS.has(wrapperKey(key)))) return null; |
| 89 | + if (keys.some((key) => expectedNames.some((name) => matchesExpected(key, name)))) return null; |
| 90 | + const structured = Object.values(root).some((entry) => entry && typeof entry === 'object'); |
| 91 | + const semantic = keys.some((key) => MODEL_SIGNAL_KEYS.has(wrapperKey(key))); |
| 92 | + return structured || semantic ? root : null; |
| 93 | +} |
| 94 | + |
| 95 | +export function extractModelObjects(bundle, expectedNames, { maxDepth = 8, maxNodes = 10000 } = {}) { |
| 96 | + const expected = [...new Set(expectedNames.map(String))]; |
| 97 | + const candidates = new Map(); |
| 98 | + const diagnostics = []; |
| 99 | + const seen = new WeakSet(); |
| 100 | + let nodes = 0; |
| 101 | + |
| 102 | + function offer(name, rawValue, score, origin) { |
| 103 | + const value = asModelObject(rawValue); |
| 104 | + if (!value) return; |
| 105 | + const current = candidates.get(name); |
| 106 | + if (!current || score > current.score) { |
| 107 | + candidates.set(name, { value, score, origin }); |
| 108 | + diagnostics.push({ type: 'accepted', model: name, origin, score }); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + function visit(rawValue, depth = 0, origin = '$') { |
| 113 | + if (depth > maxDepth || nodes >= maxNodes) return; |
| 114 | + const value = parseJsonValue(rawValue); |
| 115 | + if (Array.isArray(value)) { |
| 116 | + nodes++; |
| 117 | + for (let index = 0; index < value.length; index++) visit(value[index], depth + 1, `${origin}[${index}]`); |
| 118 | + return; |
| 119 | + } |
| 120 | + if (!isPlainObject(value) || seen.has(value)) return; |
| 121 | + seen.add(value); nodes++; |
| 122 | + |
| 123 | + const matchedModelKeys = new Set(); |
| 124 | + for (const [key, child] of Object.entries(value)) { |
| 125 | + for (const name of expected) { |
| 126 | + if (matchesExpected(key, name)) { offer(name, child, 1000 - (depth * 10), `${origin}.${key}`); matchedModelKeys.add(key); } |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + const identity = descriptorIdentity(value); |
| 131 | + if (identity) { |
| 132 | + for (const name of expected) { |
| 133 | + if (matchesExpected(identity, name)) offer(name, descriptorPayload(value), 800 - (depth * 10), `${origin}<${identity}>`); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + const entries = Object.entries(value).sort(([left], [right]) => { |
| 138 | + const leftWrapper = WRAPPER_KEYS.has(wrapperKey(left)) ? 0 : 1; |
| 139 | + const rightWrapper = WRAPPER_KEYS.has(wrapperKey(right)) ? 0 : 1; |
| 140 | + return leftWrapper - rightWrapper; |
| 141 | + }); |
| 142 | + for (const [key, child] of entries) if (!matchedModelKeys.has(key)) visit(child, depth + 1, `${origin}.${key}`); |
| 143 | + } |
| 144 | + |
| 145 | + visit(bundle); |
| 146 | + if (!candidates.size) { |
| 147 | + const singleton = directSingletonCandidate(bundle, expected); |
| 148 | + if (singleton) offer(expected[0], singleton, 100, '$<direct-singleton>'); |
| 149 | + } |
| 150 | + |
| 151 | + const objects = Object.fromEntries(expected.filter((name) => candidates.has(name)).map((name) => [name, candidates.get(name).value])); |
| 152 | + const missing = expected.filter((name) => !Object.hasOwn(objects, name)); |
| 153 | + if (nodes >= maxNodes) diagnostics.push({ type: 'limit', reason: 'maxNodes', maxNodes }); |
| 154 | + return { objects, missing, diagnostics, visitedNodes: nodes }; |
| 155 | +} |
| 156 | + |
| 157 | +export function mergeModelObjects(target, extraction) { |
| 158 | + for (const [name, value] of Object.entries(extraction.objects ?? {})) if (!Object.hasOwn(target, name)) target[name] = value; |
| 159 | + return target; |
| 160 | +} |
| 161 | + |
| 162 | +export function safeModelPlaceholder(name, reason = 'Provider omitted the requested model object after bounded recovery.') { |
| 163 | + return { |
| 164 | + status: 'degraded', |
| 165 | + providerOutputStatus: 'missing', |
| 166 | + classification: 'UNKNOWN', |
| 167 | + confidence: 0, |
| 168 | + evidence: [], |
| 169 | + unknowns: [{ |
| 170 | + id: `${canonicalModelName(name) || 'model'}-provider-output-missing`, |
| 171 | + kind: 'provider-output-gap', |
| 172 | + name: `${name} model unavailable`, |
| 173 | + statement: reason, |
| 174 | + classification: 'UNKNOWN', |
| 175 | + confidence: 0, |
| 176 | + evidence: [] |
| 177 | + }], |
| 178 | + normalizationNotes: [`A deterministic UNKNOWN placeholder was created for ${name}; no repository fact was invented.`] |
| 179 | + }; |
| 180 | +} |
| 181 | + |
| 182 | +export function resolveModelObjects(expectedNames, bundles, { missingPolicy = 'placeholder', placeholderReason } = {}) { |
| 183 | + const expected = [...new Set(expectedNames.map(String))]; |
| 184 | + const objects = {}; |
| 185 | + const diagnostics = []; |
| 186 | + for (const [index, bundle] of bundles.entries()) { |
| 187 | + const extraction = extractModelObjects(bundle, expected.filter((name) => !Object.hasOwn(objects, name))); |
| 188 | + mergeModelObjects(objects, extraction); |
| 189 | + diagnostics.push(...extraction.diagnostics.map((entry) => ({ ...entry, attempt: index + 1 }))); |
| 190 | + } |
| 191 | + const unresolved = expected.filter((name) => !Object.hasOwn(objects, name)); |
| 192 | + const degraded = []; |
| 193 | + if (unresolved.length && missingPolicy !== 'fail') { |
| 194 | + for (const name of unresolved) { objects[name] = safeModelPlaceholder(name, placeholderReason); degraded.push(name); } |
| 195 | + } |
| 196 | + return { objects, missing: expected.filter((name) => !Object.hasOwn(objects, name)), degraded, diagnostics }; |
| 197 | +} |
0 commit comments