@aglyn/plugins-ai 1.0.0-beta.217 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +17 -17
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/activity/ai-activity-actions.js +10 -2
- package/src/lib/activity/ai-activity-actions.js.map +1 -1
- package/src/lib/components/ai-assist-provider.component.js +2 -2
- package/src/lib/components/ai-assist-provider.component.js.map +1 -1
- package/src/lib/components/ai-brief-dialog.component.js +3 -3
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-experiment-cards.component.js +1 -1
- package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
- package/src/lib/components/ai-job-plan.component.d.ts +8 -0
- package/src/lib/components/ai-job-plan.component.js +21 -4
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/components/ai-permissions-on-host.component.d.ts +12 -2
- package/src/lib/components/ai-permissions-on-host.component.js +12 -3
- package/src/lib/components/ai-permissions-on-host.component.js.map +1 -1
- package/src/lib/components/ai-product-copy-card.component.d.ts +1 -1
- package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
- package/src/lib/components/ai-product-import-option.component.d.ts +1 -1
- package/src/lib/components/ai-product-import-option.component.js.map +1 -1
- package/src/lib/components/ai-product-zones.d.ts +124 -0
- package/src/lib/components/ai-product-zones.js +32 -0
- package/src/lib/components/ai-product-zones.js.map +1 -0
- package/src/lib/components/ai-products-hub-card.component.d.ts +1 -1
- package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
- package/src/lib/components/ai-products-proposals.component.d.ts +1 -1
- package/src/lib/components/ai-products-proposals.component.js.map +1 -1
- package/src/lib/components/ai-seo-audit-card.component.js +35 -92
- package/src/lib/components/ai-seo-audit-card.component.js.map +1 -1
- package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
- package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
- package/src/lib/components/assist-jobs-drawer.component.js +21 -15
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +2 -2
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/assist-signals-page.component.js +2 -2
- package/src/lib/components/assist-signals-page.component.js.map +1 -1
- package/src/lib/jobs/ai-email-bindings.d.ts +28 -21
- package/src/lib/jobs/ai-email-bindings.js +75 -42
- package/src/lib/jobs/ai-email-bindings.js.map +1 -1
- package/src/lib/jobs/ai-job-campaign-step.d.ts +0 -2
- package/src/lib/jobs/ai-job-campaign-step.js +5 -10
- package/src/lib/jobs/ai-job-campaign-step.js.map +1 -1
- package/src/lib/jobs/ai-job-component-step.js +9 -3
- package/src/lib/jobs/ai-job-component-step.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +9 -1
- package/src/lib/jobs/ai-job-drafts.js +12 -5
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-email-step.d.ts +0 -2
- package/src/lib/jobs/ai-job-email-step.js +1 -3
- package/src/lib/jobs/ai-job-email-step.js.map +1 -1
- package/src/lib/jobs/ai-job-experiment-step.d.ts +1 -1
- package/src/lib/jobs/ai-job-experiment-step.js +1 -1
- package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
- package/src/lib/jobs/ai-job-generation.d.ts +1 -1
- package/src/lib/jobs/ai-job-generation.js +10 -2
- package/src/lib/jobs/ai-job-generation.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-step.js +1 -1
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-page-sections.d.ts +4 -2
- package/src/lib/jobs/ai-job-page-sections.js +7 -4
- package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.js +7 -2
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-conformance.js +33 -0
- package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +3 -0
- package/src/lib/jobs/ai-job-plan-step.js +29 -17
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plugin-drafts.d.ts +14 -6
- package/src/lib/jobs/ai-job-plugin-drafts.js +12 -4
- package/src/lib/jobs/ai-job-plugin-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-products-step.d.ts +7 -0
- package/src/lib/jobs/ai-job-products-step.js +54 -32
- package/src/lib/jobs/ai-job-products-step.js.map +1 -1
- package/src/lib/jobs/ai-job-seo-step.d.ts +3 -24
- package/src/lib/jobs/ai-job-seo-step.js +35 -108
- package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
- package/src/lib/jobs/ai-job-template-step.js +1 -0
- package/src/lib/jobs/ai-job-template-step.js.map +1 -1
- package/src/lib/jobs/ai-job-theme-step.d.ts +1 -1
- package/src/lib/jobs/ai-job-theme-step.js +1 -1
- package/src/lib/jobs/ai-job-theme-step.js.map +1 -1
- package/src/lib/jobs/ai-job-workflow-step.js +17 -9
- package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
- package/src/lib/jobs/ai-workflow-records.d.ts +19 -11
- package/src/lib/jobs/ai-workflow-records.js +60 -27
- package/src/lib/jobs/ai-workflow-records.js.map +1 -1
- package/src/lib/model/ai-allotments.d.ts +11 -3
- package/src/lib/model/ai-allotments.js +21 -4
- package/src/lib/model/ai-allotments.js.map +1 -1
- package/src/lib/model/ai-automation-outline.d.ts +21 -2
- package/src/lib/model/ai-automation-outline.js.map +1 -1
- package/src/lib/model/ai-build-plan.d.ts +57 -0
- package/src/lib/model/ai-build-plan.js +116 -7
- package/src/lib/model/ai-build-plan.js.map +1 -1
- package/src/lib/model/ai-seo.d.ts +11 -51
- package/src/lib/model/ai-seo.js +0 -17
- package/src/lib/model/ai-seo.js.map +1 -1
- package/src/lib/model/ai-site-inventory.js +1 -1
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-template-subjects.js +2 -2
- package/src/lib/model/ai-template-subjects.js.map +1 -1
- package/src/lib/model/ai-theme-proposal.d.ts +1 -1
- package/src/lib/model/ai-theme-proposal.js +1 -1
- package/src/lib/model/ai-theme-proposal.js.map +1 -1
- package/src/lib/model/ai-workflow-job.d.ts +1 -3
- package/src/lib/model/ai-workflow-job.js +1 -2
- package/src/lib/model/ai-workflow-job.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +149 -0
- package/src/lib/plan-entitlements.js +168 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +1 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -4
- package/src/lib/runtime/ai-doctrine-validators.js +179 -23
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.js +5 -5
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.js +29 -14
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/runtime/ai-eval.d.ts +8 -1
- package/src/lib/runtime/ai-eval.js +29 -2
- package/src/lib/runtime/ai-eval.js.map +1 -1
- package/src/lib/runtime/ai-node-tree.js +3 -3
- package/src/lib/runtime/ai-node-tree.js.map +1 -1
- package/src/lib/runtime/ai-palette.d.ts +6 -0
- package/src/lib/runtime/ai-palette.generated.js +21 -255
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/ai-palette.js +7 -1
- package/src/lib/runtime/ai-palette.js.map +1 -1
- package/src/lib/runtime/seo-audit.d.ts +13 -63
- package/src/lib/runtime/seo-audit.js +22 -207
- package/src/lib/runtime/seo-audit.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +1 -1
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-org.js +4 -25
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-user.js +2 -19
- package/src/lib/server/ai-admin-user.js.map +1 -1
- package/src/lib/server/ai-assist-prompts.js +3 -3
- package/src/lib/server/ai-assist-prompts.js.map +1 -1
- package/src/lib/server/ai-insight-digest-route.js +8 -1
- package/src/lib/server/ai-insight-digest-route.js.map +1 -1
- package/src/lib/server/assist-chat.js +11 -10
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/assist-view-context.js +23 -23
- package/src/lib/server/assist-view-context.js.map +1 -1
- package/src/lib/server.d.ts +1 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-automation-index.d.ts +20 -0
- package/src/lib/testing/stand-in-automation-index.js +102 -0
- package/src/lib/testing/stand-in-automation-index.js.map +1 -0
- package/src/lib/testing/stand-in-list-send-time.d.ts +4 -0
- package/src/lib/testing/stand-in-list-send-time.js +77 -0
- package/src/lib/testing/stand-in-list-send-time.js.map +1 -0
- package/src/lib/testing/stand-in-pipeline-index.d.ts +32 -0
- package/src/lib/testing/stand-in-pipeline-index.js +59 -0
- package/src/lib/testing/stand-in-pipeline-index.js.map +1 -0
- package/src/lib/testing/stand-in-product-index.d.ts +20 -0
- package/src/lib/testing/stand-in-product-index.js +105 -0
- package/src/lib/testing/stand-in-product-index.js.map +1 -0
- package/src/lib/tools/ai-component-tool.js +2 -2
- package/src/lib/tools/ai-component-tool.js.map +1 -1
- package/src/lib/tools/ai-duplicate-resource-tool.js +1 -1
- package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
- package/src/lib/tools/ai-seo-tool.d.ts +6 -18
- package/src/lib/tools/ai-seo-tool.js +7 -38
- package/src/lib/tools/ai-seo-tool.js.map +1 -1
- package/src/lib/tools/ai-workflow-tool.d.ts +1 -1
- package/src/lib/tools/ai-workflow-tool.js +1 -1
- package/src/lib/tools/ai-workflow-tool.js.map +1 -1
- package/src/lib/usage/ai-allotment-alerts.d.ts +3 -0
- package/src/lib/usage/ai-allotment-alerts.js +20 -8
- package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
- package/src/lib/usage/assist-free-taste.js +2 -1
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-signal-mining.js +1 -1
- package/src/lib/usage/assist-signal-mining.js.map +1 -1
- package/src/lib/usage-axes.d.ts +39 -0
- package/src/lib/usage-axes.js +70 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/runtime/seo-page-facts.d.ts +0 -61
- package/src/lib/runtime/seo-page-facts.js +0 -150
- package/src/lib/runtime/seo-page-facts.js.map +0 -1
|
@@ -40,7 +40,7 @@ import { getOrgForUser, memberHasPermissionOnHost } from "@aglyn/tenant-data-adm
|
|
|
40
40
|
*/ export const AI_DUPLICATE_RESOURCE_TOOL_NAME = 'duplicate_resource';
|
|
41
41
|
/** The structured-output tool a generation offers the model. */ export const AI_DUPLICATE_RESOURCE_TOOL = {
|
|
42
42
|
name: AI_DUPLICATE_RESOURCE_TOOL_NAME,
|
|
43
|
-
description: 'Copy a resource this site already has — a screen, an email design, a component, a layout, ' + 'a template, a form or a workflow — as a new draft to build from, instead of creating it again. ' + 'The copy is unpublished: it has no address, runs nothing and is placed nowhere until a person does.',
|
|
43
|
+
description: 'Copy a resource this site already has — a page (kind "screen"), an email design, a component, a layout, ' + 'a template, a form or a workflow — as a new draft to build from, instead of creating it again. ' + 'The copy is unpublished: it has no address, runs nothing and is placed nowhere until a person does.',
|
|
44
44
|
inputSchema: {
|
|
45
45
|
type: 'object',
|
|
46
46
|
properties: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/tools/ai-duplicate-resource-tool.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n DUPLICABLE_HOST_RESOURCE_KINDS,\n DUPLICATE_NAME_MAX,\n isDuplicableHostResourceKind,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport { orgPermissionLabel } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { duplicateResource } from '@aglyn/tenant-data-admin/server/duplicate-resource'\nimport {\n getOrgForUser,\n memberHasPermissionOnHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport type { AiTool } from '../providers/contract'\nimport type { AiGateContext } from '../runtime/ai-gate'\n\n/**\n * The `duplicate_resource` tool (AGL-2936, AGL-2984): a model that would\n * rebuild something the site already has copies it instead, through core's\n * `duplicateResource` — the same copy the row menus and the console door\n * make, with the same uniqueness rules, the same band arithmetic and the same\n * activity rows.\n *\n * It spends no tokens of its own, so it calls no provider: it runs inside a\n * door or a job step that has already climbed `aiGateLadder`, and takes that\n * ladder's context as its proof — the caller is signed in and verified, the\n * release flag, entitlement and lockdown rungs admitted the request, and the\n * workspace's band and caps admitted a reservation. On top of that it asks\n * what a copy needs whichever door called it: `ai.generate`, because a copy\n * is a generation's output, and a host role that may write the site, because\n * the copy lands there.\n *\n * DRAFTS ONLY, by construction of the copy: a screen copy has no route, a\n * workflow copy no trigger, and nothing places a component, layout or form\n * copy until a person does.\n */\n\nexport const AI_DUPLICATE_RESOURCE_TOOL_NAME = 'duplicate_resource'\n\n/** The structured-output tool a generation offers the model. */\nexport const AI_DUPLICATE_RESOURCE_TOOL: AiTool = {\n name: AI_DUPLICATE_RESOURCE_TOOL_NAME,\n description:\n 'Copy a resource this site already has — a screen, an email design, a component, a layout, ' +\n 'a template, a form or a workflow — as a new draft to build from, instead of creating it again. ' +\n 'The copy is unpublished: it has no address, runs nothing and is placed nowhere until a person does.',\n inputSchema: {\n type: 'object',\n properties: {\n kind: {\n type: 'string',\n enum: [...DUPLICABLE_HOST_RESOURCE_KINDS],\n description: 'The kind of resource to copy.',\n },\n sourceId: {\n type: 'string',\n description: 'The id of the resource to copy, as the site inventory lists it.',\n },\n name: {\n type: ['string', 'null'],\n description: `The copy's name, at most ${DUPLICATE_NAME_MAX} characters, or null for \"Copy of\" the source's name.`,\n },\n },\n required: ['kind', 'sourceId', 'name'],\n additionalProperties: false,\n },\n strict: true,\n}\n\n/** A tool call's input, read. */\nexport type AiDuplicateResourceInput = {\n kind: DuplicableHostResourceKind\n sourceId: string\n name: string | null\n}\n\n/** What the tool answers: the copy, or a refusal the caller can relay. */\nexport type AiDuplicateResourceOutcome =\n | { ok: true; kind: DuplicableHostResourceKind; id: string; versionId: string | null; name: string }\n | { ok: false; status: number; error: string }\n\n/** Reads a tool call's input; `null` when it is not one this tool accepts. */\nexport function parseAiDuplicateResourceInput(\n input: Record<string, unknown>,\n): AiDuplicateResourceInput | null {\n const kind = input['kind']\n const sourceId = typeof input['sourceId'] === 'string' ? input['sourceId'].trim() : ''\n const name = input['name']\n if (!isDuplicableHostResourceKind(kind)) return null\n if (!sourceId || sourceId.length > 64 || sourceId.includes('/')) return null\n if (name !== null && typeof name !== 'string') return null\n const trimmed = typeof name === 'string' ? name.trim().slice(0, DUPLICATE_NAME_MAX) : ''\n return { kind, sourceId, name: trimmed || null }\n}\n\n/**\n * Makes the copy on `hostId` for the person the gate admitted. Refusals are\n * outcomes, never throws: a model's call that names a missing source or a\n * full band is an answer to relay, not a fault.\n */\nexport async function runAiDuplicateResourceTool(\n context: { gate: AiGateContext; hostId: string },\n input: Record<string, unknown>,\n): Promise<AiDuplicateResourceOutcome> {\n const { gate } = context\n const hostId = String(context.hostId ?? '').trim()\n const parsed = parseAiDuplicateResourceInput(input)\n if (!parsed) return { ok: false, status: 400, error: 'That is not a resource this site can copy' }\n if (!gate.reservation?.allowed) {\n return { ok: false, status: 429, error: 'This workspace cannot run AI requests right now' }\n }\n if (!hostId) return { ok: false, status: 400, error: 'Name the site to copy on' }\n\n const host = await gate.firestore.collection('hosts').doc(hostId).get()\n // A site of another workspace is a site this request cannot see.\n if (!host.exists || host.get('orgId') !== gate.orgId) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n if (!gate.staff) {\n const membership = await getOrgForUser(gate.uid, gate.orgId)\n const permitted =\n membership?.orgId === gate.orgId &&\n (await memberHasPermissionOnHost(gate.orgId, hostId, membership.member, 'ai.generate'))\n if (!permitted) {\n return {\n ok: false,\n status: 403,\n error: `Your role does not include \"${orgPermissionLabel('ai.generate')}\" — ask an organization admin`,\n }\n }\n }\n const memberRole = ((host.get('memberRoles') ?? {}) as Record<string, unknown>)[gate.uid]\n if (!hostRoleCanWrite(memberRole)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n\n const result = await duplicateResource(parsed.kind, {\n orgId: gate.orgId,\n hostId,\n sourceId: parsed.sourceId,\n name: parsed.name,\n uid: gate.uid,\n email: gate.decoded.email ? String(gate.decoded.email) : null,\n org: gate.org as Record<string, unknown>,\n attemptKey: null,\n })\n if (result.ok === false) return result\n return {\n ok: true,\n kind: parsed.kind,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n}\n"],"names":["DUPLICABLE_HOST_RESOURCE_KINDS","DUPLICATE_NAME_MAX","isDuplicableHostResourceKind","hostRoleCanWrite","orgPermissionLabel","duplicateResource","getOrgForUser","memberHasPermissionOnHost","AI_DUPLICATE_RESOURCE_TOOL_NAME","AI_DUPLICATE_RESOURCE_TOOL","name","description","inputSchema","type","properties","kind","enum","sourceId","required","additionalProperties","strict","parseAiDuplicateResourceInput","input","trim","length","includes","trimmed","slice","runAiDuplicateResourceTool","context","host","gate","hostId","String","parsed","ok","status","error","reservation","allowed","firestore","collection","doc","get","exists","orgId","staff","membership","uid","permitted","member","memberRole","result","email","decoded","org","attemptKey","id","versionId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/tools/ai-duplicate-resource-tool.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n DUPLICABLE_HOST_RESOURCE_KINDS,\n DUPLICATE_NAME_MAX,\n isDuplicableHostResourceKind,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport { orgPermissionLabel } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { duplicateResource } from '@aglyn/tenant-data-admin/server/duplicate-resource'\nimport {\n getOrgForUser,\n memberHasPermissionOnHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport type { AiTool } from '../providers/contract'\nimport type { AiGateContext } from '../runtime/ai-gate'\n\n/**\n * The `duplicate_resource` tool (AGL-2936, AGL-2984): a model that would\n * rebuild something the site already has copies it instead, through core's\n * `duplicateResource` — the same copy the row menus and the console door\n * make, with the same uniqueness rules, the same band arithmetic and the same\n * activity rows.\n *\n * It spends no tokens of its own, so it calls no provider: it runs inside a\n * door or a job step that has already climbed `aiGateLadder`, and takes that\n * ladder's context as its proof — the caller is signed in and verified, the\n * release flag, entitlement and lockdown rungs admitted the request, and the\n * workspace's band and caps admitted a reservation. On top of that it asks\n * what a copy needs whichever door called it: `ai.generate`, because a copy\n * is a generation's output, and a host role that may write the site, because\n * the copy lands there.\n *\n * DRAFTS ONLY, by construction of the copy: a screen copy has no route, a\n * workflow copy no trigger, and nothing places a component, layout or form\n * copy until a person does.\n */\n\nexport const AI_DUPLICATE_RESOURCE_TOOL_NAME = 'duplicate_resource'\n\n/** The structured-output tool a generation offers the model. */\nexport const AI_DUPLICATE_RESOURCE_TOOL: AiTool = {\n name: AI_DUPLICATE_RESOURCE_TOOL_NAME,\n description:\n 'Copy a resource this site already has — a page (kind \"screen\"), an email design, a component, a layout, ' +\n 'a template, a form or a workflow — as a new draft to build from, instead of creating it again. ' +\n 'The copy is unpublished: it has no address, runs nothing and is placed nowhere until a person does.',\n inputSchema: {\n type: 'object',\n properties: {\n kind: {\n type: 'string',\n enum: [...DUPLICABLE_HOST_RESOURCE_KINDS],\n description: 'The kind of resource to copy.',\n },\n sourceId: {\n type: 'string',\n description: 'The id of the resource to copy, as the site inventory lists it.',\n },\n name: {\n type: ['string', 'null'],\n description: `The copy's name, at most ${DUPLICATE_NAME_MAX} characters, or null for \"Copy of\" the source's name.`,\n },\n },\n required: ['kind', 'sourceId', 'name'],\n additionalProperties: false,\n },\n strict: true,\n}\n\n/** A tool call's input, read. */\nexport type AiDuplicateResourceInput = {\n kind: DuplicableHostResourceKind\n sourceId: string\n name: string | null\n}\n\n/** What the tool answers: the copy, or a refusal the caller can relay. */\nexport type AiDuplicateResourceOutcome =\n | { ok: true; kind: DuplicableHostResourceKind; id: string; versionId: string | null; name: string }\n | { ok: false; status: number; error: string }\n\n/** Reads a tool call's input; `null` when it is not one this tool accepts. */\nexport function parseAiDuplicateResourceInput(\n input: Record<string, unknown>,\n): AiDuplicateResourceInput | null {\n const kind = input['kind']\n const sourceId = typeof input['sourceId'] === 'string' ? input['sourceId'].trim() : ''\n const name = input['name']\n if (!isDuplicableHostResourceKind(kind)) return null\n if (!sourceId || sourceId.length > 64 || sourceId.includes('/')) return null\n if (name !== null && typeof name !== 'string') return null\n const trimmed = typeof name === 'string' ? name.trim().slice(0, DUPLICATE_NAME_MAX) : ''\n return { kind, sourceId, name: trimmed || null }\n}\n\n/**\n * Makes the copy on `hostId` for the person the gate admitted. Refusals are\n * outcomes, never throws: a model's call that names a missing source or a\n * full band is an answer to relay, not a fault.\n */\nexport async function runAiDuplicateResourceTool(\n context: { gate: AiGateContext; hostId: string },\n input: Record<string, unknown>,\n): Promise<AiDuplicateResourceOutcome> {\n const { gate } = context\n const hostId = String(context.hostId ?? '').trim()\n const parsed = parseAiDuplicateResourceInput(input)\n if (!parsed) return { ok: false, status: 400, error: 'That is not a resource this site can copy' }\n if (!gate.reservation?.allowed) {\n return { ok: false, status: 429, error: 'This workspace cannot run AI requests right now' }\n }\n if (!hostId) return { ok: false, status: 400, error: 'Name the site to copy on' }\n\n const host = await gate.firestore.collection('hosts').doc(hostId).get()\n // A site of another workspace is a site this request cannot see.\n if (!host.exists || host.get('orgId') !== gate.orgId) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n if (!gate.staff) {\n const membership = await getOrgForUser(gate.uid, gate.orgId)\n const permitted =\n membership?.orgId === gate.orgId &&\n (await memberHasPermissionOnHost(gate.orgId, hostId, membership.member, 'ai.generate'))\n if (!permitted) {\n return {\n ok: false,\n status: 403,\n error: `Your role does not include \"${orgPermissionLabel('ai.generate')}\" — ask an organization admin`,\n }\n }\n }\n const memberRole = ((host.get('memberRoles') ?? {}) as Record<string, unknown>)[gate.uid]\n if (!hostRoleCanWrite(memberRole)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n\n const result = await duplicateResource(parsed.kind, {\n orgId: gate.orgId,\n hostId,\n sourceId: parsed.sourceId,\n name: parsed.name,\n uid: gate.uid,\n email: gate.decoded.email ? String(gate.decoded.email) : null,\n org: gate.org as Record<string, unknown>,\n attemptKey: null,\n })\n if (result.ok === false) return result\n return {\n ok: true,\n kind: parsed.kind,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n}\n"],"names":["DUPLICABLE_HOST_RESOURCE_KINDS","DUPLICATE_NAME_MAX","isDuplicableHostResourceKind","hostRoleCanWrite","orgPermissionLabel","duplicateResource","getOrgForUser","memberHasPermissionOnHost","AI_DUPLICATE_RESOURCE_TOOL_NAME","AI_DUPLICATE_RESOURCE_TOOL","name","description","inputSchema","type","properties","kind","enum","sourceId","required","additionalProperties","strict","parseAiDuplicateResourceInput","input","trim","length","includes","trimmed","slice","runAiDuplicateResourceTool","context","host","gate","hostId","String","parsed","ok","status","error","reservation","allowed","firestore","collection","doc","get","exists","orgId","staff","membership","uid","permitted","member","memberRole","result","email","decoded","org","attemptKey","id","versionId"],"mappings":"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"}
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { SeoFindingCode } from '@aglyn/aglyn/app-utils/seo-audit';
|
|
17
18
|
import { type SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields';
|
|
18
|
-
import type {
|
|
19
|
+
import type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts';
|
|
20
|
+
import type { AiSeoFieldValues, AiSeoSiteFormField } from '../model/ai-seo';
|
|
19
21
|
import type { AiTool } from '../providers/contract';
|
|
20
22
|
import type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
|
|
21
|
-
import type { AiSeoPageFacts } from '../runtime/seo-page-facts';
|
|
22
23
|
/**
|
|
23
24
|
* The strict tools SEO by AI answers through (AGL-2910), and the checks that
|
|
24
25
|
* hold each answer to the editor it proposes for.
|
|
@@ -37,21 +38,8 @@ import type { AiSeoPageFacts } from '../runtime/seo-page-facts';
|
|
|
37
38
|
* in the answer is broken, and otherwise the violations and the parts at
|
|
38
39
|
* fault, for the generation call's one re-ask.
|
|
39
40
|
*/
|
|
40
|
-
/** Target keywords one listing may be asked to cover. */
|
|
41
|
-
export declare const AI_SEO_MAX_KEYWORDS = 5;
|
|
42
41
|
/** The most times one keyword may appear, per field, before it reads as stuffing. */
|
|
43
42
|
export declare const AI_SEO_KEYWORD_LIMITS: Readonly<Partial<Record<SeoListingFieldKey, number>>>;
|
|
44
|
-
/** How many times `keyword` appears in `text` as whole words, any case. */
|
|
45
|
-
export declare function aiSeoKeywordCount(text: string | null | undefined, keyword: string): number;
|
|
46
|
-
/** Keywords as a person typed them: trimmed, deduplicated, capped. */
|
|
47
|
-
export declare function aiSeoKeywordList(raw: unknown): string[];
|
|
48
|
-
/** Where a page already says each target keyword. */
|
|
49
|
-
export declare function aiSeoKeywordCoverage(keywords: readonly string[], sources: {
|
|
50
|
-
title?: string | null;
|
|
51
|
-
description?: string | null;
|
|
52
|
-
h1?: string | null;
|
|
53
|
-
body?: string | null;
|
|
54
|
-
}): AiSeoKeywordCoverage[];
|
|
55
43
|
export declare const AI_SEO_FIELDS_TOOL_NAME = "propose_search_listing";
|
|
56
44
|
/** The fields a listing proposal must fill; the image description may be left out. */
|
|
57
45
|
export declare const AI_SEO_REQUIRED_FIELDS: ReadonlySet<SeoListingFieldKey>;
|
|
@@ -87,7 +75,7 @@ export declare const AI_SEO_FIX_ALT_MAX_CHARS: number;
|
|
|
87
75
|
/** Undescribed images one page of a batch gets descriptions for; the rest wait for the next audit. */
|
|
88
76
|
export declare const AI_SEO_FIX_IMAGES_PER_PAGE = 3;
|
|
89
77
|
/** The undescribed images a batch asks about for a page, in page order. */
|
|
90
|
-
export declare function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>):
|
|
78
|
+
export declare function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>): SeoPageFacts['imagesMissingAlt'];
|
|
91
79
|
/** The strict tool for one batch: a title, description, heading and image descriptions per page. */
|
|
92
80
|
export declare function aiSeoFixesTool(screenIds: readonly string[]): AiTool;
|
|
93
81
|
/** A batch page as the fixes prompt and check read it. */
|
|
@@ -95,7 +83,7 @@ export interface AiSeoBatchPage {
|
|
|
95
83
|
screenId: string;
|
|
96
84
|
path: string;
|
|
97
85
|
name: string;
|
|
98
|
-
codes: ReadonlySet<
|
|
86
|
+
codes: ReadonlySet<SeoFindingCode>;
|
|
99
87
|
keywords: readonly string[];
|
|
100
88
|
seo: {
|
|
101
89
|
title?: string;
|
|
@@ -104,7 +92,7 @@ export interface AiSeoBatchPage {
|
|
|
104
92
|
image?: string;
|
|
105
93
|
imageAlt?: string;
|
|
106
94
|
} | undefined;
|
|
107
|
-
facts:
|
|
95
|
+
facts: SeoPageFacts;
|
|
108
96
|
}
|
|
109
97
|
/** The findings that let a batch answer propose each value. */
|
|
110
98
|
export declare const AI_SEO_FIX_CODES: {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { MEDIA_ALT_MAX_LENGTH } from "@aglyn/aglyn/app-utils/media-alt";
|
|
17
|
+
import { seoKeywordCount } from "@aglyn/aglyn/app-utils/seo-keywords";
|
|
17
18
|
import { SEO_LISTING_FIELDS } from "@aglyn/aglyn/app-utils/seo-listing-fields";
|
|
18
19
|
import { HostEntityType } from "@aglyn/aglyn/foundation/definitions/platform.types";
|
|
19
20
|
import { AI_TEXT_LIMITS } from "../runtime/ai-palette.js";
|
|
@@ -35,45 +36,13 @@ import { AI_TEXT_LIMITS } from "../runtime/ai-palette.js";
|
|
|
35
36
|
* in the answer is broken, and otherwise the violations and the parts at
|
|
36
37
|
* fault, for the generation call's one re-ask.
|
|
37
38
|
*/ /* ------------------------------------------------------------------------ *
|
|
38
|
-
* Keywords:
|
|
39
|
-
*
|
|
40
|
-
/** The most times one keyword may appear, per field, before it reads as stuffing. */ export const AI_SEO_KEYWORD_LIMITS = {
|
|
39
|
+
* Keywords: what stuffing looks like. Coverage — whether a page says a
|
|
40
|
+
* keyword at all — is the platform's (`app-utils/seo-keywords`).
|
|
41
|
+
* ------------------------------------------------------------------------ */ /** The most times one keyword may appear, per field, before it reads as stuffing. */ export const AI_SEO_KEYWORD_LIMITS = {
|
|
41
42
|
title: 1,
|
|
42
43
|
description: 2,
|
|
43
44
|
breadcrumb: 1
|
|
44
45
|
};
|
|
45
|
-
const escapeRegExp = (value)=>value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
-
/** How many times `keyword` appears in `text` as whole words, any case. */ export function aiSeoKeywordCount(text, keyword) {
|
|
47
|
-
const needle = keyword.trim().toLowerCase();
|
|
48
|
-
if (!needle || !text) return 0;
|
|
49
|
-
const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(needle).replace(/\s+/g, '\\s+')}(?=$|[^\\p{L}\\p{N}])`, 'giu');
|
|
50
|
-
return [
|
|
51
|
-
...text.toLowerCase().matchAll(pattern)
|
|
52
|
-
].length;
|
|
53
|
-
}
|
|
54
|
-
/** Keywords as a person typed them: trimmed, deduplicated, capped. */ export function aiSeoKeywordList(raw) {
|
|
55
|
-
const parts = Array.isArray(raw) ? raw.map(String) : String(raw != null ? raw : '').split(/[,\n]/);
|
|
56
|
-
const seen = new Set();
|
|
57
|
-
const out = [];
|
|
58
|
-
for (const part of parts){
|
|
59
|
-
const keyword = part.replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
60
|
-
const key = keyword.toLowerCase();
|
|
61
|
-
if (!keyword || seen.has(key)) continue;
|
|
62
|
-
seen.add(key);
|
|
63
|
-
out.push(keyword);
|
|
64
|
-
if (out.length >= AI_SEO_MAX_KEYWORDS) break;
|
|
65
|
-
}
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
|
-
/** Where a page already says each target keyword. */ export function aiSeoKeywordCoverage(keywords, sources) {
|
|
69
|
-
return keywords.map((keyword)=>({
|
|
70
|
-
keyword,
|
|
71
|
-
inTitle: aiSeoKeywordCount(sources.title, keyword) > 0,
|
|
72
|
-
inDescription: aiSeoKeywordCount(sources.description, keyword) > 0,
|
|
73
|
-
inH1: aiSeoKeywordCount(sources.h1, keyword) > 0,
|
|
74
|
-
inBody: aiSeoKeywordCount(sources.body, keyword) > 0
|
|
75
|
-
}));
|
|
76
|
-
}
|
|
77
46
|
/** A word of four letters or more used three times in one short field reads as stuffing. */ function repeatedWord(text) {
|
|
78
47
|
var _text_toLowerCase_match;
|
|
79
48
|
const counts = new Map();
|
|
@@ -176,7 +145,7 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
176
145
|
continue;
|
|
177
146
|
}
|
|
178
147
|
const limit = AI_SEO_KEYWORD_LIMITS[key];
|
|
179
|
-
const stuffed = limit ? ((_context_keywords = context.keywords) != null ? _context_keywords : []).find((keyword)=>
|
|
148
|
+
const stuffed = limit ? ((_context_keywords = context.keywords) != null ? _context_keywords : []).find((keyword)=>seoKeywordCount(value, keyword) > limit) : undefined;
|
|
180
149
|
if (stuffed) {
|
|
181
150
|
violations.push({
|
|
182
151
|
rule: null,
|
|
@@ -353,7 +322,7 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
353
322
|
broken.push(`title is ${title.length} characters; the limit is ${SEO_LISTING_FIELDS.title.maxLength}`);
|
|
354
323
|
} else if (page.keywords.some((keyword)=>{
|
|
355
324
|
var _AI_SEO_KEYWORD_LIMITS_title;
|
|
356
|
-
return
|
|
325
|
+
return seoKeywordCount(title, keyword) > ((_AI_SEO_KEYWORD_LIMITS_title = AI_SEO_KEYWORD_LIMITS.title) != null ? _AI_SEO_KEYWORD_LIMITS_title : 1);
|
|
357
326
|
})) {
|
|
358
327
|
broken.push('title repeats a keyword');
|
|
359
328
|
} else if (taken.has(normalizeTitle(title))) {
|
|
@@ -369,7 +338,7 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
369
338
|
broken.push(`description is ${description.length} characters; the limit is ${SEO_LISTING_FIELDS.description.maxLength}`);
|
|
370
339
|
} else if (page.keywords.some((keyword)=>{
|
|
371
340
|
var _AI_SEO_KEYWORD_LIMITS_description;
|
|
372
|
-
return
|
|
341
|
+
return seoKeywordCount(description, keyword) > ((_AI_SEO_KEYWORD_LIMITS_description = AI_SEO_KEYWORD_LIMITS.description) != null ? _AI_SEO_KEYWORD_LIMITS_description : 2);
|
|
373
342
|
})) {
|
|
374
343
|
broken.push('description repeats a keyword');
|
|
375
344
|
} else {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/tools/ai-seo-tool.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from '@aglyn/aglyn/app-utils/media-alt'\nimport {\n SEO_LISTING_FIELDS,\n type SeoListingFieldKey,\n} from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport { HostEntityType } from '@aglyn/aglyn/foundation/definitions/platform.types'\nimport type {\n AiSeoFieldValues,\n AiSeoFindingCode,\n AiSeoKeywordCoverage,\n AiSeoSiteFormField,\n} from '../model/ai-seo'\nimport type { AiTool } from '../providers/contract'\nimport { AI_TEXT_LIMITS } from '../runtime/ai-palette'\nimport type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators'\nimport type { AiSeoPageFacts } from '../runtime/seo-page-facts'\n\n/**\n * The strict tools SEO by AI answers through (AGL-2910), and the checks that\n * hold each answer to the editor it proposes for.\n *\n * - `propose_search_listing` — one page's or one product's listing. BUILT\n * from the editor's own field list, the `seo-listing-fields` catalog the\n * SEO card draws its inputs from, so a field and its length are said once:\n * `ai-seo-tool.spec.ts` asserts the parity in both directions.\n * - `propose_seo_fixes` — one batch of a site audit's page fixes.\n * - `propose_site_listing` — the site's structured data and its guidance\n * for AI agents, the two fields `/llms.txt` leads with.\n *\n * A strict schema forbids extra keys, requires every key and says \"no value\"\n * with `null`; it accepts no length keyword, so every length is stated in a\n * description and enforced here. A check returns a value only when nothing\n * in the answer is broken, and otherwise the violations and the parts at\n * fault, for the generation call's one re-ask.\n */\n\n/* ------------------------------------------------------------------------ *\n * Keywords: coverage, and what stuffing looks like\n * ------------------------------------------------------------------------ */\n\n/** Target keywords one listing may be asked to cover. */\nexport const AI_SEO_MAX_KEYWORDS = 5\n\n/** The most times one keyword may appear, per field, before it reads as stuffing. */\nexport const AI_SEO_KEYWORD_LIMITS: Readonly<Partial<Record<SeoListingFieldKey, number>>> = {\n title: 1,\n description: 2,\n breadcrumb: 1,\n}\n\nconst escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n\n/** How many times `keyword` appears in `text` as whole words, any case. */\nexport function aiSeoKeywordCount(text: string | null | undefined, keyword: string): number {\n const needle = keyword.trim().toLowerCase()\n if (!needle || !text) return 0\n const pattern = new RegExp(\n `(^|[^\\\\p{L}\\\\p{N}])${escapeRegExp(needle).replace(/\\s+/g, '\\\\s+')}(?=$|[^\\\\p{L}\\\\p{N}])`,\n 'giu',\n )\n return [...text.toLowerCase().matchAll(pattern)].length\n}\n\n/** Keywords as a person typed them: trimmed, deduplicated, capped. */\nexport function aiSeoKeywordList(raw: unknown): string[] {\n const parts = Array.isArray(raw) ? raw.map(String) : String(raw ?? '').split(/[,\\n]/)\n const seen = new Set<string>()\n const out: string[] = []\n for (const part of parts) {\n const keyword = part.replace(/\\s+/g, ' ').trim().slice(0, 60)\n const key = keyword.toLowerCase()\n if (!keyword || seen.has(key)) continue\n seen.add(key)\n out.push(keyword)\n if (out.length >= AI_SEO_MAX_KEYWORDS) break\n }\n return out\n}\n\n/** Where a page already says each target keyword. */\nexport function aiSeoKeywordCoverage(\n keywords: readonly string[],\n sources: { title?: string | null; description?: string | null; h1?: string | null; body?: string | null },\n): AiSeoKeywordCoverage[] {\n return keywords.map((keyword) => ({\n keyword,\n inTitle: aiSeoKeywordCount(sources.title, keyword) > 0,\n inDescription: aiSeoKeywordCount(sources.description, keyword) > 0,\n inH1: aiSeoKeywordCount(sources.h1, keyword) > 0,\n inBody: aiSeoKeywordCount(sources.body, keyword) > 0,\n }))\n}\n\n/** A word of four letters or more used three times in one short field reads as stuffing. */\nfunction repeatedWord(text: string): string | null {\n const counts = new Map<string, number>()\n for (const word of text.toLowerCase().match(/[\\p{L}\\p{N}]{4,}/gu) ?? []) {\n const count = (counts.get(word) ?? 0) + 1\n if (count >= 3) return word\n counts.set(word, count)\n }\n return null\n}\n\nconst collapse = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\nconst text = (description: string) => ({ anyOf: [{ type: 'string' }, { type: 'null' }], description })\n\n/* ------------------------------------------------------------------------ *\n * One listing, from the editor's field list\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_FIELDS_TOOL_NAME = 'propose_search_listing'\n\n/** The fields a listing proposal must fill; the image description may be left out. */\nexport const AI_SEO_REQUIRED_FIELDS: ReadonlySet<SeoListingFieldKey> = new Set([\n 'title',\n 'description',\n 'breadcrumb',\n])\n\n/** Fields in the catalog's order, whatever order a caller named them in. */\nexport function orderedSeoListingFields(fields: readonly SeoListingFieldKey[]): SeoListingFieldKey[] {\n const wanted = new Set(fields)\n return (Object.keys(SEO_LISTING_FIELDS) as SeoListingFieldKey[]).filter((key) => wanted.has(key))\n}\n\n/** The strict tool for a listing with exactly these fields, each required and nullable. */\nexport function aiSeoFieldsTool(fields: readonly SeoListingFieldKey[]): AiTool {\n const keys = orderedSeoListingFields(fields)\n const properties: Record<string, unknown> = {}\n for (const key of keys) {\n const field = SEO_LISTING_FIELDS[key]\n properties[key] = text(\n `${field.label}. ${field.purpose} At most ${field.maxLength} characters.` +\n (AI_SEO_REQUIRED_FIELDS.has(key) ? '' : ' null when nothing you were given says what the picture shows.'),\n )\n }\n return {\n name: AI_SEO_FIELDS_TOOL_NAME,\n description:\n 'Propose the search listing for one page or one product. Every field is required; ' +\n 'null means no value can be written from what you were given.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties,\n required: keys,\n additionalProperties: false,\n },\n }\n}\n\nexport interface AiSeoFieldsCheckContext {\n fields: readonly SeoListingFieldKey[]\n /** Whether the listing has a social image; without one, an image description is dropped. */\n hasImage: boolean\n keywords?: readonly string[]\n /** Titles other pages of the site use, which this listing must not repeat. */\n otherTitles?: readonly string[]\n}\n\nconst normalizeTitle = (value: string): string => value.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/**\n * A listing answer held to the editor: every requested text field written,\n * each within its length, no keyword past its per-field count, no word\n * repeated to rank, no title another page uses.\n */\nexport function checkAiSeoFields(\n answer: Record<string, unknown>,\n context: AiSeoFieldsCheckContext,\n): { value: AiSeoFieldValues | null; violations: AiDoctrineViolation[]; offending?: Record<string, unknown> } {\n const values: AiSeoFieldValues = {}\n const violations: AiDoctrineViolation[] = []\n const offending: Record<string, unknown> = {}\n const otherTitles = new Set((context.otherTitles ?? []).map(normalizeTitle))\n for (const key of orderedSeoListingFields(context.fields)) {\n const field = SEO_LISTING_FIELDS[key]\n const raw = answer[key]\n if (raw !== null && raw !== undefined && typeof raw !== 'string') {\n violations.push({ rule: null, code: 'type', message: `${field.label} must be text or null.` })\n offending[key] = raw\n continue\n }\n const value = collapse(raw)\n if (!value) {\n if (AI_SEO_REQUIRED_FIELDS.has(key)) {\n violations.push({ rule: null, code: 'missing', message: `${field.label} is required.` })\n }\n continue\n }\n // An image description with no image to describe is a sentence about\n // nothing; it is left out rather than asked for again.\n if (key === 'imageAlt' && !context.hasImage) continue\n if (value.length > field.maxLength) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `${field.label} is ${value.length} characters; the limit is ${field.maxLength}.`,\n })\n offending[key] = value\n continue\n }\n const limit = AI_SEO_KEYWORD_LIMITS[key]\n const stuffed = limit\n ? (context.keywords ?? []).find((keyword) => aiSeoKeywordCount(value, keyword) > limit)\n : undefined\n if (stuffed) {\n violations.push({\n rule: null,\n code: 'keyword-stuffing',\n message: `${field.label} repeats \"${stuffed}\"; use a keyword at most ${limit === 1 ? 'once' : `${limit} times`} there.`,\n })\n offending[key] = value\n continue\n }\n const repeated = key === 'imageAlt' ? null : repeatedWord(value)\n if (repeated) {\n violations.push({ rule: null, code: 'repetition', message: `${field.label} repeats \"${repeated}\" three times.` })\n offending[key] = value\n continue\n }\n if (key === 'title' && otherTitles.has(normalizeTitle(value))) {\n violations.push({\n rule: null,\n code: 'duplicate-title',\n message: `The title \"${value}\" is already another page's title.`,\n })\n offending[key] = value\n continue\n }\n values[key] = value\n }\n return violations.length ? { value: null, violations, offending } : { value: values, violations: [] }\n}\n\n/* ------------------------------------------------------------------------ *\n * A batch of a site audit's page fixes\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_FIXES_TOOL_NAME = 'propose_seo_fixes'\n\n/**\n * How long an image description a batch proposes may be. Shorter than the\n * `alt` ceiling: a description read aloud is better short, and it keeps the\n * largest batch answer inside its measured budget.\n */\nexport const AI_SEO_FIX_ALT_MAX_CHARS = Math.min(150, MEDIA_ALT_MAX_LENGTH)\n\n/** Undescribed images one page of a batch gets descriptions for; the rest wait for the next audit. */\nexport const AI_SEO_FIX_IMAGES_PER_PAGE = 3\n\n/** The undescribed images a batch asks about for a page, in page order. */\nexport function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>): AiSeoPageFacts['imagesMissingAlt'] {\n return page.facts.imagesMissingAlt.slice(0, AI_SEO_FIX_IMAGES_PER_PAGE)\n}\n\n/** The strict tool for one batch: a title, description, heading and image descriptions per page. */\nexport function aiSeoFixesTool(screenIds: readonly string[]): AiTool {\n return {\n name: AI_SEO_FIXES_TOOL_NAME,\n description:\n 'Propose fixes for the pages you were given. One entry per page; null means leave that value as it is.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties: {\n pages: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n screenId: { type: 'string', enum: [...screenIds] },\n title: text(`${SEO_LISTING_FIELDS.title.purpose} At most ${SEO_LISTING_FIELDS.title.maxLength} characters.`),\n description: text(\n `${SEO_LISTING_FIELDS.description.purpose} At most ${SEO_LISTING_FIELDS.description.maxLength} characters.`,\n ),\n h1: text(`The page’s one main heading. At most ${AI_TEXT_LIMITS.headline} characters.`),\n imageAlts: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n nodeId: { type: 'string' },\n alt: {\n type: 'string',\n description: `What the picture shows. At most ${AI_SEO_FIX_ALT_MAX_CHARS} characters.`,\n },\n },\n required: ['nodeId', 'alt'],\n additionalProperties: false,\n },\n },\n },\n required: ['screenId', 'title', 'description', 'h1', 'imageAlts'],\n additionalProperties: false,\n },\n },\n },\n required: ['pages'],\n additionalProperties: false,\n },\n }\n}\n\n/** A batch page as the fixes prompt and check read it. */\nexport interface AiSeoBatchPage {\n screenId: string\n path: string\n name: string\n codes: ReadonlySet<AiSeoFindingCode>\n keywords: readonly string[]\n seo: { title?: string; description?: string; breadcrumb?: string; image?: string; imageAlt?: string } | undefined\n facts: AiSeoPageFacts\n}\n\n/** The findings that let a batch answer propose each value. */\nexport const AI_SEO_FIX_CODES = {\n title: ['title-missing', 'title-too-long', 'title-duplicate', 'keyword-missing'],\n description: ['description-missing', 'description-too-long', 'description-duplicate', 'keyword-missing'],\n h1: ['h1-missing', 'h1-thin'],\n} as const satisfies Record<string, readonly AiSeoFindingCode[]>\n\nexport interface AiSeoBatchAnswer {\n title: string | null\n description: string | null\n h1: string | null\n imageAlts: Array<{ nodeId: string; alt: string }>\n}\n\n/**\n * A batch answer held to the audit and the editor: values only where a\n * finding asked for them, each within its length, no keyword past its count,\n * no title another page uses, and image descriptions only for this page's\n * undescribed images.\n */\nexport function checkAiSeoFixes(\n answer: Record<string, unknown>,\n pages: readonly AiSeoBatchPage[],\n titlesElsewhere: readonly string[],\n): { value: Record<string, AiSeoBatchAnswer> | null; violations: AiDoctrineViolation[]; offending?: Record<string, unknown> } {\n const byId = new Map(pages.map((page) => [page.screenId, page]))\n const violations: AiDoctrineViolation[] = []\n const offending: Array<Record<string, unknown>> = []\n const value: Record<string, AiSeoBatchAnswer> = {}\n const taken = new Set(titlesElsewhere.map(normalizeTitle))\n const entries = Array.isArray(answer['pages']) ? (answer['pages'] as unknown[]) : null\n if (!entries) {\n return { value: null, violations: [{ rule: null, code: 'shape', message: 'The answer must list the pages.' }] }\n }\n const asks = (page: AiSeoBatchPage, codes: readonly AiSeoFindingCode[]) =>\n codes.some((code) => page.codes.has(code))\n for (const raw of entries) {\n const entry = (raw ?? {}) as Record<string, unknown>\n const page = byId.get(String(entry['screenId'] ?? ''))\n if (!page || value[page.screenId]) continue\n const broken: string[] = []\n const fix: AiSeoBatchAnswer = { title: null, description: null, h1: null, imageAlts: [] }\n\n const title = collapse(entry['title'])\n if (title && asks(page, AI_SEO_FIX_CODES.title)) {\n if (title.length > SEO_LISTING_FIELDS.title.maxLength) {\n broken.push(`title is ${title.length} characters; the limit is ${SEO_LISTING_FIELDS.title.maxLength}`)\n } else if (page.keywords.some((keyword) => aiSeoKeywordCount(title, keyword) > (AI_SEO_KEYWORD_LIMITS.title ?? 1))) {\n broken.push('title repeats a keyword')\n } else if (taken.has(normalizeTitle(title))) {\n broken.push(`title \"${title}\" is another page's title`)\n } else {\n fix.title = title\n taken.add(normalizeTitle(title))\n }\n }\n const description = collapse(entry['description'])\n if (description && asks(page, AI_SEO_FIX_CODES.description)) {\n if (description.length > SEO_LISTING_FIELDS.description.maxLength) {\n broken.push(\n `description is ${description.length} characters; the limit is ${SEO_LISTING_FIELDS.description.maxLength}`,\n )\n } else if (\n page.keywords.some((keyword) => aiSeoKeywordCount(description, keyword) > (AI_SEO_KEYWORD_LIMITS.description ?? 2))\n ) {\n broken.push('description repeats a keyword')\n } else {\n fix.description = description\n }\n }\n const h1 = collapse(entry['h1'])\n if (h1 && asks(page, AI_SEO_FIX_CODES.h1)) {\n if (h1.length > AI_TEXT_LIMITS.headline) {\n broken.push(`h1 is ${h1.length} characters; the limit is ${AI_TEXT_LIMITS.headline}`)\n } else {\n fix.h1 = h1\n }\n }\n const undescribed = new Set(aiSeoFixImages(page).map((image) => image.nodeId))\n for (const rawAlt of Array.isArray(entry['imageAlts']) ? (entry['imageAlts'] as unknown[]) : []) {\n const altEntry = (rawAlt ?? {}) as Record<string, unknown>\n const nodeId = String(altEntry['nodeId'] ?? '')\n const alt = collapse(altEntry['alt'])\n if (!undescribed.has(nodeId) || !alt || fix.imageAlts.some((known) => known.nodeId === nodeId)) continue\n if (alt.length > AI_SEO_FIX_ALT_MAX_CHARS) {\n broken.push(`the description of image ${nodeId} is over ${AI_SEO_FIX_ALT_MAX_CHARS} characters`)\n } else {\n fix.imageAlts.push({ nodeId, alt })\n }\n }\n if (broken.length) {\n violations.push(...broken.map((message) => ({ rule: null, code: 'fix', message: `Page ${page.screenId}: ${message}.` })))\n offending.push(entry)\n } else {\n value[page.screenId] = fix\n }\n }\n return violations.length ? { value: null, violations, offending: { pages: offending } } : { value, violations: [] }\n}\n\n/* ------------------------------------------------------------------------ *\n * The site: structured data and guidance for AI agents\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_SITE_TOOL_NAME = 'propose_site_listing'\n\n/** The longest structured-data description the site SEO form accepts. */\nexport const AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS = 300\n\n/** The longest agent guidance the site SEO form accepts. */\nexport const AI_SEO_AGENT_GUIDANCE_MAX_CHARS = 1_000\n\n/** The entity types the tool offers, and the value the site SEO form stores for each. */\nexport const AI_SEO_ENTITY_TYPE_VALUES: Readonly<Record<string, string>> = {\n Organization: `${HostEntityType.ORGANIZATION}`,\n Person: `${HostEntityType.PERSON}`,\n}\n\n/** The site-wide proposal's strict tool: structured data and agent guidance, each nullable. */\nexport function aiSeoSiteTool(): AiTool {\n const properties = {\n entityType: {\n anyOf: [{ type: 'string', enum: Object.keys(AI_SEO_ENTITY_TYPE_VALUES) }, { type: 'null' }],\n description: 'Whether a company or a person publishes the site.',\n },\n entityName: text('The publisher’s name as the site gives it.'),\n entityDescription: text(\n `What the publisher IS, in one sentence — not what a page is about. At most ${AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS} characters.`,\n ),\n contactEmail: text('A contact email address, only when a page of the site shows it.'),\n contactTelephone: text('A contact telephone number, only when a page of the site shows it.'),\n contactType: text('What that contact answers, such as customer support or sales.'),\n whenToUse: text(\n `For AI agents: the questions this site is the best source for, specifically. At most ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS} characters.`,\n ),\n howToUse: text(\n `For AI agents: which pages answer what, and what not to rely on. At most ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS} characters.`,\n ),\n }\n return {\n name: AI_SEO_SITE_TOOL_NAME,\n description:\n 'Propose the site’s structured data and its guidance for AI agents. Every field is required; null means leave it as it is.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties,\n required: Object.keys(properties),\n additionalProperties: false,\n },\n }\n}\n\nexport interface AiSeoSiteCheckContext {\n /** The form fields that are blank on the site now; only these may be proposed. */\n blank: ReadonlySet<AiSeoSiteFormField>\n /** Every word the site's pages show, for the contact details to be found in. */\n siteText: string\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The site proposal held to the form and to the site: lengths, and a contact\n * detail only when a page shows it. A detail no page shows is left out with\n * a note rather than asked for again — asking again invites composing one.\n */\nexport function checkAiSeoSite(\n answer: Record<string, unknown>,\n context: AiSeoSiteCheckContext,\n): {\n value: { values: Partial<Record<AiSeoSiteFormField, string>>; notes: string[] } | null\n violations: AiDoctrineViolation[]\n offending?: Record<string, unknown>\n} {\n const values: Partial<Record<AiSeoSiteFormField, string>> = {}\n const notes: string[] = []\n const violations: AiDoctrineViolation[] = []\n const offending: Record<string, unknown> = {}\n const put = (field: AiSeoSiteFormField, value: string) => {\n if (value && context.blank.has(field)) values[field] = value\n }\n\n const type = collapse(answer['entityType'])\n if (type && AI_SEO_ENTITY_TYPE_VALUES[type]) put('seo.entity.type', AI_SEO_ENTITY_TYPE_VALUES[type])\n put('seo.entity.name', collapse(answer['entityName']).slice(0, 200))\n\n const description = collapse(answer['entityDescription'])\n if (description.length > AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `The entity description is ${description.length} characters; the limit is ${AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS}.`,\n })\n offending['entityDescription'] = description\n } else {\n put('seo.entity.description', description)\n }\n\n const siteText = context.siteText.toLowerCase()\n const siteDigits = context.siteText.replace(/\\D+/g, '')\n const email = collapse(answer['contactEmail'])\n if (email) {\n if (EMAIL.test(email) && siteText.includes(email.toLowerCase())) put('seo.entity.email', email)\n else notes.push('A contact email was left out: no page of the site shows it.')\n }\n const telephone = collapse(answer['contactTelephone'])\n if (telephone) {\n const digits = telephone.replace(/\\D+/g, '')\n if (digits.length >= 7 && siteDigits.includes(digits)) put('seo.entity.telephone', telephone)\n else notes.push('A contact telephone number was left out: no page of the site shows it.')\n }\n if (values['seo.entity.email'] || values['seo.entity.telephone']) {\n put('seo.entity.contactType', collapse(answer['contactType']).slice(0, 100))\n }\n\n for (const [key, field] of [\n ['whenToUse', 'seo.agent.whenToUse'],\n ['howToUse', 'seo.agent.howToUse'],\n ] as const) {\n const guidance = collapse(answer[key])\n if (guidance.length > AI_SEO_AGENT_GUIDANCE_MAX_CHARS) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `${key} is ${guidance.length} characters; the limit is ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS}.`,\n })\n offending[key] = guidance\n } else {\n put(field, guidance)\n }\n }\n return violations.length ? { value: null, violations, offending } : { value: { values, notes }, violations: [] }\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","SEO_LISTING_FIELDS","HostEntityType","AI_TEXT_LIMITS","AI_SEO_MAX_KEYWORDS","AI_SEO_KEYWORD_LIMITS","title","description","breadcrumb","escapeRegExp","value","replace","aiSeoKeywordCount","text","keyword","needle","trim","toLowerCase","pattern","RegExp","matchAll","length","aiSeoKeywordList","raw","parts","Array","isArray","map","String","split","seen","Set","out","part","slice","key","has","add","push","aiSeoKeywordCoverage","keywords","sources","inTitle","inDescription","inH1","h1","inBody","body","repeatedWord","counts","Map","word","match","count","get","set","collapse","anyOf","type","AI_SEO_FIELDS_TOOL_NAME","AI_SEO_REQUIRED_FIELDS","orderedSeoListingFields","fields","wanted","Object","keys","filter","aiSeoFieldsTool","properties","field","label","purpose","maxLength","name","strict","inputSchema","required","additionalProperties","normalizeTitle","checkAiSeoFields","answer","context","values","violations","offending","otherTitles","undefined","rule","code","message","hasImage","limit","stuffed","find","repeated","AI_SEO_FIXES_TOOL_NAME","AI_SEO_FIX_ALT_MAX_CHARS","Math","min","AI_SEO_FIX_IMAGES_PER_PAGE","aiSeoFixImages","page","facts","imagesMissingAlt","aiSeoFixesTool","screenIds","pages","items","screenId","enum","headline","imageAlts","nodeId","alt","AI_SEO_FIX_CODES","checkAiSeoFixes","titlesElsewhere","byId","taken","entries","asks","codes","some","entry","broken","fix","undescribed","image","rawAlt","altEntry","known","AI_SEO_SITE_TOOL_NAME","AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS","AI_SEO_AGENT_GUIDANCE_MAX_CHARS","AI_SEO_ENTITY_TYPE_VALUES","Organization","ORGANIZATION","Person","PERSON","aiSeoSiteTool","entityType","entityName","entityDescription","contactEmail","contactTelephone","contactType","whenToUse","howToUse","EMAIL","checkAiSeoSite","notes","put","blank","siteText","siteDigits","email","test","includes","telephone","digits","guidance"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/tools/ai-seo-tool.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { MEDIA_ALT_MAX_LENGTH } from '@aglyn/aglyn/app-utils/media-alt'\nimport type { SeoFindingCode } from '@aglyn/aglyn/app-utils/seo-audit'\nimport { seoKeywordCount } from '@aglyn/aglyn/app-utils/seo-keywords'\nimport {\n SEO_LISTING_FIELDS,\n type SeoListingFieldKey,\n} from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts'\nimport { HostEntityType } from '@aglyn/aglyn/foundation/definitions/platform.types'\nimport type { AiSeoFieldValues, AiSeoSiteFormField } from '../model/ai-seo'\nimport type { AiTool } from '../providers/contract'\nimport { AI_TEXT_LIMITS } from '../runtime/ai-palette'\nimport type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators'\n\n/**\n * The strict tools SEO by AI answers through (AGL-2910), and the checks that\n * hold each answer to the editor it proposes for.\n *\n * - `propose_search_listing` — one page's or one product's listing. BUILT\n * from the editor's own field list, the `seo-listing-fields` catalog the\n * SEO card draws its inputs from, so a field and its length are said once:\n * `ai-seo-tool.spec.ts` asserts the parity in both directions.\n * - `propose_seo_fixes` — one batch of a site audit's page fixes.\n * - `propose_site_listing` — the site's structured data and its guidance\n * for AI agents, the two fields `/llms.txt` leads with.\n *\n * A strict schema forbids extra keys, requires every key and says \"no value\"\n * with `null`; it accepts no length keyword, so every length is stated in a\n * description and enforced here. A check returns a value only when nothing\n * in the answer is broken, and otherwise the violations and the parts at\n * fault, for the generation call's one re-ask.\n */\n\n/* ------------------------------------------------------------------------ *\n * Keywords: what stuffing looks like. Coverage — whether a page says a\n * keyword at all — is the platform's (`app-utils/seo-keywords`).\n * ------------------------------------------------------------------------ */\n\n/** The most times one keyword may appear, per field, before it reads as stuffing. */\nexport const AI_SEO_KEYWORD_LIMITS: Readonly<Partial<Record<SeoListingFieldKey, number>>> = {\n title: 1,\n description: 2,\n breadcrumb: 1,\n}\n\n/** A word of four letters or more used three times in one short field reads as stuffing. */\nfunction repeatedWord(text: string): string | null {\n const counts = new Map<string, number>()\n for (const word of text.toLowerCase().match(/[\\p{L}\\p{N}]{4,}/gu) ?? []) {\n const count = (counts.get(word) ?? 0) + 1\n if (count >= 3) return word\n counts.set(word, count)\n }\n return null\n}\n\nconst collapse = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\nconst text = (description: string) => ({ anyOf: [{ type: 'string' }, { type: 'null' }], description })\n\n/* ------------------------------------------------------------------------ *\n * One listing, from the editor's field list\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_FIELDS_TOOL_NAME = 'propose_search_listing'\n\n/** The fields a listing proposal must fill; the image description may be left out. */\nexport const AI_SEO_REQUIRED_FIELDS: ReadonlySet<SeoListingFieldKey> = new Set([\n 'title',\n 'description',\n 'breadcrumb',\n])\n\n/** Fields in the catalog's order, whatever order a caller named them in. */\nexport function orderedSeoListingFields(fields: readonly SeoListingFieldKey[]): SeoListingFieldKey[] {\n const wanted = new Set(fields)\n return (Object.keys(SEO_LISTING_FIELDS) as SeoListingFieldKey[]).filter((key) => wanted.has(key))\n}\n\n/** The strict tool for a listing with exactly these fields, each required and nullable. */\nexport function aiSeoFieldsTool(fields: readonly SeoListingFieldKey[]): AiTool {\n const keys = orderedSeoListingFields(fields)\n const properties: Record<string, unknown> = {}\n for (const key of keys) {\n const field = SEO_LISTING_FIELDS[key]\n properties[key] = text(\n `${field.label}. ${field.purpose} At most ${field.maxLength} characters.` +\n (AI_SEO_REQUIRED_FIELDS.has(key) ? '' : ' null when nothing you were given says what the picture shows.'),\n )\n }\n return {\n name: AI_SEO_FIELDS_TOOL_NAME,\n description:\n 'Propose the search listing for one page or one product. Every field is required; ' +\n 'null means no value can be written from what you were given.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties,\n required: keys,\n additionalProperties: false,\n },\n }\n}\n\nexport interface AiSeoFieldsCheckContext {\n fields: readonly SeoListingFieldKey[]\n /** Whether the listing has a social image; without one, an image description is dropped. */\n hasImage: boolean\n keywords?: readonly string[]\n /** Titles other pages of the site use, which this listing must not repeat. */\n otherTitles?: readonly string[]\n}\n\nconst normalizeTitle = (value: string): string => value.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/**\n * A listing answer held to the editor: every requested text field written,\n * each within its length, no keyword past its per-field count, no word\n * repeated to rank, no title another page uses.\n */\nexport function checkAiSeoFields(\n answer: Record<string, unknown>,\n context: AiSeoFieldsCheckContext,\n): { value: AiSeoFieldValues | null; violations: AiDoctrineViolation[]; offending?: Record<string, unknown> } {\n const values: AiSeoFieldValues = {}\n const violations: AiDoctrineViolation[] = []\n const offending: Record<string, unknown> = {}\n const otherTitles = new Set((context.otherTitles ?? []).map(normalizeTitle))\n for (const key of orderedSeoListingFields(context.fields)) {\n const field = SEO_LISTING_FIELDS[key]\n const raw = answer[key]\n if (raw !== null && raw !== undefined && typeof raw !== 'string') {\n violations.push({ rule: null, code: 'type', message: `${field.label} must be text or null.` })\n offending[key] = raw\n continue\n }\n const value = collapse(raw)\n if (!value) {\n if (AI_SEO_REQUIRED_FIELDS.has(key)) {\n violations.push({ rule: null, code: 'missing', message: `${field.label} is required.` })\n }\n continue\n }\n // An image description with no image to describe is a sentence about\n // nothing; it is left out rather than asked for again.\n if (key === 'imageAlt' && !context.hasImage) continue\n if (value.length > field.maxLength) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `${field.label} is ${value.length} characters; the limit is ${field.maxLength}.`,\n })\n offending[key] = value\n continue\n }\n const limit = AI_SEO_KEYWORD_LIMITS[key]\n const stuffed = limit\n ? (context.keywords ?? []).find((keyword) => seoKeywordCount(value, keyword) > limit)\n : undefined\n if (stuffed) {\n violations.push({\n rule: null,\n code: 'keyword-stuffing',\n message: `${field.label} repeats \"${stuffed}\"; use a keyword at most ${limit === 1 ? 'once' : `${limit} times`} there.`,\n })\n offending[key] = value\n continue\n }\n const repeated = key === 'imageAlt' ? null : repeatedWord(value)\n if (repeated) {\n violations.push({ rule: null, code: 'repetition', message: `${field.label} repeats \"${repeated}\" three times.` })\n offending[key] = value\n continue\n }\n if (key === 'title' && otherTitles.has(normalizeTitle(value))) {\n violations.push({\n rule: null,\n code: 'duplicate-title',\n message: `The title \"${value}\" is already another page's title.`,\n })\n offending[key] = value\n continue\n }\n values[key] = value\n }\n return violations.length ? { value: null, violations, offending } : { value: values, violations: [] }\n}\n\n/* ------------------------------------------------------------------------ *\n * A batch of a site audit's page fixes\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_FIXES_TOOL_NAME = 'propose_seo_fixes'\n\n/**\n * How long an image description a batch proposes may be. Shorter than the\n * `alt` ceiling: a description read aloud is better short, and it keeps the\n * largest batch answer inside its measured budget.\n */\nexport const AI_SEO_FIX_ALT_MAX_CHARS = Math.min(150, MEDIA_ALT_MAX_LENGTH)\n\n/** Undescribed images one page of a batch gets descriptions for; the rest wait for the next audit. */\nexport const AI_SEO_FIX_IMAGES_PER_PAGE = 3\n\n/** The undescribed images a batch asks about for a page, in page order. */\nexport function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>): SeoPageFacts['imagesMissingAlt'] {\n return page.facts.imagesMissingAlt.slice(0, AI_SEO_FIX_IMAGES_PER_PAGE)\n}\n\n/** The strict tool for one batch: a title, description, heading and image descriptions per page. */\nexport function aiSeoFixesTool(screenIds: readonly string[]): AiTool {\n return {\n name: AI_SEO_FIXES_TOOL_NAME,\n description:\n 'Propose fixes for the pages you were given. One entry per page; null means leave that value as it is.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties: {\n pages: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n screenId: { type: 'string', enum: [...screenIds] },\n title: text(`${SEO_LISTING_FIELDS.title.purpose} At most ${SEO_LISTING_FIELDS.title.maxLength} characters.`),\n description: text(\n `${SEO_LISTING_FIELDS.description.purpose} At most ${SEO_LISTING_FIELDS.description.maxLength} characters.`,\n ),\n h1: text(`The page’s one main heading. At most ${AI_TEXT_LIMITS.headline} characters.`),\n imageAlts: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n nodeId: { type: 'string' },\n alt: {\n type: 'string',\n description: `What the picture shows. At most ${AI_SEO_FIX_ALT_MAX_CHARS} characters.`,\n },\n },\n required: ['nodeId', 'alt'],\n additionalProperties: false,\n },\n },\n },\n required: ['screenId', 'title', 'description', 'h1', 'imageAlts'],\n additionalProperties: false,\n },\n },\n },\n required: ['pages'],\n additionalProperties: false,\n },\n }\n}\n\n/** A batch page as the fixes prompt and check read it. */\nexport interface AiSeoBatchPage {\n screenId: string\n path: string\n name: string\n codes: ReadonlySet<SeoFindingCode>\n keywords: readonly string[]\n seo: { title?: string; description?: string; breadcrumb?: string; image?: string; imageAlt?: string } | undefined\n facts: SeoPageFacts\n}\n\n/** The findings that let a batch answer propose each value. */\nexport const AI_SEO_FIX_CODES = {\n title: ['title-missing', 'title-too-long', 'title-duplicate', 'keyword-missing'],\n description: ['description-missing', 'description-too-long', 'description-duplicate', 'keyword-missing'],\n h1: ['h1-missing', 'h1-thin'],\n} as const satisfies Record<string, readonly SeoFindingCode[]>\n\nexport interface AiSeoBatchAnswer {\n title: string | null\n description: string | null\n h1: string | null\n imageAlts: Array<{ nodeId: string; alt: string }>\n}\n\n/**\n * A batch answer held to the audit and the editor: values only where a\n * finding asked for them, each within its length, no keyword past its count,\n * no title another page uses, and image descriptions only for this page's\n * undescribed images.\n */\nexport function checkAiSeoFixes(\n answer: Record<string, unknown>,\n pages: readonly AiSeoBatchPage[],\n titlesElsewhere: readonly string[],\n): { value: Record<string, AiSeoBatchAnswer> | null; violations: AiDoctrineViolation[]; offending?: Record<string, unknown> } {\n const byId = new Map(pages.map((page) => [page.screenId, page]))\n const violations: AiDoctrineViolation[] = []\n const offending: Array<Record<string, unknown>> = []\n const value: Record<string, AiSeoBatchAnswer> = {}\n const taken = new Set(titlesElsewhere.map(normalizeTitle))\n const entries = Array.isArray(answer['pages']) ? (answer['pages'] as unknown[]) : null\n if (!entries) {\n return { value: null, violations: [{ rule: null, code: 'shape', message: 'The answer must list the pages.' }] }\n }\n const asks = (page: AiSeoBatchPage, codes: readonly SeoFindingCode[]) =>\n codes.some((code) => page.codes.has(code))\n for (const raw of entries) {\n const entry = (raw ?? {}) as Record<string, unknown>\n const page = byId.get(String(entry['screenId'] ?? ''))\n if (!page || value[page.screenId]) continue\n const broken: string[] = []\n const fix: AiSeoBatchAnswer = { title: null, description: null, h1: null, imageAlts: [] }\n\n const title = collapse(entry['title'])\n if (title && asks(page, AI_SEO_FIX_CODES.title)) {\n if (title.length > SEO_LISTING_FIELDS.title.maxLength) {\n broken.push(`title is ${title.length} characters; the limit is ${SEO_LISTING_FIELDS.title.maxLength}`)\n } else if (page.keywords.some((keyword) => seoKeywordCount(title, keyword) > (AI_SEO_KEYWORD_LIMITS.title ?? 1))) {\n broken.push('title repeats a keyword')\n } else if (taken.has(normalizeTitle(title))) {\n broken.push(`title \"${title}\" is another page's title`)\n } else {\n fix.title = title\n taken.add(normalizeTitle(title))\n }\n }\n const description = collapse(entry['description'])\n if (description && asks(page, AI_SEO_FIX_CODES.description)) {\n if (description.length > SEO_LISTING_FIELDS.description.maxLength) {\n broken.push(\n `description is ${description.length} characters; the limit is ${SEO_LISTING_FIELDS.description.maxLength}`,\n )\n } else if (\n page.keywords.some((keyword) => seoKeywordCount(description, keyword) > (AI_SEO_KEYWORD_LIMITS.description ?? 2))\n ) {\n broken.push('description repeats a keyword')\n } else {\n fix.description = description\n }\n }\n const h1 = collapse(entry['h1'])\n if (h1 && asks(page, AI_SEO_FIX_CODES.h1)) {\n if (h1.length > AI_TEXT_LIMITS.headline) {\n broken.push(`h1 is ${h1.length} characters; the limit is ${AI_TEXT_LIMITS.headline}`)\n } else {\n fix.h1 = h1\n }\n }\n const undescribed = new Set(aiSeoFixImages(page).map((image) => image.nodeId))\n for (const rawAlt of Array.isArray(entry['imageAlts']) ? (entry['imageAlts'] as unknown[]) : []) {\n const altEntry = (rawAlt ?? {}) as Record<string, unknown>\n const nodeId = String(altEntry['nodeId'] ?? '')\n const alt = collapse(altEntry['alt'])\n if (!undescribed.has(nodeId) || !alt || fix.imageAlts.some((known) => known.nodeId === nodeId)) continue\n if (alt.length > AI_SEO_FIX_ALT_MAX_CHARS) {\n broken.push(`the description of image ${nodeId} is over ${AI_SEO_FIX_ALT_MAX_CHARS} characters`)\n } else {\n fix.imageAlts.push({ nodeId, alt })\n }\n }\n if (broken.length) {\n violations.push(...broken.map((message) => ({ rule: null, code: 'fix', message: `Page ${page.screenId}: ${message}.` })))\n offending.push(entry)\n } else {\n value[page.screenId] = fix\n }\n }\n return violations.length ? { value: null, violations, offending: { pages: offending } } : { value, violations: [] }\n}\n\n/* ------------------------------------------------------------------------ *\n * The site: structured data and guidance for AI agents\n * ------------------------------------------------------------------------ */\n\nexport const AI_SEO_SITE_TOOL_NAME = 'propose_site_listing'\n\n/** The longest structured-data description the site SEO form accepts. */\nexport const AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS = 300\n\n/** The longest agent guidance the site SEO form accepts. */\nexport const AI_SEO_AGENT_GUIDANCE_MAX_CHARS = 1_000\n\n/** The entity types the tool offers, and the value the site SEO form stores for each. */\nexport const AI_SEO_ENTITY_TYPE_VALUES: Readonly<Record<string, string>> = {\n Organization: `${HostEntityType.ORGANIZATION}`,\n Person: `${HostEntityType.PERSON}`,\n}\n\n/** The site-wide proposal's strict tool: structured data and agent guidance, each nullable. */\nexport function aiSeoSiteTool(): AiTool {\n const properties = {\n entityType: {\n anyOf: [{ type: 'string', enum: Object.keys(AI_SEO_ENTITY_TYPE_VALUES) }, { type: 'null' }],\n description: 'Whether a company or a person publishes the site.',\n },\n entityName: text('The publisher’s name as the site gives it.'),\n entityDescription: text(\n `What the publisher IS, in one sentence — not what a page is about. At most ${AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS} characters.`,\n ),\n contactEmail: text('A contact email address, only when a page of the site shows it.'),\n contactTelephone: text('A contact telephone number, only when a page of the site shows it.'),\n contactType: text('What that contact answers, such as customer support or sales.'),\n whenToUse: text(\n `For AI agents: the questions this site is the best source for, specifically. At most ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS} characters.`,\n ),\n howToUse: text(\n `For AI agents: which pages answer what, and what not to rely on. At most ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS} characters.`,\n ),\n }\n return {\n name: AI_SEO_SITE_TOOL_NAME,\n description:\n 'Propose the site’s structured data and its guidance for AI agents. Every field is required; null means leave it as it is.',\n strict: true,\n inputSchema: {\n type: 'object',\n properties,\n required: Object.keys(properties),\n additionalProperties: false,\n },\n }\n}\n\nexport interface AiSeoSiteCheckContext {\n /** The form fields that are blank on the site now; only these may be proposed. */\n blank: ReadonlySet<AiSeoSiteFormField>\n /** Every word the site's pages show, for the contact details to be found in. */\n siteText: string\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The site proposal held to the form and to the site: lengths, and a contact\n * detail only when a page shows it. A detail no page shows is left out with\n * a note rather than asked for again — asking again invites composing one.\n */\nexport function checkAiSeoSite(\n answer: Record<string, unknown>,\n context: AiSeoSiteCheckContext,\n): {\n value: { values: Partial<Record<AiSeoSiteFormField, string>>; notes: string[] } | null\n violations: AiDoctrineViolation[]\n offending?: Record<string, unknown>\n} {\n const values: Partial<Record<AiSeoSiteFormField, string>> = {}\n const notes: string[] = []\n const violations: AiDoctrineViolation[] = []\n const offending: Record<string, unknown> = {}\n const put = (field: AiSeoSiteFormField, value: string) => {\n if (value && context.blank.has(field)) values[field] = value\n }\n\n const type = collapse(answer['entityType'])\n if (type && AI_SEO_ENTITY_TYPE_VALUES[type]) put('seo.entity.type', AI_SEO_ENTITY_TYPE_VALUES[type])\n put('seo.entity.name', collapse(answer['entityName']).slice(0, 200))\n\n const description = collapse(answer['entityDescription'])\n if (description.length > AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `The entity description is ${description.length} characters; the limit is ${AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS}.`,\n })\n offending['entityDescription'] = description\n } else {\n put('seo.entity.description', description)\n }\n\n const siteText = context.siteText.toLowerCase()\n const siteDigits = context.siteText.replace(/\\D+/g, '')\n const email = collapse(answer['contactEmail'])\n if (email) {\n if (EMAIL.test(email) && siteText.includes(email.toLowerCase())) put('seo.entity.email', email)\n else notes.push('A contact email was left out: no page of the site shows it.')\n }\n const telephone = collapse(answer['contactTelephone'])\n if (telephone) {\n const digits = telephone.replace(/\\D+/g, '')\n if (digits.length >= 7 && siteDigits.includes(digits)) put('seo.entity.telephone', telephone)\n else notes.push('A contact telephone number was left out: no page of the site shows it.')\n }\n if (values['seo.entity.email'] || values['seo.entity.telephone']) {\n put('seo.entity.contactType', collapse(answer['contactType']).slice(0, 100))\n }\n\n for (const [key, field] of [\n ['whenToUse', 'seo.agent.whenToUse'],\n ['howToUse', 'seo.agent.howToUse'],\n ] as const) {\n const guidance = collapse(answer[key])\n if (guidance.length > AI_SEO_AGENT_GUIDANCE_MAX_CHARS) {\n violations.push({\n rule: null,\n code: 'too-long',\n message: `${key} is ${guidance.length} characters; the limit is ${AI_SEO_AGENT_GUIDANCE_MAX_CHARS}.`,\n })\n offending[key] = guidance\n } else {\n put(field, guidance)\n }\n }\n return violations.length ? { value: null, violations, offending } : { value: { values, notes }, violations: [] }\n}\n"],"names":["MEDIA_ALT_MAX_LENGTH","seoKeywordCount","SEO_LISTING_FIELDS","HostEntityType","AI_TEXT_LIMITS","AI_SEO_KEYWORD_LIMITS","title","description","breadcrumb","repeatedWord","text","counts","Map","word","toLowerCase","match","count","get","set","collapse","value","replace","trim","anyOf","type","AI_SEO_FIELDS_TOOL_NAME","AI_SEO_REQUIRED_FIELDS","Set","orderedSeoListingFields","fields","wanted","Object","keys","filter","key","has","aiSeoFieldsTool","properties","field","label","purpose","maxLength","name","strict","inputSchema","required","additionalProperties","normalizeTitle","checkAiSeoFields","answer","context","values","violations","offending","otherTitles","map","raw","undefined","push","rule","code","message","hasImage","length","limit","stuffed","keywords","find","keyword","repeated","AI_SEO_FIXES_TOOL_NAME","AI_SEO_FIX_ALT_MAX_CHARS","Math","min","AI_SEO_FIX_IMAGES_PER_PAGE","aiSeoFixImages","page","facts","imagesMissingAlt","slice","aiSeoFixesTool","screenIds","pages","items","screenId","enum","h1","headline","imageAlts","nodeId","alt","AI_SEO_FIX_CODES","checkAiSeoFixes","titlesElsewhere","byId","taken","entries","Array","isArray","asks","codes","some","entry","String","broken","fix","add","undescribed","image","rawAlt","altEntry","known","AI_SEO_SITE_TOOL_NAME","AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS","AI_SEO_AGENT_GUIDANCE_MAX_CHARS","AI_SEO_ENTITY_TYPE_VALUES","Organization","ORGANIZATION","Person","PERSON","aiSeoSiteTool","entityType","entityName","entityDescription","contactEmail","contactTelephone","contactType","whenToUse","howToUse","EMAIL","checkAiSeoSite","notes","put","blank","siteText","siteDigits","email","test","includes","telephone","digits","guidance"],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type TriggerCombinator, type TriggerConditionOp } from '@aglyn/aglyn/app-utils/actions';
|
|
18
18
|
import { type ContactLifecycleStage, type CrmActivityKind, type CrmTaskKind } from '@aglyn/aglyn/app-utils/crm';
|
|
19
|
-
import { type HostEventType } from '@aglyn/aglyn/app-utils/
|
|
19
|
+
import { type HostEventType } from '@aglyn/aglyn/app-utils/host-events';
|
|
20
20
|
import { type AiAutomationCapabilities, type AiAutomationStepType, type AiAutomationUnsupported } from '../model/ai-workflow-job';
|
|
21
21
|
import type { AiTool } from '../providers/contract';
|
|
22
22
|
import type { AiGenerationCheckResult } from '../runtime/ai-doctrine';
|
|
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { ACTION_MAX_CONDITIONS, ACTION_MAX_STEPS, CONTACT_TAG_MAX_LENGTH, FLOW_TIMED_OUT_FIELD, FLOW_WAIT_MAX_MINUTES, FLOW_WAIT_MIN_MINUTES, HOST_ACTION_STEP_LABELS, TRIGGER_COMBINATORS, TRIGGER_CONDITION_OPS } from "@aglyn/aglyn/app-utils/actions";
|
|
18
18
|
import { CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_LIFECYCLE_STAGES, CRM_ACTIVITY_KINDS, CRM_TASK_KINDS, CRM_TASK_MAX_DUE_DAYS } from "@aglyn/aglyn/app-utils/crm";
|
|
19
|
-
import { HOST_EVENT_PAYLOAD_KEYS, HOST_EVENT_TYPES } from "@aglyn/aglyn/app-utils/
|
|
19
|
+
import { HOST_EVENT_PAYLOAD_KEYS, HOST_EVENT_TYPES } from "@aglyn/aglyn/app-utils/host-events";
|
|
20
20
|
import { AI_AUTOMATION_NEED_LABELS, AI_AUTOMATION_STEP_NEEDS, AI_AUTOMATION_STEP_TYPES, AI_AUTOMATION_TRIGGER_NEEDS, AI_AUTOMATION_UNSUPPORTED, AI_AUTOMATION_UNSUPPORTED_NEED, aiAutomationTriggerLabel } from "../model/ai-workflow-job.js";
|
|
21
21
|
import { HOSTILE_TEXT } from "../runtime/ai-node-tree.js";
|
|
22
22
|
/**
|