feat(ontology): add core service catalog
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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: 'n8n 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()
|
||||
}
|
||||
Reference in New Issue
Block a user