feat(ai-workspace): support SEO codex provider bridge
This commit is contained in:
parent
1aceabee44
commit
a7c6c34c82
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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." }
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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." }
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ export async function validateCatalog() {
|
|||
contexts: contextBindings.contexts.length,
|
||||
bindingTypes: contextBindings.bindingTypes.length,
|
||||
bindings: contextBindings.bindings.length,
|
||||
domainPackages: catalog.domainPackages.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue