import fs from 'node:fs/promises' import { canonicalizeEntityId, loadCatalog } from './catalog.mjs' import { validateCatalog } from './validate.mjs' function tokenize(value) { return String(value || '') .toLowerCase() .split(/[^a-zа-яё0-9_]+/iu) .filter(Boolean) } function scoreRule(rule, input) { let score = 0 const entityId = input.entityId || '' if (entityId && rule.inputEntityIds.includes(entityId)) score += 10 if (input.surface && input.surface === rule.fromSurface) score += 6 if (score === 0) return 0 const intentTokens = new Set(tokenize(input.intent)) for (const hint of rule.intentHints || []) { const hintTokens = tokenize(hint) if (hintTokens.some((token) => intentTokens.has(token))) score += 2 } return score } function refMatches(candidateRefs = {}, inputRefs = {}) { const candidateEntries = Object.entries(candidateRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '') const inputEntries = Object.entries(inputRefs || {}).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '') if (!candidateEntries.length || !inputEntries.length) return false const matches = (entries, refs) => entries.every(([key, value]) => String(refs[key] || '') === String(value)) const candidateInsideInput = matches(candidateEntries, inputRefs) const inputInsideCandidate = matches(inputEntries, candidateRefs) if (!candidateInsideInput && !inputInsideCandidate) return false const matchedKeys = candidateEntries .map(([key]) => key) .filter((key) => inputRefs[key] !== undefined && String(inputRefs[key]) === String(candidateRefs[key])) const hasStrongRef = ['issue_id', 'workflow_id', 'runtime_workflow_id', 'node_id', 'grant_id'].some((key) => matchedKeys.includes(key)) return hasStrongRef || matchedKeys.length >= 2 } function contextScore(context, input) { let score = 0 if (input.contextId && input.contextId === context.id) score += 100 if (refMatches(context.refs, input.refs)) score += 40 if (score === 0) return 0 if (input.surface && input.surface === context.surface) score += 10 if (input.entityId && input.entityId === context.entityId) score += 10 return score } function findMatchingContexts(catalog, input) { return catalog.contextBindings.contexts .map((context) => ({ context, score: contextScore(context, input) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score || a.context.id.localeCompare(b.context.id)) } function bindingTouchesContext(binding, contextIds) { return contextIds.has(binding.fromContextId) || contextIds.has(binding.toContextId) } function findBindingsForContexts(catalog, contexts) { const contextIds = new Set(contexts.map(({ context }) => context.id)) return catalog.contextBindings.bindings .filter((binding) => bindingTouchesContext(binding, contextIds)) .map((binding) => ({ ...binding, type: catalog.bindingTypeById.get(binding.typeId) || null, fromContext: catalog.contextById.get(binding.fromContextId) || null, toContext: catalog.contextById.get(binding.toContextId) || null, })) } function findRequiredBindingTypes(catalog, selectedRule) { if (!selectedRule) return [] return catalog.contextBindings.bindingTypes.filter((bindingType) => bindingType.relationId === selectedRule.relationId) } export async function resolveContext(input = {}) { const catalog = await loadCatalog() const entityId = canonicalizeEntityId(input.entityId || input.alias || input.term, catalog) const normalizedInput = { ...input, entityId, } const matchedContexts = findMatchingContexts(catalog, normalizedInput) if (!normalizedInput.entityId && matchedContexts[0]?.context?.entityId) { normalizedInput.entityId = matchedContexts[0].context.entityId } const candidates = catalog.resolverRules.rules .map((rule) => ({ rule, score: scoreRule(rule, normalizedInput) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score || a.rule.id.localeCompare(b.rule.id)) const selected = candidates[0]?.rule || null const bindings = findBindingsForContexts(catalog, matchedContexts) const requiredBindingTypes = findRequiredBindingTypes(catalog, selected) const existingBindingTypeIds = new Set(bindings.map((binding) => binding.typeId)) const missingBindingTypes = requiredBindingTypes.filter((bindingType) => !existingBindingTypeIds.has(bindingType.id)) return { input: normalizedInput, canonicalEntity: catalog.entityById.get(normalizedInput.entityId) || null, matchedContexts: matchedContexts.map(({ context, score }) => ({ id: context.id, score, surface: context.surface, entityId: context.entityId, label: context.label, sourceSystem: context.sourceSystem, refs: context.refs, status: context.status, })), bindings: bindings.map((binding) => ({ id: binding.id, typeId: binding.typeId, status: binding.status, fromContextId: binding.fromContextId, toContextId: binding.toContextId, relationId: binding.type?.relationId || null, summary: binding.summary || binding.type?.summary || '', })), missingBindingTypes: missingBindingTypes.map((bindingType) => ({ id: bindingType.id, relationId: bindingType.relationId, requiredFromRefs: bindingType.requiredFromRefs || [], requiredToRefs: bindingType.requiredToRefs || [], summary: bindingType.summary, })), selectedRule: selected, candidates: candidates.map(({ rule, score }) => ({ id: rule.id, score, outputSurface: rule.outputSurface, outputEntityIds: rule.outputEntityIds, requiredBindings: rule.requiredBindings, summary: rule.summary, })), } } async function resolveFromCli() { const inputIndex = process.argv.indexOf('--input') const inputJsonIndex = process.argv.indexOf('--input-json') let input = null if (inputIndex >= 0) { input = JSON.parse(process.argv[inputIndex + 1] || '{}') } else if (inputJsonIndex >= 0) { const raw = await fs.readFile(process.argv[inputJsonIndex + 1], 'utf8') input = JSON.parse(raw) } if (!input) { console.error('Usage: node src/resolver.mjs --input-json examples/ops-card-to-engine-request.json') process.exit(2) } const out = await resolveContext(input) console.log(JSON.stringify(out, null, 2)) } async function runSmoke() { const validation = await validateCatalog() if (!validation.ok) { console.error(JSON.stringify(validation, null, 2)) process.exit(1) } const samples = [ { surface: 'ops', alias: 'issue', intent: 'inspect engine workflow for this card' }, { surface: 'ops', alias: 'issue', intent: 'inspect engine workflow for this ontology card', refs: { workspace_slug: 'nodedc', project_id: '86629a11-eaff-4ad2-9f89-e5245a344fcc', issue_id: '1312519a-0eda-4ea0-9e34-2113c3724ecf', }, }, { surface: 'engine', entityId: 'engine.workflow_l1', intent: 'создай карточку в ops по текущему workflow' }, { surface: 'assistant', entityId: 'assistant.bridge', intent: 'select mcp gateway grant and tool' }, { term: 'runtime workflow id', intent: 'runtime workflow id lookup' }, ] const results = [] for (const sample of samples) { const out = await resolveContext(sample) results.push({ sample, canonicalEntityId: out.canonicalEntity?.id || out.input.entityId || null, selectedRuleId: out.selectedRule?.id || null, outputSurface: out.selectedRule?.outputSurface || null, outputEntityIds: out.selectedRule?.outputEntityIds || [], matchedContextIds: out.matchedContexts.map((context) => context.id), missingBindingTypeIds: out.missingBindingTypes.map((bindingType) => bindingType.id), }) } console.log(JSON.stringify({ ok: true, validation: validation.counts, results }, null, 2)) } if (import.meta.url === `file://${process.argv[1]}` && process.argv.includes('--smoke')) { await runSmoke() } else if (import.meta.url === `file://${process.argv[1]}`) { await resolveFromCli() }