Files
NODEDC_PLATFORM/services/ontology-core/src/resolver.mjs
T

213 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}