213 lines
8.1 KiB
JavaScript
213 lines
8.1 KiB
JavaScript
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()
|
||
}
|