diff --git a/docs/AI_WORKSPACE_CONFIG_CONTRACT.md b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md index 1211973..2edf5d7 100644 --- a/docs/AI_WORKSPACE_CONFIG_CONTRACT.md +++ b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md @@ -27,6 +27,18 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica - The deployed AI Hub is allowed only as transport. Launcher, Engine, Ops, Authentik, task manager, and downstream app calls must stay local. - The remote worker must not call product apps directly. Assistant actions must route back through the local AI Workspace Assistant. +`local-seo/deployed-hub-control-plane`: + +- SEO frontend: `http://127.0.0.1:5177` +- SEO backend: `http://127.0.0.1:4100` +- SEO AI Workspace profile: `deployed-hub` +- SEO backend Assistant-compatible control-plane URL: `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru` +- SEO backend control-plane token: `SEO_AI_WORKSPACE_CONTROL_TOKEN`, accepted by the public Hub server-side API. +- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub` +- Public Hub exposes only token-gated Assistant control-plane proxy endpoints required by SEO model-provider setup, probe, thread dispatch, and message polling. +- Public Hub must proxy those calls to internal Platform Assistant with server-side internal token. Browser clients and remote workers must not receive the Hub internal token or Assistant token. +- This profile is for local SEO development against the deployed AI Workspace service plane. It is not the same as `local`, and it must not silently fall back to localhost or product app URLs. + `synology/prod-public`: - Browser Launcher: `https://hub.nodedc.ru` @@ -57,6 +69,23 @@ New apps must join AI Workspace through app manifests/config/adapters: Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow. +### NDC SEO Mode + +`seo-mode` is a first-class AI Workspace surface, not `global`. + +Local SEO testing should keep SEO app/backend on fixed local ports while using the configured AI Workspace transport/profile: + +- SEO frontend: `http://127.0.0.1:5177` +- SEO backend: `http://127.0.0.1:4100` +- SEO model-provider surface: `seo-mode` +- SEO model task mode: `seo-model-task` +- Worker-facing transport: public AI Hub relay when selected by profile, normally `wss://ai-hub.nodedc.ru/api/ai-workspace/hub` +- SEO task payload: sanitized `seo-model-task` contract only + +The SEO backend must select profiles through env/config, not code edits. Local and deploy-host may use different Assistant URLs, executor ids, setup commands, model defaults, and app grants, but the request contract stays the same. + +Remote Codex for SEO must not receive Wordstat/Yandex credentials, product app tokens, unrestricted repo access, rewrite/apply actions, or destructive actions. Wordstat/SERP collection remains SEO-backend-owned. + ## Verification Before changing worker, Assistant, Hub, or app adapter routing, run: diff --git a/infra/synology/README.md b/infra/synology/README.md index 16e3acb..5a1f78b 100644 --- a/infra/synology/README.md +++ b/infra/synology/README.md @@ -73,6 +73,8 @@ AI_WORKSPACE_HUB_FALLBACK_URLS= Server-side Hub API calls, including executor status checks, use `AI_WORKSPACE_HUB_INTERNAL_URL` and require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. +Local NDC SEO Mode development uses this same server-side Hub class through `SEO_AI_WORKSPACE_PROFILE=deployed-hub`, `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`, and a Hub-accepted `SEO_AI_WORKSPACE_CONTROL_TOKEN`. The Hub only exposes an Assistant-compatible allowlist proxy for setup/probe/thread dispatch calls behind bearer-token auth; it does not expose unrestricted Assistant, product apps, Authentik, Engine, Ops, or Tasker to the remote Codex worker. + AI Workspace Assistant also calls product entitlement adapters before every Codex run. Ops uses the Agent Gateway internal endpoint; the token must match `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN` from the Ops Agent Gateway deployment: ```env diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs index 5a3cd98..302b104 100755 --- a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs @@ -1257,6 +1257,8 @@ function usesJsonOutput(args) { return Array.isArray(args) && args.includes('--json') } +const CODEX_ASSISTANT_TEXT_MAX = 250000 + function truncateText(value, max = 1400) { const text = String(value || '').trim() return text.length > max ? `${text.slice(0, max).trim()}...` : text @@ -1504,7 +1506,7 @@ function createActiveMirror(command, payload = {}, meta = {}) { if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000) if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000) if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000) - if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000) + if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, CODEX_ASSISTANT_TEXT_MAX) if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed' if (kind === 'timeout') state.status = 'timeout' if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed' @@ -1520,7 +1522,7 @@ function createActiveMirror(command, payload = {}, meta = {}) { async complete(status, assistantText = '') { state.status = status state.finishedAt = new Date().toISOString() - if (assistantText) state.assistantText = markdownText(assistantText, 12000) + if (assistantText) state.assistantText = markdownText(assistantText, CODEX_ASSISTANT_TEXT_MAX) await write({ at: state.finishedAt, kind: `mirror_${status}`, @@ -1656,7 +1658,7 @@ function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) { const text = truncateText(reasoningTextFromItem(item)) return text ? { kind: 'codex_reasoning', message: text } : null } - if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') } + if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) } if (itemType === 'command_execution') { const status = item.status ? ` - ${item.status}` : '' return { diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs index 4db6c5f..8bd9329 100755 --- a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const DEFAULT_GATEWAY = "https://ai-hub.nodedc.ru"; const PACKAGE_NAME = "@nodedc/ai-workspace-bridge"; const SERVICE_NAME = "NDC AI Workspace Bridge"; -const WINDOWS_TASK_NAME = "NDC AI Workspace Bridge"; +const WINDOWS_TASK_NAME = "NDCAIWorkspaceBridge"; const HEALTH_PATH = "/api/ai-workspace/bridge/v1/health"; const BRIDGE_PROTOCOL_VERSION = "ai-workspace-bridge.v1"; const BRIDGE_CAPABILITIES = [ @@ -638,7 +638,7 @@ async function installAutostart(startScriptPath, installRoot) { async function installWindowsTask(startScriptPath) { const taskRun = `"${process.execPath}" "${startScriptPath}"`; - runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {}); + runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { shell: false }); await runCommand("schtasks", [ "/Create", "/TN", @@ -650,7 +650,7 @@ async function installWindowsTask(startScriptPath) { "/RL", "HIGHEST", "/F", - ], {}); + ], { shell: false }); } async function installLaunchAgent(startScriptPath) { @@ -788,7 +788,7 @@ function runCommandQuiet(command, args, options = {}) { const result = spawnSync(command, args, { env: commandEnv(options), encoding: "utf8", - shell: process.platform === "win32", + shell: options.shell ?? process.platform === "win32", windowsHide: true, }); return { @@ -807,7 +807,7 @@ function runCommand(command, args, options = {}) { const child = spawn(command, args, { env: commandEnv(options), stdio: options.stdio || "inherit", - shell: process.platform === "win32", + shell: options.shell ?? process.platform === "win32", windowsHide: true, }); child.on("error", reject); diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json index 5fe0721..e411c81 100644 --- a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ai-workspace-bridge", - "version": "0.1.0", + "version": "0.1.1", "description": "Install NODE.DC AI Workspace Bridge worker.", "type": "module", "bin": { diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index adf7db3..a605d76 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -9,7 +9,7 @@ import { Pool } from "pg"; import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs"; import { loadCatalog } from "../../ontology-core/src/catalog.mjs"; -const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]); +const SUPPORTED_SURFACES = new Set(["engine", "ops", "seo-mode", "global"]); const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]); const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]); const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]); @@ -100,6 +100,24 @@ const APP_ROUTING_CATALOG = [ requiredScopes: ["ops:project:read"], deniedText: ACCESS_DENIED_TEXT, }, + { + appId: "seo-mode", + appTitle: "NDC SEO Mode", + surface: "seo-mode", + skillId: "seo-mode-context", + whenToUse: [ + "SEO model-provider tasks", + "keyword cleaning proposals", + "SERP interpretation proposals", + "strategy synthesis proposals", + "strategy quality review", + ], + actionNamespaces: ["seo.*"], + actionIdPrefixes: ["seo."], + mcpServerNames: [], + requiredScopes: ["seo:model-provider:run"], + deniedText: ACCESS_DENIED_TEXT, + }, ]; const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000); const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000); @@ -422,7 +440,8 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma port: installerPort, appMcpServers, }); - const command = buildBridgeSetupCommand(setupCode.code); + const bridgePackageSpec = bridgePackageSpecForSetupCode(setupCode.code); + const command = buildBridgeSetupCommand(setupCode.code, bridgePackageSpec); res.status(201).json({ ok: true, owner: publicOwner(owner), @@ -430,7 +449,7 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma install: { command, packageName: config.bridgePackageName, - packageSpec: config.bridgePackageSpec, + packageSpec: bridgePackageSpec, gatewayUrl: config.setupGatewayUrl, expiresAt: setupCode.expiresAt, }, @@ -980,12 +999,12 @@ function bridgeSetupPayload(executor, options = {}) { }; } -function buildBridgeSetupCommand(code) { +function buildBridgeSetupCommand(code, packageSpec = config.bridgePackageSpec) { const parts = [ "npx", "--yes", "--package", - config.bridgePackageSpec, + packageSpec, AI_WORKSPACE_BRIDGE_PACKAGE_BIN, "setup", code, @@ -995,6 +1014,13 @@ function buildBridgeSetupCommand(code) { return parts.join(" "); } +function bridgePackageSpecForSetupCode(code) { + const packageSpec = String(config.bridgePackageSpec || ""); + if (!/^https?:\/\//i.test(packageSpec)) return packageSpec; + const separator = packageSpec.includes("?") ? "&" : "?"; + return `${packageSpec}${separator}setup=${encodeURIComponent(setupCodeSuffix(code))}`; +} + async function getBridgePackageTarball() { if (cachedBridgePackageTarball) return cachedBridgePackageTarball; @@ -1217,7 +1243,7 @@ function mergeOwnerActiveContext(current, incoming) { }; } - if ((surface === "engine" || surface === "ops") && !isPlainObject(incomingContexts[surface])) { + if ((surface === "engine" || surface === "ops" || surface === "seo-mode") && !isPlainObject(incomingContexts[surface])) { const { contexts: _contexts, ...surfaceContext } = incomingContext; nextContexts[surface] = { ...(isPlainObject(nextContexts[surface]) ? nextContexts[surface] : {}), @@ -2770,15 +2796,17 @@ function mergeSurfaceContexts(...contexts) { } const surface = normalizeKey(context.surface); const mode = normalizeKey(context.modeId); - const inferredSurface = surface === "engine" || surface === "ops" + const inferredSurface = surface === "engine" || surface === "ops" || surface === "seo-mode" ? surface : mode === "ndc-agent-core" ? "engine" : mode === "ops" ? "ops" + : mode === "seo-model-task" + ? "seo-mode" : ""; if (inferredSurface) { - const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, ...plain } = context; + const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, seoModeContext: _seoModeContext, ...plain } = context; out[inferredSurface] = mergeBridgeContextObject(out[inferredSurface], plain); } } @@ -3373,7 +3401,7 @@ async function migrate() { constraint ai_workspace_threads_owner_check check (owner_user_id is not null or owner_email is not null), constraint ai_workspace_threads_surface_check - check (origin_surface in ('engine', 'ops', 'global')), + check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')), constraint ai_workspace_threads_lifecycle_check check (lifecycle_state in ('active', 'archived')) ) @@ -3420,13 +3448,27 @@ async function migrate() { constraint ai_workspace_runs_owner_check check (owner_user_id is not null or owner_email is not null), constraint ai_workspace_runs_surface_check - check (origin_surface in ('engine', 'ops', 'global')), + check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')), constraint ai_workspace_runs_status_check check (status in ('running', 'completed', 'failed', 'timeout')), unique (owner_key, thread_id, request_id) ) `); + await pool.query(` + alter table ai_workspace_threads + drop constraint if exists ai_workspace_threads_surface_check, + add constraint ai_workspace_threads_surface_check + check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')) + `); + + await pool.query(` + alter table ai_workspace_runs + drop constraint if exists ai_workspace_runs_surface_check, + add constraint ai_workspace_runs_surface_check + check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')) + `); + await pool.query(` create table if not exists ai_workspace_run_events ( id uuid primary key, @@ -4203,6 +4245,11 @@ function parseEntitlementAdapters() { token: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN, required: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED, }); + collectEntitlementAdapter(adapters, "seo-mode", { + url: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_URL, + token: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_TOKEN, + required: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_REQUIRED, + }); collectEntitlementAdapter(adapters, "launcher", { url: process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL || @@ -4228,6 +4275,7 @@ function parseEntitlementAdapters() { const order = new Map([ ["ops", 10], ["engine", 20], + ["seo-mode", 30], ["launcher", 90], ]); return Array.from(byAppId.values()) diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 index f0b8cca..4ce6915 100644 --- a/services/ai-workspace-assistant/src/templates/install-windows.ps1 +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -1962,6 +1962,8 @@ function usesJsonOutput(args) { return Array.isArray(args) && args.includes('--json') } +const CODEX_ASSISTANT_TEXT_MAX = 250000 + function truncateText(value, max = 1400) { const text = String(value || '').trim() return text.length > max ? `${text.slice(0, max).trim()}...` : text @@ -2209,7 +2211,7 @@ function createActiveMirror(command, payload = {}, meta = {}) { if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000) if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000) if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000) - if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000) + if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, CODEX_ASSISTANT_TEXT_MAX) if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed' if (kind === 'timeout') state.status = 'timeout' if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed' @@ -2225,7 +2227,7 @@ function createActiveMirror(command, payload = {}, meta = {}) { async complete(status, assistantText = '') { state.status = status state.finishedAt = new Date().toISOString() - if (assistantText) state.assistantText = markdownText(assistantText, 12000) + if (assistantText) state.assistantText = markdownText(assistantText, CODEX_ASSISTANT_TEXT_MAX) await write({ at: state.finishedAt, kind: `mirror_${status}`, @@ -2361,7 +2363,7 @@ function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) { const text = truncateText(reasoningTextFromItem(item)) return text ? { kind: 'codex_reasoning', message: text } : null } - if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') } + if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) } if (itemType === 'command_execution') { const status = item.status ? ` - ${item.status}` : '' return { diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index ec10e98..cec17d7 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -8,6 +8,8 @@ const MAX_EVENTS_PER_AGENT = 5000; const MAX_REQUEST_META_PER_AGENT = 1000; const HEARTBEAT_INTERVAL_MS = 30000; const AGENT_STALE_MS = 75000; +const DEFAULT_AGENT_EVENT_TEXT_MAX = Number(process.env.AI_WORKSPACE_AGENT_EVENT_TEXT_MAX || 12000); +const CODEX_MESSAGE_EVENT_TEXT_MAX = Number(process.env.AI_WORKSPACE_CODEX_MESSAGE_EVENT_TEXT_MAX || 250000); const ASSISTANT_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25000); const ASSISTANT_RELAY_ACTION_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ACTION_TIMEOUT_MS || 45000); const ASSISTANT_RELAY_MAX_PENDING = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_PENDING || 200); @@ -67,6 +69,14 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna }); app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions)); +app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-command", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/check", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.get("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); +app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInternalApi, asyncRoute(proxyAssistantControlPlane)); app.post("/api/ai-workspace/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem)); app.get("/api/ai-workspace/bridge-package.tgz", asyncRoute(proxyBridgePackage)); @@ -302,6 +312,33 @@ async function proxyAssistantActions(req, res) { res.send(text); } +async function proxyAssistantControlPlane(req, res) { + if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) { + res.status(503).json({ ok: false, error: "assistant_control_plane_proxy_not_configured" }); + return; + } + + const method = String(req.method || "GET").toUpperCase(); + const hasBody = !["GET", "HEAD"].includes(method); + const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}${String(req.originalUrl || req.url || "")}`; + const upstream = await fetch(targetUrl, { + method, + redirect: "manual", + headers: { + Accept: "application/json", + Authorization: `Bearer ${config.assistantInternalAccessToken}`, + ...forwardOwnerHeaders(req), + ...(hasBody ? { "Content-Type": "application/json" } : {}), + }, + ...(hasBody ? { body: JSON.stringify(req.body || {}) } : {}), + }); + const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8"; + const text = await upstream.text(); + res.status(upstream.status); + res.setHeader("content-type", contentType); + res.send(text); +} + async function proxySetupCodeRedeem(req, res) { if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) { res.status(503).json({ ok: false, error: "assistant_setup_proxy_not_configured" }); @@ -572,6 +609,8 @@ function pushAgentEvent(agent, requestId, rawEvent = {}) { const now = new Date().toISOString(); const cleanRequestId = String(requestId || rawEvent.requestId || ""); const meta = cleanRequestId ? agent.requestMeta?.get(cleanRequestId) : null; + const kind = cleanString(rawEvent.kind || rawEvent.type || "event", 40); + const eventTextMax = kind === "codex_message" ? CODEX_MESSAGE_EVENT_TEXT_MAX : DEFAULT_AGENT_EVENT_TEXT_MAX; const event = { id: ++eventSeq, pairingCode: agent.pairingCode, @@ -581,10 +620,10 @@ function pushAgentEvent(agent, requestId, rawEvent = {}) { threadTitle: rawEvent.threadTitle == null ? cleanString(meta?.threadTitle, 240) : cleanString(rawEvent.threadTitle, 240), userMessage: rawEvent.userMessage == null ? cleanString(meta?.userMessage, 12000) : cleanString(rawEvent.userMessage, 12000), remoteControlMode: rawEvent.remoteControlMode == null ? cleanString(meta?.remoteControlMode, 40) : cleanString(rawEvent.remoteControlMode, 40), - kind: cleanString(rawEvent.kind || rawEvent.type || "event", 40), + kind, stream: rawEvent.stream ? cleanString(rawEvent.stream, 40) : "", - text: rawEvent.text == null ? "" : cleanString(rawEvent.text, 12000), - message: rawEvent.message == null ? "" : cleanString(rawEvent.message, 12000), + text: rawEvent.text == null ? "" : cleanString(rawEvent.text, eventTextMax), + message: rawEvent.message == null ? "" : cleanString(rawEvent.message, eventTextMax), command: rawEvent.command == null ? "" : cleanString(rawEvent.command, 1000), cwd: rawEvent.cwd == null ? "" : cleanString(rawEvent.cwd, 500), currentFile: rawEvent.currentFile == null ? "" : cleanString(rawEvent.currentFile, 1000), diff --git a/services/ontology-core/README.md b/services/ontology-core/README.md index b075121..f553e1b 100644 --- a/services/ontology-core/README.md +++ b/services/ontology-core/README.md @@ -36,6 +36,7 @@ catalog/context-bindings.json catalog/assistant-access-policy.json catalog/assistant-actions.json catalog/assistant-risk-policy.json +catalog/domain-packages/*/*.json examples/*.json docs/baseline/*.md docs/CONTEXT_BINDINGS.md @@ -43,6 +44,8 @@ docs/ASSISTANT_ACCESS_POLICY.md docs/ASSISTANT_ACTION_REGISTRY.md docs/ASSISTANT_CALLER_CONTRACT.md docs/ASSISTANT_EXECUTION_GATEWAY.md +docs/SEO_DOMAIN_ONTOLOGY.md +docs/PROJECT_ONTOLOGY_INSTANCE_SCHEMA.md src/catalog.mjs src/registry.mjs src/validate.mjs @@ -144,6 +147,22 @@ The resolver returns: - existing context bindings; - missing binding types required for the selected route. +## Domain Packages + +Ontology Core can load domain packages from: + +```text +catalog/domain-packages/* +``` + +Each package may contribute entities, relations, aliases, guardrails, evidence, resolver rules, context binding types, assistant access policy entries, assistant actions, and risk policy entries. + +Current package: + +- `seo` - NDC SEO mod domain ontology for site scans, project ontology instances, scope contracts, semantic analysis, market evidence, optimization planning, validation, changesets, and future app-owned SEO assistant actions. + +The package loader merges domain packages into the base catalog before validation. Domain packages extend core meanings; they do not own app data or execute mutations. + ## First Implementation Target The first useful resolver flows are: diff --git a/services/ontology-core/catalog/domain-packages/seo/aliases.json b/services/ontology-core/catalog/domain-packages/seo/aliases.json new file mode 100644 index 0000000..f8dd18b --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/aliases.json @@ -0,0 +1,58 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "aliases": [ + { "alias": "SEO mod", "canonicalId": "seo.project" }, + { "alias": "NDC SEO mod", "canonicalId": "seo.project" }, + { "alias": "seo project", "canonicalId": "seo.project" }, + { "alias": "site", "canonicalId": "seo.site" }, + { "alias": "сайт", "canonicalId": "seo.site" }, + { "alias": "source", "canonicalId": "seo.source" }, + { "alias": "scan", "canonicalId": "seo.scan_version" }, + { "alias": "scope", "canonicalId": "seo.scope_contract" }, + { "alias": "scope contract", "canonicalId": "seo.scope_contract" }, + { "alias": "page", "canonicalId": "seo.page" }, + { "alias": "страница", "canonicalId": "seo.page" }, + { "alias": "section", "canonicalId": "seo.section" }, + { "alias": "block", "canonicalId": "seo.section" }, + { "alias": "секция", "canonicalId": "seo.section" }, + { "alias": "блок", "canonicalId": "seo.section" }, + { "alias": "ProjectOntology", "canonicalId": "seo.project_ontology" }, + { "alias": "project ontology", "canonicalId": "seo.project_ontology" }, + { "alias": "semantic profile", "canonicalId": "seo.semantic_profile" }, + { "alias": "intent cluster", "canonicalId": "seo.intent_cluster" }, + { "alias": "demand universe", "canonicalId": "seo.demand_universe" }, + { "alias": "semantic anchor", "canonicalId": "seo.semantic_anchor" }, + { "alias": "смысловой якорь", "canonicalId": "seo.semantic_anchor" }, + { "alias": "anchor review", "canonicalId": "seo.anchor_review" }, + { "alias": "фиксация ключей", "canonicalId": "seo.anchor_review" }, + { "alias": "wordstat queue", "canonicalId": "seo.wordstat_queue" }, + { "alias": "очередь Wordstat", "canonicalId": "seo.wordstat_queue" }, + { "alias": "seed", "canonicalId": "seo.seed_query" }, + { "alias": "seed query", "canonicalId": "seo.seed_query" }, + { "alias": "keyword", "canonicalId": "seo.keyword" }, + { "alias": "ключ", "canonicalId": "seo.keyword" }, + { "alias": "ключевик", "canonicalId": "seo.keyword" }, + { "alias": "Wordstat signal", "canonicalId": "seo.market_signal" }, + { "alias": "market evidence", "canonicalId": "seo.market_signal" }, + { "alias": "keyword curation board", "canonicalId": "seo.keyword_curation_board" }, + { "alias": "карта распределения ключей", "canonicalId": "seo.keyword_curation_board" }, + { "alias": "keyword role", "canonicalId": "seo.keyword_role_decision" }, + { "alias": "роль ключа", "canonicalId": "seo.keyword_role_decision" }, + { "alias": "keyword map", "canonicalId": "seo.keyword_map" }, + { "alias": "landing section mapping", "canonicalId": "seo.landing_section_mapping" }, + { "alias": "маппинг посадочных", "canonicalId": "seo.landing_section_mapping" }, + { "alias": "future landing gap", "canonicalId": "seo.future_landing_gap" }, + { "alias": "будущая посадочная", "canonicalId": "seo.future_landing_gap" }, + { "alias": "optimization plan", "canonicalId": "seo.optimization_plan" }, + { "alias": "rewrite eligibility", "canonicalId": "seo.rewrite_eligibility" }, + { "alias": "semantic block", "canonicalId": "seo.semantic_block" }, + { "alias": "семантический блок", "canonicalId": "seo.semantic_block" }, + { "alias": "rewrite variant", "canonicalId": "seo.rewrite_variant" }, + { "alias": "validation result", "canonicalId": "seo.validation_result" }, + { "alias": "change set", "canonicalId": "seo.change_set" }, + { "alias": "changeset", "canonicalId": "seo.change_set" }, + { "alias": "apply result", "canonicalId": "seo.apply_result" }, + { "alias": "coverage expansion", "canonicalId": "seo.expansion_opportunity" } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/assistant-access-policy.json b/services/ontology-core/catalog/domain-packages/seo/assistant-access-policy.json new file mode 100644 index 0000000..33ed52c --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/assistant-access-policy.json @@ -0,0 +1,62 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "capabilities": [ + { "id": "assistant.seo_read", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can read SEO project context visible to the actor." }, + { "id": "assistant.seo_run_analysis", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request non-destructive SEO scan, ontology extraction, semantic analysis, and evidence collection in an authorized project." }, + { "id": "assistant.seo_plan_changes", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request anchor review, keyword curation, keyword map, optimization plan, rewrite variants, and validation previews." }, + { "id": "assistant.seo_apply_changes", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request an SEO changeset apply only through the SEO-owned adapter, confirmation, validation, and Gateway/app scopes." } + ], + "roleMatrix": [ + { + "roleId": "assistant_member", + "allowedCapabilities": [ + "assistant.seo_read", + "assistant.seo_run_analysis", + "assistant.seo_plan_changes", + "assistant.seo_apply_changes" + ], + "summary": "SEO actions are app-owned and remain bounded by SEO project permissions and Gateway scopes." + }, + { + "roleId": "assistant_admin", + "allowedCapabilities": [ + "assistant.seo_read", + "assistant.seo_run_analysis", + "assistant.seo_plan_changes", + "assistant.seo_apply_changes" + ], + "summary": "Assistant admin does not bypass SEO project permissions; it only has the same SEO capabilities plus existing admin capabilities." + } + ], + "operations": [ + { + "id": "assistant.op.seo_read_project", + "capabilityId": "assistant.seo_read", + "sourceEndpoints": ["GET /seo/projects/:projectId"], + "requiredGuards": ["requireSeoProjectRead"], + "summary": "Read SEO project context through SEO-owned API." + }, + { + "id": "assistant.op.seo_run_analysis", + "capabilityId": "assistant.seo_run_analysis", + "sourceEndpoints": ["POST /seo/projects/:projectId/scan", "POST /seo/projects/:projectId/analysis/semantic", "POST /seo/projects/:projectId/market/collect"], + "requiredGuards": ["requireSeoProjectWrite"], + "summary": "Run SEO scan and analysis through SEO-owned API." + }, + { + "id": "assistant.op.seo_plan_changes", + "capabilityId": "assistant.seo_plan_changes", + "sourceEndpoints": ["POST /seo/projects/:projectId/anchor-review", "POST /seo/projects/:projectId/keyword-map", "POST /seo/projects/:projectId/optimization-plan", "POST /seo/projects/:projectId/rewrite-variants"], + "requiredGuards": ["requireSeoProjectWrite"], + "summary": "Prepare anchor review, curated keyword map, SEO plan and rewrite variants without mutating target source." + }, + { + "id": "assistant.op.seo_apply_changes", + "capabilityId": "assistant.seo_apply_changes", + "sourceEndpoints": ["POST /seo/projects/:projectId/changesets/:changesetId/apply"], + "requiredGuards": ["requireSeoProjectApply", "requireSeoFinalValidation", "requireConfirmationToken", "requireIdempotencyKey"], + "summary": "Apply approved SEO changeset through SEO-owned adapter and safety gates." + } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/assistant-actions.json b/services/ontology-core/catalog/domain-packages/seo/assistant-actions.json new file mode 100644 index 0000000..62cb960 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/assistant-actions.json @@ -0,0 +1,223 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "apps": [ + { + "id": "seo", + "name": "NDC SEO mod", + "authorityEntityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.change_set"], + "adapterId": "adapter.seo.module_api", + "summary": "Owns SEO project source/crawl, project ontology extraction, semantic analysis, market evidence, planning, validation, changesets, and apply/export." + } + ], + "actions": [ + { + "id": "seo.project.read_context", + "app": "seo", + "domain": "projects", + "intentAliases": ["show seo project", "show site seo context", "покажи seo проект", "покажи сео проект", "покажи контекст сайта"], + "entityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.project_ontology"], + "capabilityIds": ["assistant.seo_read"], + "riskLevel": "read", + "confirmationMode": "none", + "selfActionPolicy": "allowed", + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Read SEO project context visible to the actor." + }, + { + "id": "seo.project.scan", + "app": "seo", + "domain": "scan", + "intentAliases": ["scan seo project", "scan site", "run seo scan", "просканируй сайт", "запусти seo scan", "запусти скан сайта"], + "entityIds": ["seo.project", "seo.source", "seo.scan_version"], + "capabilityIds": ["assistant.seo_run_analysis"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_run_scan"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Run SEO project scan and create a new scan_version artifact." + }, + { + "id": "seo.ontology.extract", + "app": "seo", + "domain": "ontology", + "intentAliases": ["extract project ontology", "build site ontology", "определи онтологию сайта", "собери онтологию проекта"], + "entityIds": ["seo.project", "seo.project_ontology", "seo.semantic_profile"], + "capabilityIds": ["assistant.seo_run_analysis"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_run_analysis"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Extract per-project site ontology from approved source/scope evidence." + }, + { + "id": "seo.semantic.analyze", + "app": "seo", + "domain": "semantic", + "intentAliases": ["run semantic analysis", "analyze seo semantics", "проанализируй семантику", "запусти semantic analysis"], + "entityIds": ["seo.semantic_profile", "seo.intent_cluster", "seo.demand_universe", "seo.semantic_anchor"], + "capabilityIds": ["assistant.seo_run_analysis"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_run_analysis"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Run semantic analysis bounded by ProjectOntologyInstance and scope contract." + }, + { + "id": "seo.anchor_review.approve", + "app": "seo", + "domain": "market", + "intentAliases": ["approve seo anchors", "approve wordstat anchors", "зафиксируй ключи", "подтверди якоря", "зафиксируй смысловые якоря"], + "entityIds": ["seo.semantic_anchor", "seo.anchor_review", "seo.wordstat_queue"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_collect_market_evidence"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Create or update the human-approved anchor review and Wordstat queue before provider collection." + }, + { + "id": "seo.market.collect_wordstat", + "app": "seo", + "domain": "market", + "intentAliases": ["collect wordstat", "run wordstat", "собери wordstat", "собери частотности", "запусти вордстат"], + "entityIds": ["seo.anchor_review", "seo.wordstat_queue", "seo.seed_query", "seo.market_signal", "seo.keyword"], + "capabilityIds": ["assistant.seo_run_analysis"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_collect_market_evidence"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Collect Wordstat market evidence only for the human-approved Wordstat queue." + }, + { + "id": "seo.keyword.curate", + "app": "seo", + "domain": "keywords", + "intentAliases": ["curate keywords", "sort keyword board", "распредели вес ключей", "разложи ключи", "отсортируй ключи"], + "entityIds": ["seo.keyword", "seo.keyword_curation_board", "seo.keyword_role_decision"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_plan_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Pre-sort external keyword candidates into unresolved, landing core, supporting core, differentiators, or long tail lanes for human review." + }, + { + "id": "seo.keyword.map", + "app": "seo", + "domain": "keywords", + "intentAliases": ["build keyword map", "map keywords", "собери keyword map", "распредели ключи", "составь карту ключей"], + "entityIds": ["seo.keyword", "seo.keyword_curation_board", "seo.keyword_role_decision", "seo.keyword_map", "seo.scope_item"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_plan_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Build keyword map from curated keyword role decisions and approved scope items." + }, + { + "id": "seo.mapping.approve", + "app": "seo", + "domain": "plan", + "intentAliases": ["approve landing mapping", "approve section mapping", "утверди посадочные", "утверди маппинг секций"], + "entityIds": ["seo.keyword_map", "seo.landing_section_mapping", "seo.future_landing_gap", "seo.scope_item"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_plan_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Approve keyword landing/section mapping and keep future landing gaps non-blocking until explicit promotion." + }, + { + "id": "seo.plan.build", + "app": "seo", + "domain": "plan", + "intentAliases": ["build optimization plan", "prepare seo plan", "собери план оптимизации", "подготовь seo план"], + "entityIds": ["seo.keyword_map", "seo.landing_section_mapping", "seo.future_landing_gap", "seo.optimization_plan", "seo.scope_contract"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_plan_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Build SEO optimization plan before rewrite." + }, + { + "id": "seo.rewrite.propose", + "app": "seo", + "domain": "rewrite", + "intentAliases": ["propose rewrite variants", "generate seo variants", "предложи варианты текста", "сгенерируй rewrite variants"], + "entityIds": ["seo.optimization_plan", "seo.rewrite_eligibility", "seo.semantic_block", "seo.rewrite_variant"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_plan_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Generate rewrite variants into working draft only." + }, + { + "id": "seo.validation.run", + "app": "seo", + "domain": "validation", + "intentAliases": ["run seo validation", "validate seo changes", "проверь seo изменения", "запусти финальную проверку"], + "entityIds": ["seo.validation_result", "seo.change_set", "seo.rewrite_variant"], + "capabilityIds": ["assistant.seo_plan_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_validate_changes"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Run final SEO validation before changeset apply/export." + }, + { + "id": "seo.change_set.preview", + "app": "seo", + "domain": "changesets", + "intentAliases": ["preview seo changeset", "show seo diff", "покажи seo diff", "покажи changeset"], + "entityIds": ["seo.change_set", "seo.validation_result"], + "capabilityIds": ["assistant.seo_read"], + "riskLevel": "read", + "confirmationMode": "none", + "selfActionPolicy": "allowed", + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Preview SEO changeset and validation state without applying changes." + }, + { + "id": "seo.change_set.apply", + "app": "seo", + "domain": "changesets", + "intentAliases": ["apply seo changes", "apply seo changeset", "примени seo изменения", "примени changeset"], + "entityIds": ["seo.change_set", "seo.validation_result", "seo.apply_result"], + "capabilityIds": ["assistant.seo_apply_changes"], + "riskLevel": "write", + "confirmationMode": "explicit", + "selfActionPolicy": "allowed", + "requiredScopes": ["seo.project.can_apply_change_set"], + "adapterId": "adapter.seo.module_api", + "adapterStatus": "future", + "summary": "Apply an approved SEO changeset through SEO-owned adapter after validation, backup, idempotency, and explicit confirmation." + } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/context-bindings.json b/services/ontology-core/catalog/domain-packages/seo/context-bindings.json new file mode 100644 index 0000000..8dafb37 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/context-bindings.json @@ -0,0 +1,35 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-06-28", + "contexts": [], + "bindingTypes": [ + { + "id": "binding_type.seo_project_to_ops_context", + "relationId": "ontology.resolves_seo_project_to_ops_context", + "fromEntityIds": ["seo.project"], + "toEntityIds": ["ops.project", "ops.card"], + "requiredFromRefs": ["seo_project_id"], + "requiredToRefs": ["workspace_slug", "project_id"], + "summary": "Binds an SEO project to its OPS Product project/card context." + }, + { + "id": "binding_type.ops_context_to_seo_project", + "relationId": "ontology.resolves_ops_context_to_seo_project", + "fromEntityIds": ["ops.project", "ops.card"], + "toEntityIds": ["seo.project"], + "requiredFromRefs": ["workspace_slug", "project_id"], + "requiredToRefs": ["seo_project_id"], + "summary": "Binds OPS Product context to an SEO project." + }, + { + "id": "binding_type.hub_app_to_seo_project", + "relationId": "ontology.resolves_hub_app_to_seo_project", + "fromEntityIds": ["hub.application"], + "toEntityIds": ["seo.project"], + "requiredFromRefs": ["application_id"], + "requiredToRefs": ["seo_project_id"], + "summary": "Binds a HUB/Launcher app entry to SEO module project context." + } + ], + "bindings": [] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/entities.json b/services/ontology-core/catalog/domain-packages/seo/entities.json new file mode 100644 index 0000000..a6f2ea0 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/entities.json @@ -0,0 +1,38 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "entities": [ + { "id": "seo.project", "name": "SEO Project", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "SEO module project boundary for one connected site/source and its analysis history." }, + { "id": "seo.site", "name": "SEO Site", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Analyzed website as seen through source import and/or live crawl." }, + { "id": "seo.source", "name": "SEO Source", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Input source: local folder, uploaded package, browser-folder import, repository, or live URL crawl." }, + { "id": "seo.scan_version", "name": "SEO Scan Version", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Immutable scan boundary for project index, pages, media, and derived scope." }, + { "id": "seo.scope_contract", "name": "SEO Scope Contract", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "User-approved working scope consumed consistently by analysis, planning, rewrite, validation, and apply." }, + { "id": "seo.scope_item", "name": "SEO Scope Item", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "One selectable work zone: URL page, generated section, template block, admin page, technical page, content source, or hidden section." }, + { "id": "seo.page", "name": "SEO Page", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Logical page or route-level SEO target." }, + { "id": "seo.section", "name": "SEO Section", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Stable section/block identity under a page or template, not a fragile DOM selector." }, + { "id": "seo.media_asset", "name": "SEO Media Asset", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Image, video, poster, picture/srcset source, CSS background, or other media target with SEO metadata." }, + { "id": "seo.project_ontology", "name": "SEO Project Ontology Instance", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + Ontology Core", "summary": "Versioned per-project site ontology extracted from evidence and aligned with Ontology Core." }, + { "id": "seo.semantic_profile", "name": "SEO Semantic Profile", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Structured meaning, tone, entities, intents, gaps, and constraints extracted from selected scope." }, + { "id": "seo.intent_cluster", "name": "SEO Intent Cluster", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Search/user intent cluster grounded in project ontology and site evidence." }, + { "id": "seo.demand_universe", "name": "SEO Demand Universe", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Broad pre-Wordstat meaning and query hypothesis set derived from project context before external evidence collection." }, + { "id": "seo.semantic_anchor", "name": "SEO Semantic Anchor", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Human-reviewable meaning anchor extracted from project context and used as the only allowed route into Wordstat/SERP queues." }, + { "id": "seo.anchor_review", "name": "SEO Anchor Review", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human approval gate that decides which semantic anchors may be sent to external market evidence providers." }, + { "id": "seo.wordstat_queue", "name": "SEO Wordstat Queue", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Explicit queue of human-approved anchor phrases sent to Wordstat evidence collection." }, + { "id": "seo.seed_query", "name": "SEO Seed Query", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Candidate phrase sent to market evidence providers after user/model review." }, + { "id": "seo.keyword", "name": "SEO Keyword", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Cleaned and classified search phrase with project-level decision state." }, + { "id": "seo.market_signal", "name": "SEO Market Signal", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + external providers", "summary": "Wordstat, SERP, Webmaster, Metrica, or model evidence attached to project-level decisions." }, + { "id": "seo.keyword_curation_board", "name": "SEO Keyword Curation Board", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human-in-the-loop board where external keyword candidates are sorted into SEO roles before strategy." }, + { "id": "seo.keyword_role_decision", "name": "SEO Keyword Role Decision", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "User/model-assisted role assignment for a keyword: unresolved, landing core, supporting core, differentiator, or long tail." }, + { "id": "seo.keyword_map", "name": "SEO Keyword Map", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Approved mapping from keywords to pages/sections with roles and limits." }, + { "id": "seo.landing_section_mapping", "name": "SEO Landing Section Mapping", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human-approved mapping from curated keyword groups to existing pages, sections, or future landing gaps." }, + { "id": "seo.future_landing_gap", "name": "SEO Future Landing Gap", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Non-blocking strategy gap for a potential future landing that must not be materialized or rewritten without explicit promotion." }, + { "id": "seo.optimization_plan", "name": "SEO Optimization Plan", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Approved plan of text/meta/media/schema tasks before rewrite." }, + { "id": "seo.rewrite_eligibility", "name": "SEO Rewrite Eligibility", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Gate proving that a rewrite target is an approved existing page/section/content-field binding rather than a future gap." }, + { "id": "seo.semantic_block", "name": "SEO Semantic Block", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Persisted semantic grouping of page content targets used for block-level rewrite proposals." }, + { "id": "seo.rewrite_variant", "name": "SEO Rewrite Variant", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Generated optimization variant over working text, not a direct source mutation." }, + { "id": "seo.validation_result", "name": "SEO Validation Result", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Final validation output: deterministic checks, over-SEO, language quality, media checks, and hard/soft issues." }, + { "id": "seo.change_set", "name": "SEO Change Set", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Explicit diff/apply unit with dry-run, backup, rollback, and export boundaries." }, + { "id": "seo.apply_result", "name": "SEO Apply Result", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Result of app-owned apply/export action after confirmation and validation." }, + { "id": "seo.expansion_opportunity", "name": "SEO Expansion Opportunity", "surface": "seo", "status": ["future-concept"], "authority": "NDC SEO mod", "summary": "Optional future coverage/growth suggestion after core analysis and market evidence, not part of the current ontology slice." } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/evidence.json b/services/ontology-core/catalog/domain-packages/seo/evidence.json new file mode 100644 index 0000000..fab4e81 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/evidence.json @@ -0,0 +1,39 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "sourceRoots": [ + { + "surface": "seo", + "path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SEO/seo_mode/seo_mode", + "mode": "read-only architecture/source inspection" + } + ], + "ledgers": [ + { + "id": "ledger.seo_domain_v0", + "path": "docs/SEO_DOMAIN_ONTOLOGY.md", + "entityIds": [ + "seo.project", + "seo.scope_contract", + "seo.project_ontology", + "seo.semantic_profile", + "seo.semantic_anchor", + "seo.anchor_review", + "seo.wordstat_queue", + "seo.market_signal", + "seo.keyword_curation_board", + "seo.landing_section_mapping", + "seo.future_landing_gap", + "seo.change_set" + ] + } + ], + "baselineDocs": [ + "docs/SEO_DOMAIN_ONTOLOGY.md" + ], + "restrictions": [ + "Do not promote per-project SEO keywords or market rows into global ontology truth.", + "Do not let Ontology Core execute SEO source mutations.", + "Do not implement optional business expansion before core SEO pipeline evidence is proven." + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/guardrails.json b/services/ontology-core/catalog/domain-packages/seo/guardrails.json new file mode 100644 index 0000000..3ca7205 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/guardrails.json @@ -0,0 +1,77 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "rules": [ + { + "id": "guardrail.seo.project_ontology_not_global_truth", + "severity": "error", + "summary": "Per-project SEO ontology is versioned project evidence; it must not be silently promoted into global Ontology Core truth.", + "entityIds": ["seo.project_ontology", "future.domain_package"] + }, + { + "id": "guardrail.seo.keywords_are_project_evidence", + "severity": "error", + "summary": "Keywords, Wordstat rows, SERP rows, and forecasts are project evidence, not reusable ontology roots.", + "entityIds": ["seo.keyword", "seo.market_signal", "seo.project_ontology"] + }, + { + "id": "guardrail.seo.scope_contract_required", + "severity": "error", + "summary": "Semantic analysis, keyword map, rewrite, validation, and changeset must be bounded by an approved scope contract.", + "entityIds": ["seo.scope_contract", "seo.scope_item", "seo.semantic_profile", "seo.change_set"] + }, + { + "id": "guardrail.seo.wordstat_requires_anchor_review", + "severity": "error", + "summary": "Wordstat/SERP collection must use only human-approved semantic anchors from anchor review, never raw site text or model auto proposals.", + "entityIds": ["seo.semantic_anchor", "seo.anchor_review", "seo.wordstat_queue", "seo.market_signal"] + }, + { + "id": "guardrail.seo.strategy_requires_curated_keywords", + "severity": "error", + "summary": "Strategy and optimization plan must consume curated keyword role decisions, not raw provider rows or unreviewed normalization output.", + "entityIds": ["seo.keyword_curation_board", "seo.keyword_role_decision", "seo.keyword_map", "seo.optimization_plan"] + }, + { + "id": "guardrail.seo.future_landing_gap_non_blocking", + "severity": "error", + "summary": "Future landing gaps are strategy context only; they must not force materialization, rewrite, or random existing page selection without explicit promotion.", + "entityIds": ["seo.future_landing_gap", "seo.landing_section_mapping", "seo.rewrite_eligibility", "seo.rewrite_variant"] + }, + { + "id": "guardrail.seo.rewrite_requires_existing_binding", + "severity": "error", + "summary": "Rewrite variants require approved existing page/section/content-field bindings and must not be generated for unresolved future gaps.", + "entityIds": ["seo.rewrite_eligibility", "seo.semantic_block", "seo.section", "seo.rewrite_variant"] + }, + { + "id": "guardrail.seo.apply_requires_validation", + "severity": "error", + "summary": "SEO apply/export requires explicit changeset, final validation, dry-run/backup boundary, and app-owned adapter execution.", + "entityIds": ["seo.change_set", "seo.validation_result", "seo.apply_result", "assistant.action"] + }, + { + "id": "guardrail.seo.ontology_core_does_not_apply", + "severity": "error", + "summary": "Ontology Core may resolve SEO action context but must not mutate site sources or execute SEO apply.", + "entityIds": ["seo.change_set", "assistant.action", "agent.scope", "agent.audit_event"] + }, + { + "id": "guardrail.seo.expansion_is_future_optional_branch", + "severity": "warning", + "summary": "Expansion opportunities are a future optional branch after core analysis and market evidence; they must not replace current site context by default.", + "entityIds": ["seo.expansion_opportunity", "seo.market_signal", "seo.optimization_plan"] + } + ], + "blockedConflations": [ + ["seo.project_ontology", "future.domain_package"], + ["seo.keyword", "future.domain_package"], + ["seo.semantic_anchor", "seo.market_signal"], + ["seo.anchor_review", "seo.market_signal"], + ["seo.market_signal", "seo.intent_cluster"], + ["seo.keyword_curation_board", "seo.keyword_map"], + ["seo.future_landing_gap", "seo.page"], + ["seo.expansion_opportunity", "seo.optimization_plan"], + ["seo.change_set", "assistant.action"] + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/package.json b/services/ontology-core/catalog/domain-packages/seo/package.json new file mode 100644 index 0000000..dff132c --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/package.json @@ -0,0 +1,7 @@ +{ + "id": "seo", + "version": "0.1.0", + "updatedAt": "2026-06-28", + "status": "product-required/pending-source-pass", + "summary": "SEO module domain ontology package for site scans, project ontology, scope contracts, semantic analysis, market evidence, optimization plans, validation, and changesets." +} diff --git a/services/ontology-core/catalog/domain-packages/seo/project-ontology.schema.json b/services/ontology-core/catalog/domain-packages/seo/project-ontology.schema.json new file mode 100644 index 0000000..0b81e95 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/project-ontology.schema.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nodedc.local/ontology/seo/project-ontology.schema.json", + "title": "SEO Project Ontology Instance", + "type": "object", + "required": [ + "schemaVersion", + "projectId", + "siteId", + "coreOntologyVersion", + "seoDomainPackageVersion", + "source", + "scope", + "brand", + "pages", + "sections", + "intentClusters", + "tone", + "constraints", + "evidence", + "approvals" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "seo-project-ontology.v1" }, + "projectId": { "type": "string", "minLength": 1 }, + "siteId": { "type": "string", "minLength": 1 }, + "coreOntologyVersion": { "type": "string", "minLength": 1 }, + "seoDomainPackageVersion": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "required": ["sourceType", "scanVersionId"], + "additionalProperties": false, + "properties": { + "sourceType": { + "type": "string", + "enum": ["local_folder", "browser_folder", "uploaded_package", "repository", "live_crawl"] + }, + "scanVersionId": { "type": "string", "minLength": 1 }, + "rootUrl": { "type": ["string", "null"] }, + "sourcePath": { "type": ["string", "null"] } + } + }, + "scope": { + "type": "object", + "required": ["scopeContractId", "scopeItemIds"], + "additionalProperties": false, + "properties": { + "scopeContractId": { "type": "string", "minLength": 1 }, + "scopeItemIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "brand": { + "type": "object", + "required": ["name", "aliases", "description"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "aliases": { "type": "array", "items": { "type": "string" } }, + "description": { "type": "string" } + } + }, + "offers": { + "type": "array", + "items": { "$ref": "#/$defs/namedEvidenceItem" } + }, + "audiences": { + "type": "array", + "items": { "$ref": "#/$defs/namedEvidenceItem" } + }, + "pages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "url", "title", "evidenceIds"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "url": { "type": "string" }, + "title": { "type": "string" }, + "evidenceIds": { "$ref": "#/$defs/evidenceIds" } + } + } + }, + "sections": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "pageId", "title", "meaning", "evidenceIds"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "pageId": { "type": "string", "minLength": 1 }, + "title": { "type": "string" }, + "meaning": { "type": "string" }, + "evidenceIds": { "$ref": "#/$defs/evidenceIds" } + } + } + }, + "intentClusters": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "priority", "targetIntent", "seedQueries", "evidenceIds"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "priority": { "type": "string", "enum": ["high", "medium", "low"] }, + "targetIntent": { "type": "string" }, + "seedQueries": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "evidenceIds": { "$ref": "#/$defs/evidenceIds" } + } + } + }, + "tone": { + "type": "object", + "required": ["language", "signals", "forbiddenShifts"], + "additionalProperties": false, + "properties": { + "language": { "type": "string" }, + "signals": { "type": "array", "items": { "type": "string" } }, + "forbiddenShifts": { "type": "array", "items": { "type": "string" } } + } + }, + "constraints": { + "type": "object", + "required": ["forbiddenTopics", "forbiddenClaims", "mustKeep"], + "additionalProperties": false, + "properties": { + "forbiddenTopics": { "type": "array", "items": { "type": "string" } }, + "forbiddenClaims": { "type": "array", "items": { "type": "string" } }, + "mustKeep": { "type": "array", "items": { "type": "string" } } + } + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "sourceType", "ref", "summary"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "sourceType": { "type": "string" }, + "ref": { "type": "string" }, + "summary": { "type": "string" } + } + } + }, + "approvals": { + "type": "object", + "required": ["status", "approvedBy", "approvedAt"], + "additionalProperties": false, + "properties": { + "status": { "type": "string", "enum": ["draft", "approved", "rejected", "needs_review"] }, + "approvedBy": { "type": ["string", "null"] }, + "approvedAt": { "type": ["string", "null"] } + } + }, + "ontologyDeltaProposals": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "target", "summary", "status"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "enum": ["core", "seo_domain", "project_only"] }, + "summary": { "type": "string" }, + "status": { "type": "string", "enum": ["proposed", "accepted", "rejected", "deferred"] } + } + } + } + }, + "$defs": { + "evidenceIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "namedEvidenceItem": { + "type": "object", + "required": ["id", "name", "summary", "evidenceIds"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "summary": { "type": "string" }, + "evidenceIds": { "$ref": "#/$defs/evidenceIds" } + } + } + } +} diff --git a/services/ontology-core/catalog/domain-packages/seo/relations.json b/services/ontology-core/catalog/domain-packages/seo/relations.json new file mode 100644 index 0000000..a5e72ed --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/relations.json @@ -0,0 +1,47 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-02", + "relations": [ + { "id": "seo.project.has_site", "from": ["seo.project"], "to": ["seo.site"], "status": "product-required", "summary": "SEO project analyzes one or more site contexts." }, + { "id": "seo.project.uses_source", "from": ["seo.project"], "to": ["seo.source"], "status": "product-required", "summary": "SEO project is connected to import/crawl source." }, + { "id": "seo.project.has_project_ontology", "from": ["seo.project"], "to": ["seo.project_ontology"], "status": "product-required", "summary": "Per-project ontology profile belongs to SEO project." }, + { "id": "seo.source.produces_scan_version", "from": ["seo.source"], "to": ["seo.scan_version"], "status": "product-required", "summary": "Source import/crawl creates immutable scan version." }, + { "id": "seo.scan_version.produces_scope_contract", "from": ["seo.scan_version"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Scan and user configuration produce approved scope contract." }, + { "id": "seo.scope_contract.contains_scope_item", "from": ["seo.scope_contract"], "to": ["seo.scope_item"], "status": "product-required", "summary": "Scope contract is composed of selected work zones." }, + { "id": "seo.scope_item.targets_page", "from": ["seo.scope_item"], "to": ["seo.page"], "status": "product-required", "summary": "Scope item can target a logical page." }, + { "id": "seo.scope_item.targets_section", "from": ["seo.scope_item"], "to": ["seo.section"], "status": "product-required", "summary": "Scope item can target a stable section/block." }, + { "id": "seo.site.has_page", "from": ["seo.site"], "to": ["seo.page"], "status": "product-required", "summary": "Site contains logical pages/routes." }, + { "id": "seo.page.has_section", "from": ["seo.page"], "to": ["seo.section"], "status": "product-required", "summary": "Page can contain stable sections/blocks." }, + { "id": "seo.section.contains_media", "from": ["seo.section"], "to": ["seo.media_asset"], "status": "product-required", "summary": "Media is attached to a section when context can be resolved." }, + { "id": "seo.semantic_profile.uses_project_ontology", "from": ["seo.semantic_profile"], "to": ["seo.project_ontology"], "status": "product-required", "summary": "Semantic analysis is grounded in per-project ontology." }, + { "id": "seo.semantic_profile.describes_scope_contract", "from": ["seo.semantic_profile"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Semantic profile describes selected work scope, not arbitrary site fantasy." }, + { "id": "seo.semantic_profile.produces_demand_universe", "from": ["seo.semantic_profile"], "to": ["seo.demand_universe"], "status": "product-required", "summary": "Selected project context produces a broad demand universe before provider calls." }, + { "id": "seo.demand_universe.contains_semantic_anchor", "from": ["seo.demand_universe"], "to": ["seo.semantic_anchor"], "status": "product-required", "summary": "Demand universe contains semantic anchors that can be reviewed for market routing." }, + { "id": "seo.semantic_anchor.requires_anchor_review", "from": ["seo.semantic_anchor"], "to": ["seo.anchor_review"], "status": "product-required", "summary": "A semantic anchor cannot enter Wordstat/SERP routing before human anchor review." }, + { "id": "seo.anchor_review.approves_wordstat_queue", "from": ["seo.anchor_review"], "to": ["seo.wordstat_queue"], "status": "product-required", "summary": "Anchor review creates the explicit Wordstat queue from approved anchors." }, + { "id": "seo.intent_cluster.suggests_seed_query", "from": ["seo.intent_cluster"], "to": ["seo.seed_query"], "status": "product-required", "summary": "Intent cluster can suggest seed queries for evidence collection." }, + { "id": "seo.wordstat_queue.contains_seed_query", "from": ["seo.wordstat_queue"], "to": ["seo.seed_query"], "status": "product-required", "summary": "Only human-approved Wordstat queue items become executable seed queries." }, + { "id": "seo.wordstat_queue.produces_market_signal", "from": ["seo.wordstat_queue"], "to": ["seo.market_signal"], "status": "product-required", "summary": "Wordstat collection stores provider results as market signals tied to the approved queue." }, + { "id": "seo.seed_query.expands_to_keyword", "from": ["seo.seed_query"], "to": ["seo.keyword"], "status": "product-required", "summary": "Approved seed query can produce cleaned keyword candidates." }, + { "id": "seo.keyword.validated_by_market_signal", "from": ["seo.keyword"], "to": ["seo.market_signal"], "status": "product-required", "summary": "Keyword decisions require market evidence when available." }, + { "id": "seo.keyword_curation_board.classifies_keyword", "from": ["seo.keyword_curation_board"], "to": ["seo.keyword_role_decision", "seo.keyword"], "status": "product-required", "summary": "Keyword curation board assigns human-reviewed roles to keyword candidates." }, + { "id": "seo.keyword_role_decision.produces_keyword_map", "from": ["seo.keyword_role_decision"], "to": ["seo.keyword_map"], "status": "product-required", "summary": "Only curated keyword role decisions can feed the keyword map used by strategy." }, + { "id": "seo.keyword_map.assigns_keyword", "from": ["seo.keyword_map"], "to": ["seo.keyword", "seo.scope_item"], "status": "product-required", "summary": "Keyword map assigns keywords to approved scope items." }, + { "id": "seo.keyword_map.requires_landing_section_mapping", "from": ["seo.keyword_map"], "to": ["seo.landing_section_mapping"], "status": "product-required", "summary": "Keyword map must be resolved to approved existing pages/sections or explicit future gaps before rewrite." }, + { "id": "seo.landing_section_mapping.maps_keyword_to_scope_item", "from": ["seo.landing_section_mapping"], "to": ["seo.keyword", "seo.scope_item"], "status": "product-required", "summary": "Landing/section mapping binds curated keywords to existing approved scope items." }, + { "id": "seo.landing_section_mapping.identifies_future_gap", "from": ["seo.landing_section_mapping"], "to": ["seo.future_landing_gap"], "status": "product-required", "summary": "A mapping can explicitly mark a keyword group as a non-blocking future landing gap." }, + { "id": "seo.optimization_plan.uses_keyword_map", "from": ["seo.optimization_plan"], "to": ["seo.keyword_map"], "status": "product-required", "summary": "Optimization plan is based on approved keyword map." }, + { "id": "seo.optimization_plan.targets_scope_contract", "from": ["seo.optimization_plan"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Optimization plan targets approved scope contract." }, + { "id": "seo.rewrite_eligibility.requires_landing_section_mapping", "from": ["seo.rewrite_eligibility"], "to": ["seo.landing_section_mapping"], "status": "product-required", "summary": "Rewrite eligibility requires an approved mapping to an existing source-backed target." }, + { "id": "seo.semantic_block.groups_section", "from": ["seo.semantic_block"], "to": ["seo.section", "seo.scope_item"], "status": "product-required", "summary": "Semantic blocks group source-backed sections/scope items for block-level rewrite proposals." }, + { "id": "seo.rewrite_variant.requires_rewrite_eligibility", "from": ["seo.rewrite_variant"], "to": ["seo.rewrite_eligibility", "seo.semantic_block"], "status": "product-required", "summary": "Rewrite variants can be proposed only for eligible existing bindings, optionally grouped by semantic block." }, + { "id": "seo.rewrite_variant.generated_from_plan", "from": ["seo.rewrite_variant"], "to": ["seo.optimization_plan"], "status": "product-required", "summary": "Rewrite variant is generated from approved optimization plan." }, + { "id": "seo.validation_result.validates_change_set", "from": ["seo.validation_result"], "to": ["seo.change_set"], "status": "product-required", "summary": "Change set must be validated before apply." }, + { "id": "seo.change_set.applies_to_scope_contract", "from": ["seo.change_set"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Change set is bounded by approved scope contract." }, + { "id": "seo.change_set.produces_apply_result", "from": ["seo.change_set"], "to": ["seo.apply_result"], "status": "product-required", "summary": "Apply/export produces a traceable result." }, + { "id": "seo.expansion_opportunity.requires_market_signal", "from": ["seo.expansion_opportunity"], "to": ["seo.market_signal"], "status": "future-concept", "summary": "Future expansion proposals must be grounded in market evidence." }, + { "id": "ontology.resolves_seo_project_to_ops_context", "from": ["seo.project"], "to": ["ops.project", "ops.card"], "status": "product-required", "summary": "Assistant can route SEO project work to the correct OPS Product context." }, + { "id": "ontology.resolves_ops_context_to_seo_project", "from": ["ops.project", "ops.card"], "to": ["seo.project"], "status": "product-required", "summary": "Assistant can route OPS Product context into SEO module project context when bound." }, + { "id": "ontology.resolves_hub_app_to_seo_project", "from": ["hub.application"], "to": ["seo.project"], "status": "product-required", "summary": "Launcher/HUB application context can suggest SEO module project context." } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/seo/resolver-rules.json b/services/ontology-core/catalog/domain-packages/seo/resolver-rules.json new file mode 100644 index 0000000..548c349 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/seo/resolver-rules.json @@ -0,0 +1,42 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-06-28", + "rules": [ + { + "id": "resolver.seo_to_ops.project_context", + "relationId": "ontology.resolves_seo_project_to_ops_context", + "fromSurface": "seo", + "inputEntityIds": ["seo.project", "seo.site", "seo.scope_contract"], + "intentHints": ["ops", "card", "task", "issue", "project", "report", "карточ", "задач", "отчет"], + "outputSurface": "ops", + "outputEntityIds": ["ops.workspace", "ops.project", "ops.card", "ops.card_type"], + "requiredBindings": ["seo.project -> ops.project|ops.card context"], + "status": "product-required", + "summary": "Route SEO module project context into OPS Product work context." + }, + { + "id": "resolver.ops_to_seo.project_context", + "relationId": "ontology.resolves_ops_context_to_seo_project", + "fromSurface": "ops", + "inputEntityIds": ["ops.project", "ops.card"], + "intentHints": ["seo", "site", "semantic", "wordstat", "keyword", "rewrite", "validation", "сео", "сайт", "семантик"], + "outputSurface": "seo", + "outputEntityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.project_ontology"], + "requiredBindings": ["ops.project|ops.card -> seo.project context"], + "status": "product-required", + "summary": "Route OPS Product context into SEO module project context." + }, + { + "id": "resolver.hub_app_to_seo.project_context", + "relationId": "ontology.resolves_hub_app_to_seo_project", + "fromSurface": "hub", + "inputEntityIds": ["hub.application"], + "intentHints": ["seo", "site", "project", "audit", "сео", "сайт", "аудит"], + "outputSurface": "seo", + "outputEntityIds": ["seo.project", "seo.site"], + "requiredBindings": ["hub.application -> seo.project context"], + "status": "product-required", + "summary": "Route Launcher app context into SEO module project context." + } + ] +} diff --git a/services/ontology-core/docs/PROJECT_ONTOLOGY_INSTANCE_SCHEMA.md b/services/ontology-core/docs/PROJECT_ONTOLOGY_INSTANCE_SCHEMA.md new file mode 100644 index 0000000..b5f8138 --- /dev/null +++ b/services/ontology-core/docs/PROJECT_ONTOLOGY_INSTANCE_SCHEMA.md @@ -0,0 +1,60 @@ +# Project Ontology Instance Schema + +Status: first contract +Date: 2026-06-28 +Owner: NDC SEO mod + +## Intent + +`ProjectOntologyInstance` is the per-project/site ontology profile consumed by NDC SEO mod. +It is not the global ontology. + +It gives the model enough grounded context to analyze and rewrite the current site without +injecting unrelated offers, markets, or product claims. + +## Required Guarantees + +- Every extracted entity, intent, tone rule, and constraint must have evidence or explicit user approval. +- Unknown entities are allowed but must stay local to the project until promoted. +- Market evidence is linked as project evidence, not ontology truth. +- Rewrite and apply stages must only consume approved scope items. +- Optional expansion proposals are not part of the default optimization path. + +## JSON Fields + +Top level: + +- `schemaVersion` +- `projectId` +- `siteId` +- `coreOntologyVersion` +- `seoDomainPackageVersion` +- `source` +- `scope` +- `brand` +- `offers` +- `audiences` +- `pages` +- `sections` +- `intentClusters` +- `tone` +- `constraints` +- `evidence` +- `approvals` +- `ontologyDeltaProposals` + +## Lifecycle + +1. Source import or live crawl creates `scan_version`. +2. User-selected graph/scope creates `scope_contract`. +3. Extractor creates a draft `ProjectOntologyInstance`. +4. Dev Mode shows evidence, confidence, and unresolved questions. +5. User/model approvals produce an approved version. +6. Semantic analysis consumes approved ontology plus scope contract. +7. Market evidence, keyword map, plan, rewrite, validation, and changeset link back to ontology version. + +## NODE.DC Seed + +The NODE.DC project ontology seed should be kept as a fixture and regression target. +It must not stay embedded inside generic semantic analysis code. + diff --git a/services/ontology-core/docs/SEO_DOMAIN_ONTOLOGY.md b/services/ontology-core/docs/SEO_DOMAIN_ONTOLOGY.md new file mode 100644 index 0000000..13a7070 --- /dev/null +++ b/services/ontology-core/docs/SEO_DOMAIN_ONTOLOGY.md @@ -0,0 +1,202 @@ +# SEO Domain Ontology + +Status: Stage 5 architecture slice +Date: 2026-07-02 +Package: `catalog/domain-packages/seo` + +## Purpose + +SEO Domain Ontology defines the reusable platform vocabulary for NDC SEO mod. +It must not replace per-project evidence and must not execute SEO mutations. + +The package exists so the SEO module can participate in NodeDC-wide assistant routing +without hardcoding NODE.DC-specific product clusters into the generic SEO engine. + +## Ownership Boundaries + +Ontology Core owns: + +- canonical SEO entity/action names; +- aliases; +- relations; +- guardrails; +- assistant action cards; +- resolver rules and binding types. + +NDC SEO mod owns: + +- project source import and live crawl; +- scan versions; +- project ontology extraction; +- semantic analysis; +- Wordstat/SERP/Webmaster/Metrica evidence collection; +- keyword decisions; +- optimization plans; +- rewrite variants; +- final validation; +- changeset/apply/export execution. + +OPS Gateway and app-owned adapters own: + +- permission enforcement; +- scopes/grants; +- confirmation; +- idempotency; +- audit. + +## Three-Layer Model + +`CoreOntologySnapshot`: + +- stable platform meanings from Ontology Core; +- HUB, OPS Product, OPS Gateway, ENGINE, Assistant, and Future Interface Layer concepts; +- SEO package-level concepts after validation. + +`SeoDomainOntology`: + +- reusable `seo.*` entity/action/guardrail catalog; +- not tied to one site; +- ships with Ontology Core as a domain package. + +`ProjectOntologyInstance`: + +- versioned project/site evidence; +- extracted from source/crawl/content and user approvals; +- contains brand, offers, audiences, pages, sections, tone, constraints, entities, intents, and evidence refs; +- can propose ontology deltas but cannot silently mutate Ontology Core. + +## Current SEO Entities + +The first package slice defines: + +- `seo.project` +- `seo.site` +- `seo.source` +- `seo.scan_version` +- `seo.scope_contract` +- `seo.scope_item` +- `seo.page` +- `seo.section` +- `seo.media_asset` +- `seo.project_ontology` +- `seo.semantic_profile` +- `seo.intent_cluster` +- `seo.demand_universe` +- `seo.semantic_anchor` +- `seo.anchor_review` +- `seo.wordstat_queue` +- `seo.seed_query` +- `seo.keyword` +- `seo.market_signal` +- `seo.keyword_curation_board` +- `seo.keyword_role_decision` +- `seo.keyword_map` +- `seo.landing_section_mapping` +- `seo.future_landing_gap` +- `seo.optimization_plan` +- `seo.rewrite_eligibility` +- `seo.semantic_block` +- `seo.rewrite_variant` +- `seo.validation_result` +- `seo.change_set` +- `seo.apply_result` +- `seo.expansion_opportunity` + +`seo.expansion_opportunity` is deliberately marked `future-concept`. +It exists only to reserve the boundary for a later optional business/coverage branch. + +## Assistant Actions + +SEO assistant actions are registered as declarative future app-owned actions: + +- `seo.project.read_context` +- `seo.project.scan` +- `seo.ontology.extract` +- `seo.semantic.analyze` +- `seo.anchor_review.approve` +- `seo.market.collect_wordstat` +- `seo.keyword.curate` +- `seo.keyword.map` +- `seo.mapping.approve` +- `seo.plan.build` +- `seo.rewrite.propose` +- `seo.validation.run` +- `seo.change_set.preview` +- `seo.change_set.apply` + +All actions route to `adapter.seo.module_api` and currently use `adapterStatus: future`. +This is intentional: Ontology Core can resolve and classify intent before the SEO adapter exists, +but it must not pretend that live execution is ready. + +## Guardrails + +Hard rules: + +- per-project SEO ontology is evidence, not global truth; +- keywords, Wordstat rows, SERP rows, and forecasts stay project evidence; +- semantic analysis, keyword map, rewrite, validation, and changeset must be bounded by scope contract; +- Wordstat/SERP collection must use only human-approved anchors from `seo.anchor_review`, never raw site text or automatic model proposals; +- strategy and optimization plan must consume curated keyword role decisions, not raw provider rows; +- future landing gaps are non-blocking strategy context and must not force materialization, rewrite, or random existing page selection; +- rewrite variants require approved existing page/section/content-field bindings and may be grouped by persisted semantic blocks; +- apply requires changeset, validation, dry-run/backup boundary, confirmation, and app-owned adapter; +- Ontology Core does not execute SEO source mutations. + +## Stage 5 Keyword Curation + +The model must pre-sort keyword candidates before strategy. The board has exactly five lanes: + +- unresolved / `Неразобранные`; +- landing core / `Посадочное ядро`; +- supporting core / `Поддерживающее ядро`; +- differentiators / `Уточняющие / УТП`; +- long tail / `Длинный хвост`. + +The user can move cards between lanes. Final strategy input includes only landing core, +supporting core, differentiators, and long tail. Unresolved keywords do not participate in +strategy, mapping, rewrite, or materialization. + +Optional future rule: + +- expansion opportunities are a separate future branch after core SEO results and market evidence. + +## Current Runtime Gap + +The SEO module has moved NODE.DC-specific clusters behind an explicit project ontology seed fixture. +Generic extraction must not inject NODE.DC aliases, `/modules/ops`, or AI-platform fixture phrases. +This is covered by: + +```text +/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SEO/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts +``` + +The remaining gap is execution routing: Ontology Core declares SEO actions and guardrails, but the live +`adapter.seo.module_api` is still marked `future`. Assistant/MCP execution must follow the SEO-owned API +contract before it can simulate the full route: + +```text +semantic profile -> demand universe -> anchor review -> Wordstat queue -> market signal -> keyword curation -> landing/section mapping -> strategy/rewrite eligibility +``` + +## Package Loading + +Ontology Core now loads domain packages from: + +```text +catalog/domain-packages/* +``` + +Each package may contribute: + +- `entities.json` +- `relations.json` +- `aliases.json` +- `guardrails.json` +- `evidence.json` +- `resolver-rules.json` +- `context-bindings.json` +- `assistant-access-policy.json` +- `assistant-actions.json` +- `assistant-risk-policy.json` + +The loader merges package catalogs into the base catalog before validation. diff --git a/services/ontology-core/examples/seo-project-ontology.nodedc.example.json b/services/ontology-core/examples/seo-project-ontology.nodedc.example.json new file mode 100644 index 0000000..a540576 --- /dev/null +++ b/services/ontology-core/examples/seo-project-ontology.nodedc.example.json @@ -0,0 +1,150 @@ +{ + "schemaVersion": "seo-project-ontology.v1", + "projectId": "nodedc-site-seo", + "siteId": "nodedc-site", + "coreOntologyVersion": "0.4-pre", + "seoDomainPackageVersion": "0.1.0", + "source": { + "sourceType": "local_folder", + "scanVersionId": "scan_fixture_nodedc_site", + "rootUrl": null, + "sourcePath": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE" + }, + "scope": { + "scopeContractId": "scope_fixture_nodedc_site", + "scopeItemIds": ["page.home", "section.hero", "section.modules", "section.ops", "section.engine"] + }, + "brand": { + "name": "NODE.DC", + "aliases": ["NodeDC", "NDC", "NODEDC"], + "description": "Enterprise platform for process automation, AI assistants, workflow, operational control, and applied modules." + }, + "offers": [ + { + "id": "offer.platform", + "name": "AI platform for business automation", + "summary": "Platform layer combining Hub, Engine, Ops, Assistant, integrations, and domain modules.", + "evidenceIds": ["e.site.copy"] + }, + { + "id": "offer.ops", + "name": "Operational control and project workspace", + "summary": "Operational card/project/task context for teams and AI-assisted execution.", + "evidenceIds": ["e.ops.card"] + }, + { + "id": "offer.engine", + "name": "Workflow and automation engine", + "summary": "Workflow graph and automation runtime context connected to platform apps.", + "evidenceIds": ["e.ontology.core"] + } + ], + "audiences": [ + { + "id": "audience.enterprise", + "name": "Enterprise teams", + "summary": "Teams needing controlled automation, access boundaries, workflow, and operational visibility.", + "evidenceIds": ["e.site.copy"] + } + ], + "pages": [ + { + "id": "page.home", + "url": "/", + "title": "NODE.DC landing", + "evidenceIds": ["e.site.copy"] + } + ], + "sections": [ + { + "id": "section.hero", + "pageId": "page.home", + "title": "Hero", + "meaning": "Core platform positioning and primary offer.", + "evidenceIds": ["e.site.copy"] + }, + { + "id": "section.modules", + "pageId": "page.home", + "title": "Modules", + "meaning": "Platform modules and application catalog.", + "evidenceIds": ["e.site.copy"] + }, + { + "id": "section.ops", + "pageId": "page.home", + "title": "Ops", + "meaning": "Operational work management, projects, cards, statuses, and assistant routing.", + "evidenceIds": ["e.ops.card"] + }, + { + "id": "section.engine", + "pageId": "page.home", + "title": "Engine", + "meaning": "Workflow graph, automation nodes, runtime context, and integrations.", + "evidenceIds": ["e.ontology.core"] + } + ], + "intentClusters": [ + { + "id": "intent.ai_platform_development", + "title": "AI platform for development and automation", + "priority": "high", + "targetIntent": "Commercial platform, automation, AI, product development, enterprise workflow.", + "seedQueries": ["ai платформа для бизнеса", "платформа автоматизации бизнеса ai"], + "evidenceIds": ["e.site.copy"] + }, + { + "id": "intent.business_process_automation", + "title": "Business process automation", + "priority": "high", + "targetIntent": "BPM/workflow/process automation and operational execution.", + "seedQueries": ["автоматизация бизнес процессов", "workflow система для бизнеса"], + "evidenceIds": ["e.site.copy"] + }, + { + "id": "intent.agentic_automation", + "title": "Agentic automation and AI assistants", + "priority": "high", + "targetIntent": "AI agents, assistants, autonomous checks, and task execution.", + "seedQueries": ["ai агенты для бизнеса", "агентная автоматизация процессов"], + "evidenceIds": ["e.site.copy"] + } + ], + "tone": { + "language": "ru", + "signals": ["engineering product tone", "B2B operational language", "AI-first vocabulary"], + "forbiddenShifts": ["consumer entertainment tone", "unsupported market claims", "global business pivot without approval"] + }, + "constraints": { + "forbiddenTopics": ["old Webflow context", "SS-script legacy context", "federat legacy context"], + "forbiddenClaims": ["guaranteed traffic growth without market evidence", "unsupported integrations"], + "mustKeep": ["NODE.DC brand", "platform modularity", "source read-only before apply"] + }, + "evidence": [ + { + "id": "e.site.copy", + "sourceType": "site_source", + "ref": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE", + "summary": "Current validation target for SEO module development." + }, + { + "id": "e.ops.card", + "sourceType": "ops_card", + "ref": "c2e5e655-693f-4da5-a83e-438e98a2f6df", + "summary": "Operational SEO architecture card." + }, + { + "id": "e.ontology.core", + "sourceType": "ontology_core", + "ref": "platform/services/ontology-core", + "summary": "Platform ontology service/module baseline." + } + ], + "approvals": { + "status": "draft", + "approvedBy": null, + "approvedAt": null + }, + "ontologyDeltaProposals": [] +} diff --git a/services/ontology-core/src/assistant-policy.mjs b/services/ontology-core/src/assistant-policy.mjs index 4a60f72..8124ba5 100644 --- a/services/ontology-core/src/assistant-policy.mjs +++ b/services/ontology-core/src/assistant-policy.mjs @@ -1,11 +1,12 @@ -import { readJson } from './catalog.mjs' +import { loadCatalog, readJson } from './catalog.mjs' const LAUNCHER_ROOT_GROUPS = new Set(['nodedc:superadmin', 'nodedc:launcher:admin']) const LAUNCHER_ROOT_ROLES = new Set(['root_admin']) const LAUNCHER_CLIENT_ADMIN_ROLES = new Set(['client_owner', 'client_admin']) export async function loadAssistantAccessPolicy() { - return readJson('catalog/assistant-access-policy.json') + const catalog = await loadCatalog() + return catalog.assistantAccessPolicy } function normalizeString(value) { diff --git a/services/ontology-core/src/catalog.mjs b/services/ontology-core/src/catalog.mjs index a029ae3..37750f0 100644 --- a/services/ontology-core/src/catalog.mjs +++ b/services/ontology-core/src/catalog.mjs @@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) export const serviceRoot = path.resolve(__dirname, '..') export const catalogRoot = path.join(serviceRoot, 'catalog') +export const domainPackagesRoot = path.join(catalogRoot, 'domain-packages') export async function readJson(relativePath) { const fullPath = path.join(serviceRoot, relativePath) @@ -13,6 +14,229 @@ export async function readJson(relativePath) { return JSON.parse(raw) } +async function readJsonAt(fullPath) { + const raw = await fs.readFile(fullPath, 'utf8') + return JSON.parse(raw) +} + +async function readOptionalJsonAt(fullPath, fallback) { + try { + return await readJsonAt(fullPath) + } catch (error) { + if (error?.code === 'ENOENT') return fallback + throw error + } +} + +async function listDomainPackageDirs() { + try { + const entries = await fs.readdir(domainPackagesRoot, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(domainPackagesRoot, entry.name)) + .sort() + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +function uniqueByValue(values) { + return [...new Set(values)] +} + +function mergeRoleMatrix(baseMatrix = [], patchMatrix = []) { + const byRole = new Map(baseMatrix.map((entry) => [entry.roleId, { ...entry }])) + + for (const patch of patchMatrix || []) { + const current = byRole.get(patch.roleId) || { roleId: patch.roleId } + byRole.set(patch.roleId, { + ...current, + ...patch, + allowedCapabilities: uniqueByValue([ + ...(current.allowedCapabilities || []), + ...(patch.allowedCapabilities || []), + ]), + deniedCapabilities: uniqueByValue([ + ...(current.deniedCapabilities || []), + ...(patch.deniedCapabilities || []), + ]), + deniedWhenNoAdminScope: uniqueByValue([ + ...(current.deniedWhenNoAdminScope || []), + ...(patch.deniedWhenNoAdminScope || []), + ]), + conditionalCapabilities: [ + ...(current.conditionalCapabilities || []), + ...(patch.conditionalCapabilities || []), + ], + }) + } + + return [...byRole.values()] +} + +function mergeAssistantAccessPolicy(base, patch = {}) { + return { + ...base, + roleVocabulary: [...(base.roleVocabulary || []), ...(patch.roleVocabulary || [])], + roleAliases: [...(base.roleAliases || []), ...(patch.roleAliases || [])], + sourceAuthorities: [...(base.sourceAuthorities || []), ...(patch.sourceAuthorities || [])], + capabilities: [...(base.capabilities || []), ...(patch.capabilities || [])], + roleMatrix: mergeRoleMatrix(base.roleMatrix, patch.roleMatrix), + operations: [...(base.operations || []), ...(patch.operations || [])], + hardDenies: [...(base.hardDenies || []), ...(patch.hardDenies || [])], + } +} + +function mergeCatalog(base, extension) { + const next = { + ...base, + entities: { + ...base.entities, + statusVocabulary: uniqueByValue([ + ...(base.entities.statusVocabulary || []), + ...(extension.entities.statusVocabulary || []), + ]), + entities: [...(base.entities.entities || []), ...(extension.entities.entities || [])], + }, + relations: { + ...base.relations, + relations: [...(base.relations.relations || []), ...(extension.relations.relations || [])], + }, + aliases: { + ...base.aliases, + aliases: [...(base.aliases.aliases || []), ...(extension.aliases.aliases || [])], + }, + guardrails: { + ...base.guardrails, + rules: [...(base.guardrails.rules || []), ...(extension.guardrails.rules || [])], + blockedConflations: [ + ...(base.guardrails.blockedConflations || []), + ...(extension.guardrails.blockedConflations || []), + ], + }, + evidence: { + ...base.evidence, + sourceRoots: [...(base.evidence.sourceRoots || []), ...(extension.evidence.sourceRoots || [])], + ledgers: [...(base.evidence.ledgers || []), ...(extension.evidence.ledgers || [])], + baselineDocs: [...(base.evidence.baselineDocs || []), ...(extension.evidence.baselineDocs || [])], + restrictions: [...(base.evidence.restrictions || []), ...(extension.evidence.restrictions || [])], + }, + resolverRules: { + ...base.resolverRules, + rules: [...(base.resolverRules.rules || []), ...(extension.resolverRules.rules || [])], + }, + assistantAccessPolicy: mergeAssistantAccessPolicy( + base.assistantAccessPolicy, + extension.assistantAccessPolicy, + ), + assistantActions: { + ...base.assistantActions, + apps: [...(base.assistantActions.apps || []), ...(extension.assistantActions.apps || [])], + adapterStatuses: uniqueByValue([ + ...(base.assistantActions.adapterStatuses || []), + ...(extension.assistantActions.adapterStatuses || []), + ]), + actions: [...(base.assistantActions.actions || []), ...(extension.assistantActions.actions || [])], + }, + assistantRiskPolicy: { + ...base.assistantRiskPolicy, + riskLevels: [...(base.assistantRiskPolicy.riskLevels || []), ...(extension.assistantRiskPolicy.riskLevels || [])], + confirmationModes: [ + ...(base.assistantRiskPolicy.confirmationModes || []), + ...(extension.assistantRiskPolicy.confirmationModes || []), + ], + selfActionPolicies: [ + ...(base.assistantRiskPolicy.selfActionPolicies || []), + ...(extension.assistantRiskPolicy.selfActionPolicies || []), + ], + hardDenies: [...(base.assistantRiskPolicy.hardDenies || []), ...(extension.assistantRiskPolicy.hardDenies || [])], + globalRules: [ + ...(base.assistantRiskPolicy.globalRules || []), + ...(extension.assistantRiskPolicy.globalRules || []), + ], + }, + contextBindings: { + ...base.contextBindings, + contextStatuses: uniqueByValue([ + ...(base.contextBindings.contextStatuses || []), + ...(extension.contextBindings.contextStatuses || []), + ]), + bindingStatuses: uniqueByValue([ + ...(base.contextBindings.bindingStatuses || []), + ...(extension.contextBindings.bindingStatuses || []), + ]), + contexts: [...(base.contextBindings.contexts || []), ...(extension.contextBindings.contexts || [])], + bindingTypes: [ + ...(base.contextBindings.bindingTypes || []), + ...(extension.contextBindings.bindingTypes || []), + ], + bindings: [...(base.contextBindings.bindings || []), ...(extension.contextBindings.bindings || [])], + }, + domainPackages: [...(base.domainPackages || []), ...(extension.domainPackages || [])], + } + + return next +} + +async function loadDomainPackage(packageDir) { + const relativeDir = path.relative(serviceRoot, packageDir) + const metadata = await readOptionalJsonAt(path.join(packageDir, 'package.json'), {}) + + return { + entities: await readOptionalJsonAt(path.join(packageDir, 'entities.json'), { statusVocabulary: [], entities: [] }), + relations: await readOptionalJsonAt(path.join(packageDir, 'relations.json'), { relations: [] }), + aliases: await readOptionalJsonAt(path.join(packageDir, 'aliases.json'), { aliases: [] }), + guardrails: await readOptionalJsonAt(path.join(packageDir, 'guardrails.json'), { rules: [], blockedConflations: [] }), + evidence: await readOptionalJsonAt(path.join(packageDir, 'evidence.json'), { + sourceRoots: [], + ledgers: [], + baselineDocs: [], + restrictions: [], + }), + resolverRules: await readOptionalJsonAt(path.join(packageDir, 'resolver-rules.json'), { rules: [] }), + assistantAccessPolicy: await readOptionalJsonAt(path.join(packageDir, 'assistant-access-policy.json'), { + roleVocabulary: [], + roleAliases: [], + sourceAuthorities: [], + capabilities: [], + roleMatrix: [], + operations: [], + hardDenies: [], + }), + assistantActions: await readOptionalJsonAt(path.join(packageDir, 'assistant-actions.json'), { + apps: [], + adapterStatuses: [], + actions: [], + }), + assistantRiskPolicy: await readOptionalJsonAt(path.join(packageDir, 'assistant-risk-policy.json'), { + riskLevels: [], + confirmationModes: [], + selfActionPolicies: [], + hardDenies: [], + globalRules: [], + }), + contextBindings: await readOptionalJsonAt(path.join(packageDir, 'context-bindings.json'), { + contextStatuses: [], + bindingStatuses: [], + contexts: [], + bindingTypes: [], + bindings: [], + }), + domainPackages: [{ + id: metadata.id || path.basename(packageDir), + status: metadata.status || 'unknown', + relativePath: relativeDir, + summary: metadata.summary || '', + }], + } +} + +async function loadDomainPackages() { + const packageDirs = await listDomainPackageDirs() + return Promise.all(packageDirs.map((packageDir) => loadDomainPackage(packageDir))) +} + export async function loadCatalog() { const [ entities, @@ -37,15 +261,8 @@ export async function loadCatalog() { ]) const contextBindings = await readJson('catalog/context-bindings.json') - const entityById = new Map(entities.entities.map((entity) => [entity.id, entity])) - const relationById = new Map(relations.relations.map((relation) => [relation.id, relation])) - const contextById = new Map(contextBindings.contexts.map((context) => [context.id, context])) - const bindingTypeById = new Map(contextBindings.bindingTypes.map((bindingType) => [bindingType.id, bindingType])) - const aliasByKey = new Map( - aliases.aliases.map((alias) => [normalizeAlias(alias.alias), alias.canonicalId]), - ) - - return { + const domainPackages = await loadDomainPackages() + const mergedCatalog = domainPackages.reduce((catalog, extension) => mergeCatalog(catalog, extension), { entities, relations, aliases, @@ -56,6 +273,19 @@ export async function loadCatalog() { assistantActions, assistantRiskPolicy, contextBindings, + domainPackages: [], + }) + + const entityById = new Map(mergedCatalog.entities.entities.map((entity) => [entity.id, entity])) + const relationById = new Map(mergedCatalog.relations.relations.map((relation) => [relation.id, relation])) + const contextById = new Map(mergedCatalog.contextBindings.contexts.map((context) => [context.id, context])) + const bindingTypeById = new Map(mergedCatalog.contextBindings.bindingTypes.map((bindingType) => [bindingType.id, bindingType])) + const aliasByKey = new Map( + mergedCatalog.aliases.aliases.map((alias) => [normalizeAlias(alias.alias), alias.canonicalId]), + ) + + return { + ...mergedCatalog, entityById, relationById, contextById, diff --git a/services/ontology-core/src/validate.mjs b/services/ontology-core/src/validate.mjs index ec9f388..f7d2d4b 100644 --- a/services/ontology-core/src/validate.mjs +++ b/services/ontology-core/src/validate.mjs @@ -349,6 +349,7 @@ export async function validateCatalog() { contexts: contextBindings.contexts.length, bindingTypes: contextBindings.bindingTypes.length, bindings: contextBindings.bindings.length, + domainPackages: catalog.domainPackages.length, }, } }