feat(ontology): add core service catalog

This commit is contained in:
Codex
2026-06-20 12:54:03 +03:00
parent 83cca4224b
commit 2d5fef3948
59 changed files with 7878 additions and 0 deletions
+212
View File
@@ -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()
}