@aglyn/plugins-ai 1.0.0-beta.220 → 1.0.0-beta.222
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/package.json +17 -17
- package/src/lib/components/ai-job-plan.component.js +1 -4
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/jobs/ai-job-crm-step.d.ts +44 -3
- package/src/lib/jobs/ai-job-crm-step.js +298 -8
- package/src/lib/jobs/ai-job-crm-step.js.map +1 -1
- package/src/lib/jobs/ai-job-page-sections.d.ts +33 -1
- package/src/lib/jobs/ai-job-page-sections.js +55 -1
- package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.js +20 -11
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.js +6 -2
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-seo-step.js +52 -15
- package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.js +6 -0
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-workflow-step.js +8 -7
- package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
- package/src/lib/jobs/ai-workflow-records.d.ts +2 -2
- package/src/lib/jobs/ai-workflow-records.js +2 -2
- package/src/lib/jobs/ai-workflow-records.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-free-page-built-recording.js +1 -0
- package/src/lib/jobs/fixtures/ai-free-page-built-recording.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-free-page-recording.js +2 -0
- package/src/lib/jobs/fixtures/ai-free-page-recording.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-page-briefs.js +2 -0
- package/src/lib/jobs/fixtures/ai-page-briefs.js.map +1 -1
- package/src/lib/model/ai-automation-draft.d.ts +4 -4
- package/src/lib/model/ai-automation-draft.js +15 -10
- package/src/lib/model/ai-automation-draft.js.map +1 -1
- package/src/lib/model/ai-automation-format.d.ts +139 -0
- package/src/lib/model/ai-automation-format.js +42 -0
- package/src/lib/model/ai-automation-format.js.map +1 -0
- package/src/lib/model/ai-automation-outline.d.ts +4 -4
- package/src/lib/model/ai-automation-outline.js +10 -8
- package/src/lib/model/ai-automation-outline.js.map +1 -1
- package/src/lib/model/ai-build-plan.d.ts +39 -2
- package/src/lib/model/ai-build-plan.js +80 -3
- package/src/lib/model/ai-build-plan.js.map +1 -1
- package/src/lib/model/ai-site-inventory.d.ts +5 -0
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-workflow-job.js +2 -2
- package/src/lib/model/ai-workflow-job.js.map +1 -1
- package/src/lib/providers/catalog.d.ts +23 -0
- package/src/lib/providers/catalog.js +29 -1
- package/src/lib/providers/catalog.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +8 -0
- package/src/lib/runtime/ai-doctrine-validators.js +34 -3
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.js +1 -1
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-eval.d.ts +2 -2
- package/src/lib/runtime/ai-eval.js +3 -3
- package/src/lib/runtime/ai-eval.js.map +1 -1
- package/src/lib/runtime/ai-palette.generated.js +8 -1
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/seo-audit.d.ts +5 -2
- package/src/lib/runtime/seo-audit.js +16 -7
- package/src/lib/runtime/seo-audit.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +10 -3
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/testing/stand-in-dataset-index.js +31 -5
- package/src/lib/testing/stand-in-dataset-index.js.map +1 -1
- package/src/lib/tools/ai-seo-tool.d.ts +5 -1
- package/src/lib/tools/ai-seo-tool.js +6 -2
- package/src/lib/tools/ai-seo-tool.js.map +1 -1
- package/src/lib/tools/ai-workflow-tool.d.ts +15 -4
- package/src/lib/tools/ai-workflow-tool.js +70 -12
- package/src/lib/tools/ai-workflow-tool.js.map +1 -1
- package/src/lib/usage-axes.js +14 -1
- package/src/lib/usage-axes.js.map +1 -1
|
@@ -35,8 +35,11 @@ export declare function aiSeoAudit(pages: readonly SeoAuditPage[], site: SeoAudi
|
|
|
35
35
|
batchSize?: number;
|
|
36
36
|
}): AiSeoAuditReport;
|
|
37
37
|
/**
|
|
38
|
-
* The content fixes that need no model:
|
|
39
|
-
* editable one becomes an `h2
|
|
38
|
+
* The content fixes that need no model: the page keeps its FIRST main
|
|
39
|
+
* heading, and every editable one after it becomes an `h2`. When the first is
|
|
40
|
+
* one no fix can change — a component's, a repeat's, rich text — it is the
|
|
41
|
+
* one kept, so every editable main heading is demoted; keeping an editable one
|
|
42
|
+
* beside it would leave the page with two.
|
|
40
43
|
*/
|
|
41
44
|
export declare function aiSeoHeadingDemotions(facts: SeoPageFacts): AiSeoContentFix[];
|
|
42
45
|
/** The queue's batches, in order. */
|
|
@@ -53,15 +53,24 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* The content fixes that need no model:
|
|
57
|
-
* editable one becomes an `h2
|
|
56
|
+
* The content fixes that need no model: the page keeps its FIRST main
|
|
57
|
+
* heading, and every editable one after it becomes an `h2`. When the first is
|
|
58
|
+
* one no fix can change — a component's, a repeat's, rich text — it is the
|
|
59
|
+
* one kept, so every editable main heading is demoted; keeping an editable one
|
|
60
|
+
* beside it would leave the page with two.
|
|
58
61
|
*/ export function aiSeoHeadingDemotions(facts) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const keep = editable
|
|
62
|
-
|
|
62
|
+
if (facts.h1s.length < 2) return [];
|
|
63
|
+
const [first, ...rest] = facts.h1s;
|
|
64
|
+
const keep = first.editable ? first.nodeId : null;
|
|
65
|
+
const demoted = new Set();
|
|
66
|
+
for (const heading of rest){
|
|
67
|
+
if (heading.editable && heading.nodeId && heading.nodeId !== keep) demoted.add(heading.nodeId);
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
...demoted
|
|
71
|
+
].map((nodeId)=>({
|
|
63
72
|
kind: 'h1-demote',
|
|
64
|
-
nodeId
|
|
73
|
+
nodeId
|
|
65
74
|
}));
|
|
66
75
|
}
|
|
67
76
|
/** The queue's batches, in order. */ export function aiSeoAuditBatches(report) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-audit.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 seoAudit,\n type SeoAuditPage,\n type SeoAuditSite,\n type SeoFindingCode,\n} from '@aglyn/aglyn/app-utils/seo-audit'\nimport type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts'\nimport type { AiSeoAuditReport, AiSeoContentFix } from '../model/ai-seo'\n\n/**\n * The site audit an `seo` job runs (AGL-2910): the platform's SEO check\n * (`@aglyn/aglyn/app-utils/seo-audit`), which any site owner sees without\n * this plugin, plus what a job owes on top of it — which pages need\n * GENERATED fixes, in what order, and whether the site-wide proposal is\n * owed. The findings and the scores are the check's; this module never\n * judges a page itself, so the fixes a job proposes answer exactly the\n * findings the site's SEO check lists.\n */\n\n/** Pages one batch of fixes carries: one model call a batch. */\nexport const AI_SEO_AUDIT_BATCH_SIZE = 8\n\n/** The findings a batch of generated fixes answers. */\nconst GENERATED_FIX_CODES: ReadonlySet<SeoFindingCode> = new Set([\n 'title-missing',\n 'title-too-long',\n 'title-duplicate',\n 'description-missing',\n 'description-too-long',\n 'description-duplicate',\n 'h1-missing',\n 'h1-thin',\n 'image-alt-missing',\n 'keyword-missing',\n])\n\n/** The site-wide findings the site proposal (structured data, agent guidance) answers. */\nconst SITE_PROPOSAL_CODES: ReadonlySet<SeoFindingCode> = new Set(['entity-incomplete', 'llms-guidance-missing'])\n\n/** Check the site, then queue the pages whose findings need generated text, the worst first. */\nexport function aiSeoAudit(\n pages: readonly SeoAuditPage[],\n site: SeoAuditSite,\n options: { skipped?: number; notes?: readonly string[]; batchSize?: number } = {},\n): AiSeoAuditReport {\n const report = seoAudit(pages, site, options)\n const queue = report.pages\n .filter((page) => page.findings.some((entry) => GENERATED_FIX_CODES.has(entry.code)))\n .sort((a, b) => a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n .map((page) => page.screenId)\n return {\n kind: 'audit',\n ...report,\n siteProposal: report.site.some((entry) => SITE_PROPOSAL_CODES.has(entry.code)),\n queue,\n batchSize: Math.max(1, options.batchSize ?? AI_SEO_AUDIT_BATCH_SIZE),\n }\n}\n\n/**\n * The content fixes that need no model:
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-audit.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 seoAudit,\n type SeoAuditPage,\n type SeoAuditSite,\n type SeoFindingCode,\n} from '@aglyn/aglyn/app-utils/seo-audit'\nimport type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts'\nimport type { AiSeoAuditReport, AiSeoContentFix } from '../model/ai-seo'\n\n/**\n * The site audit an `seo` job runs (AGL-2910): the platform's SEO check\n * (`@aglyn/aglyn/app-utils/seo-audit`), which any site owner sees without\n * this plugin, plus what a job owes on top of it — which pages need\n * GENERATED fixes, in what order, and whether the site-wide proposal is\n * owed. The findings and the scores are the check's; this module never\n * judges a page itself, so the fixes a job proposes answer exactly the\n * findings the site's SEO check lists.\n */\n\n/** Pages one batch of fixes carries: one model call a batch. */\nexport const AI_SEO_AUDIT_BATCH_SIZE = 8\n\n/** The findings a batch of generated fixes answers. */\nconst GENERATED_FIX_CODES: ReadonlySet<SeoFindingCode> = new Set([\n 'title-missing',\n 'title-too-long',\n 'title-duplicate',\n 'description-missing',\n 'description-too-long',\n 'description-duplicate',\n 'h1-missing',\n 'h1-thin',\n 'image-alt-missing',\n 'keyword-missing',\n])\n\n/** The site-wide findings the site proposal (structured data, agent guidance) answers. */\nconst SITE_PROPOSAL_CODES: ReadonlySet<SeoFindingCode> = new Set(['entity-incomplete', 'llms-guidance-missing'])\n\n/** Check the site, then queue the pages whose findings need generated text, the worst first. */\nexport function aiSeoAudit(\n pages: readonly SeoAuditPage[],\n site: SeoAuditSite,\n options: { skipped?: number; notes?: readonly string[]; batchSize?: number } = {},\n): AiSeoAuditReport {\n const report = seoAudit(pages, site, options)\n const queue = report.pages\n .filter((page) => page.findings.some((entry) => GENERATED_FIX_CODES.has(entry.code)))\n .sort((a, b) => a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n .map((page) => page.screenId)\n return {\n kind: 'audit',\n ...report,\n siteProposal: report.site.some((entry) => SITE_PROPOSAL_CODES.has(entry.code)),\n queue,\n batchSize: Math.max(1, options.batchSize ?? AI_SEO_AUDIT_BATCH_SIZE),\n }\n}\n\n/**\n * The content fixes that need no model: the page keeps its FIRST main\n * heading, and every editable one after it becomes an `h2`. When the first is\n * one no fix can change — a component's, a repeat's, rich text — it is the\n * one kept, so every editable main heading is demoted; keeping an editable one\n * beside it would leave the page with two.\n */\nexport function aiSeoHeadingDemotions(facts: SeoPageFacts): AiSeoContentFix[] {\n if (facts.h1s.length < 2) return []\n const [first, ...rest] = facts.h1s\n const keep = first.editable ? first.nodeId : null\n const demoted = new Set<string>()\n for (const heading of rest) {\n if (heading.editable && heading.nodeId && heading.nodeId !== keep) demoted.add(heading.nodeId)\n }\n return [...demoted].map((nodeId) => ({ kind: 'h1-demote', nodeId }))\n}\n\n/** The queue's batches, in order. */\nexport function aiSeoAuditBatches(report: Pick<AiSeoAuditReport, 'queue' | 'batchSize'>): string[][] {\n const batches: string[][] = []\n for (let start = 0; start < report.queue.length; start += report.batchSize) {\n batches.push(report.queue.slice(start, start + report.batchSize))\n }\n return batches\n}\n"],"names":["seoAudit","AI_SEO_AUDIT_BATCH_SIZE","GENERATED_FIX_CODES","Set","SITE_PROPOSAL_CODES","aiSeoAudit","pages","site","options","report","queue","filter","page","findings","some","entry","has","code","sort","a","b","score","path","map","screenId","kind","siteProposal","batchSize","Math","max","aiSeoHeadingDemotions","facts","h1s","length","first","rest","keep","editable","nodeId","demoted","heading","add","aiSeoAuditBatches","batches","start","push","slice"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,QAAQ,QAIH,mCAAkC;AAIzC;;;;;;;;CAQC,GAED,8DAA8D,GAC9D,OAAO,MAAMC,0BAA0B,EAAC;AAExC,qDAAqD,GACrD,MAAMC,sBAAmD,IAAIC,IAAI;IAC/D;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,wFAAwF,GACxF,MAAMC,sBAAmD,IAAID,IAAI;IAAC;IAAqB;CAAwB;AAE/G,8FAA8F,GAC9F,OAAO,SAASE,WACdC,KAA8B,EAC9BC,IAAkB,EAClBC,UAA+E,CAAC,CAAC;QAYxDA;IAVzB,MAAMC,SAAST,SAASM,OAAOC,MAAMC;IACrC,MAAME,QAAQD,OAAOH,KAAK,CACvBK,MAAM,CAAC,CAACC,OAASA,KAAKC,QAAQ,CAACC,IAAI,CAAC,CAACC,QAAUb,oBAAoBc,GAAG,CAACD,MAAME,IAAI,IACjFC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEE,KAAK,GAAGD,EAAEC,KAAK,IAAKF,CAAAA,EAAEG,IAAI,GAAGF,EAAEE,IAAI,GAAG,CAAC,IAAIH,EAAEG,IAAI,GAAGF,EAAEE,IAAI,GAAG,IAAI,CAAA,GAClFC,GAAG,CAAC,CAACX,OAASA,KAAKY,QAAQ;IAC9B,OAAO;QACLC,MAAM;OACHhB;QACHiB,cAAcjB,OAAOF,IAAI,CAACO,IAAI,CAAC,CAACC,QAAUX,oBAAoBY,GAAG,CAACD,MAAME,IAAI;QAC5EP;QACAiB,WAAWC,KAAKC,GAAG,CAAC,IAAGrB,qBAAAA,QAAQmB,SAAS,YAAjBnB,qBAAqBP;;AAEhD;AAEA;;;;;;CAMC,GACD,OAAO,SAAS6B,sBAAsBC,KAAmB;IACvD,IAAIA,MAAMC,GAAG,CAACC,MAAM,GAAG,GAAG,OAAO,EAAE;IACnC,MAAM,CAACC,OAAO,GAAGC,KAAK,GAAGJ,MAAMC,GAAG;IAClC,MAAMI,OAAOF,MAAMG,QAAQ,GAAGH,MAAMI,MAAM,GAAG;IAC7C,MAAMC,UAAU,IAAIpC;IACpB,KAAK,MAAMqC,WAAWL,KAAM;QAC1B,IAAIK,QAAQH,QAAQ,IAAIG,QAAQF,MAAM,IAAIE,QAAQF,MAAM,KAAKF,MAAMG,QAAQE,GAAG,CAACD,QAAQF,MAAM;IAC/F;IACA,OAAO;WAAIC;KAAQ,CAAChB,GAAG,CAAC,CAACe,SAAY,CAAA;YAAEb,MAAM;YAAaa;QAAO,CAAA;AACnE;AAEA,mCAAmC,GACnC,OAAO,SAASI,kBAAkBjC,MAAqD;IACrF,MAAMkC,UAAsB,EAAE;IAC9B,IAAK,IAAIC,QAAQ,GAAGA,QAAQnC,OAAOC,KAAK,CAACuB,MAAM,EAAEW,SAASnC,OAAOkB,SAAS,CAAE;QAC1EgB,QAAQE,IAAI,CAACpC,OAAOC,KAAK,CAACoC,KAAK,CAACF,OAAOA,QAAQnC,OAAOkB,SAAS;IACjE;IACA,OAAOgB;AACT"}
|
|
@@ -96,11 +96,18 @@ async function readWindow(query, fields, cap, map) {
|
|
|
96
96
|
limit: cap
|
|
97
97
|
});
|
|
98
98
|
return {
|
|
99
|
-
rows: records.map((record)=>
|
|
99
|
+
rows: records.map((record)=>{
|
|
100
|
+
const fields = (Array.isArray(record.facts['fields']) ? record.facts['fields'] : []).map((field)=>({
|
|
101
|
+
id: text(field == null ? void 0 : field['id']),
|
|
102
|
+
name: text(field == null ? void 0 : field['name']) || text(field == null ? void 0 : field['id'])
|
|
103
|
+
})).filter((field)=>field.name);
|
|
104
|
+
return {
|
|
100
105
|
id: record.id,
|
|
101
106
|
name: record.name,
|
|
102
|
-
fields:
|
|
103
|
-
|
|
107
|
+
fields: fields.map((field)=>field.name),
|
|
108
|
+
fieldIds: fields.map((field)=>field.id || field.name)
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
104
111
|
truncated
|
|
105
112
|
};
|
|
106
113
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => ({\n id: record.id,\n name: record.name,\n fields: (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => text(field?.['name']) || text(field?.['id']))\n .filter(Boolean),\n })),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","name","facts","filter","Boolean","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","collections","screens","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","slug","layoutId","template","windows","exists"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","collections","screens","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","Boolean","slug","layoutId","template","windows","exists"],"mappings":"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"}
|
|
@@ -13,9 +13,7 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
17
|
-
import { datasetDisplayName } from "@aglyn/aglyn/app-utils/datasets";
|
|
18
|
-
import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
16
|
+
*/ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
19
17
|
import { registerPluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
20
18
|
import { unregisterPluginServices } from "@aglyn/aglyn/plugin-manager/plugin-services";
|
|
21
19
|
/**
|
|
@@ -28,12 +26,40 @@ import { unregisterPluginServices } from "@aglyn/aglyn/plugin-manager/plugin-ser
|
|
|
28
26
|
* `visibleTo`. What the data plugin itself answers is held in its own spec.
|
|
29
27
|
*/ const OWNER = 'data';
|
|
30
28
|
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
29
|
+
/**
|
|
30
|
+
* The fields a dataset document declares, in the Data page's order: its
|
|
31
|
+
* model's, or for a dataset from before models, each name in `fields` as a
|
|
32
|
+
* text field — the derivation the data plugin applies.
|
|
33
|
+
*/ function storedFields(data) {
|
|
34
|
+
const model = data['model'];
|
|
35
|
+
if (model && Array.isArray(model.order) && model.order.length && model.fields && typeof model.fields === 'object') {
|
|
36
|
+
return {
|
|
37
|
+
order: model.order.map(String),
|
|
38
|
+
fields: model.fields
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const names = Array.isArray(data['fields']) ? data['fields'].map(String) : [];
|
|
42
|
+
const titled = (name)=>{
|
|
43
|
+
const words = name.replace(/_/g, ' ').trim();
|
|
44
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
order: names,
|
|
48
|
+
fields: Object.fromEntries(names.map((name)=>[
|
|
49
|
+
name,
|
|
50
|
+
{
|
|
51
|
+
name: titled(name),
|
|
52
|
+
type: 'text'
|
|
53
|
+
}
|
|
54
|
+
]))
|
|
55
|
+
};
|
|
56
|
+
}
|
|
31
57
|
function record(id, data) {
|
|
32
58
|
if (!data || data['deletedAt'] != null) return null;
|
|
33
|
-
const model =
|
|
59
|
+
const model = storedFields(data);
|
|
34
60
|
return {
|
|
35
61
|
id,
|
|
36
|
-
name:
|
|
62
|
+
name: str(data['displayName']) || str(data['name']) || id,
|
|
37
63
|
facts: {
|
|
38
64
|
fields: model.order.map((fieldId)=>{
|
|
39
65
|
var _ref;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/testing/stand-in-dataset-index.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 {
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/testing/stand-in-dataset-index.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 { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n registerPluginRecordIndex,\n type PluginIndexedRecord,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { unregisterPluginServices } from '@aglyn/aglyn/plugin-manager/plugin-services'\n\n/**\n * The `dataset` index the plugin that keeps the workspace's datasets\n * publishes (AGL-3080), stood in over a spec's own Firestore double — this\n * plugin may not load the data plugin that registers the real one. It answers\n * in the shape the data plugin's `dataset-record-index.ts` documents: live\n * datasets only, narrowed to a site to those shared with it, each with its\n * fields (`{ id, name, type }`, in the dataset page's order) and its\n * `visibleTo`. What the data plugin itself answers is held in its own spec.\n */\n\nconst OWNER = 'data'\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** A field as a dataset document stores it, as far as this stand-in reads one. */\ninterface StoredField {\n name?: unknown\n type?: unknown\n}\n\n/**\n * The fields a dataset document declares, in the Data page's order: its\n * model's, or for a dataset from before models, each name in `fields` as a\n * text field — the derivation the data plugin applies.\n */\nfunction storedFields(data: Record<string, unknown>): { order: string[]; fields: Record<string, StoredField> } {\n const model = data['model'] as { order?: unknown; fields?: unknown } | undefined\n if (model && Array.isArray(model.order) && model.order.length && model.fields && typeof model.fields === 'object') {\n return { order: model.order.map(String), fields: model.fields as Record<string, StoredField> }\n }\n const names = Array.isArray(data['fields']) ? data['fields'].map(String) : []\n const titled = (name: string) => {\n const words = name.replace(/_/g, ' ').trim()\n return words ? words.charAt(0).toUpperCase() + words.slice(1) : name\n }\n return {\n order: names,\n fields: Object.fromEntries(names.map((name) => [name, { name: titled(name), type: 'text' }])),\n }\n}\n\nfunction record(id: string, data: Record<string, unknown> | undefined): PluginIndexedRecord | null {\n if (!data || data['deletedAt'] != null) return null\n const model = storedFields(data)\n return {\n id,\n name: str(data['displayName']) || str(data['name']) || id,\n facts: {\n fields: model.order.map((fieldId) => ({\n id: fieldId,\n name: str(model.fields[fieldId]?.name) || fieldId,\n type: String(model.fields[fieldId]?.type ?? 'text'),\n })),\n visibleTo: Array.isArray(data['visibleTo']) ? data['visibleTo'] : [],\n },\n }\n}\n\n/** Registers it, reading through `firestore`. */\nexport function standInDatasetIndex(firestore: FirebaseFirestore.Firestore): void {\n const datasets = (orgId: string) => firestore.collection('orgs').doc(orgId).collection('datasets')\n const shared = (one: PluginIndexedRecord | null, hostId: string | null | undefined) =>\n one && (!hostId || visibleToHost(one.facts['visibleTo'] as string[], hostId)) ? one : null\n registerPluginRecordIndex(\n 'dataset',\n {\n async list({ orgId, hostId, limit }) {\n if (!orgId) return { records: [], truncated: false }\n const snapshot = await datasets(orgId).get()\n const records = snapshot.docs\n .map((doc) => shared(record(doc.id, doc.data()), hostId))\n .filter((one): one is PluginIndexedRecord => one !== null)\n return { records: records.slice(0, limit), truncated: records.length > limit }\n },\n async get({ orgId, hostId, id }) {\n if (!orgId) return null\n const snapshot = await datasets(orgId).doc(id).get()\n return snapshot.exists ? shared(record(snapshot.id, snapshot.data()), hostId) : null\n },\n },\n { pluginId: OWNER },\n )\n}\n\n/** Forgets it, so the next spec starts with nothing keeping datasets. */\nexport function removeStandInDatasetIndex(): void {\n unregisterPluginServices(OWNER)\n}\n"],"names":["visibleToHost","registerPluginRecordIndex","unregisterPluginServices","OWNER","str","value","trim","storedFields","data","model","Array","isArray","order","length","fields","map","String","names","titled","name","words","replace","charAt","toUpperCase","slice","Object","fromEntries","type","record","id","facts","fieldId","visibleTo","standInDatasetIndex","firestore","datasets","orgId","collection","doc","shared","one","hostId","list","limit","records","truncated","snapshot","get","docs","filter","exists","pluginId","removeStandInDatasetIndex"],"mappings":"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"}
|
|
@@ -74,7 +74,11 @@ export declare const AI_SEO_FIXES_TOOL_NAME = "propose_seo_fixes";
|
|
|
74
74
|
export declare const AI_SEO_FIX_ALT_MAX_CHARS: number;
|
|
75
75
|
/** Undescribed images one page of a batch gets descriptions for; the rest wait for the next audit. */
|
|
76
76
|
export declare const AI_SEO_FIX_IMAGES_PER_PAGE = 3;
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The undescribed images a batch asks about for a page, in page order: only
|
|
79
|
+
* those the page itself holds. An image a component or a repeat renders is
|
|
80
|
+
* described in the component or the rows, never by a fix to the page.
|
|
81
|
+
*/
|
|
78
82
|
export declare function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>): SeoPageFacts['imagesMissingAlt'];
|
|
79
83
|
/** The strict tool for one batch: a title, description, heading and image descriptions per page. */
|
|
80
84
|
export declare function aiSeoFixesTool(screenIds: readonly string[]): AiTool;
|
|
@@ -194,8 +194,12 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
194
194
|
* largest batch answer inside its measured budget.
|
|
195
195
|
*/ export const AI_SEO_FIX_ALT_MAX_CHARS = Math.min(150, MEDIA_ALT_MAX_LENGTH);
|
|
196
196
|
/** Undescribed images one page of a batch gets descriptions for; the rest wait for the next audit. */ export const AI_SEO_FIX_IMAGES_PER_PAGE = 3;
|
|
197
|
-
/**
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The undescribed images a batch asks about for a page, in page order: only
|
|
199
|
+
* those the page itself holds. An image a component or a repeat renders is
|
|
200
|
+
* described in the component or the rows, never by a fix to the page.
|
|
201
|
+
*/ export function aiSeoFixImages(page) {
|
|
202
|
+
return page.facts.imagesMissingAlt.filter((image)=>image.editable).slice(0, AI_SEO_FIX_IMAGES_PER_PAGE);
|
|
199
203
|
}
|
|
200
204
|
/** The strict tool for one batch: a title, description, heading and image descriptions per page. */ export function aiSeoFixesTool(screenIds) {
|
|
201
205
|
return {
|
|
@@ -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 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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"}
|
|
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/**\n * The undescribed images a batch asks about for a page, in page order: only\n * those the page itself holds. An image a component or a repeat renders is\n * described in the component or the rows, never by a fix to the page.\n */\nexport function aiSeoFixImages(page: Pick<AiSeoBatchPage, 'facts'>): SeoPageFacts['imagesMissingAlt'] {\n return page.facts.imagesMissingAlt.filter((image) => image.editable).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","image","editable","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","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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,mCAAkC;AAEvE,SAASC,eAAe,QAAQ,sCAAqC;AACrE,SACEC,kBAAkB,QAEb,4CAA2C;AAElD,SAASC,cAAc,QAAQ,qDAAoD;AAGnF,SAASC,cAAc,QAAQ,2BAAuB;AAGtD;;;;;;;;;;;;;;;;;CAiBC,GAED;;;4EAG4E,GAE5E,mFAAmF,GACnF,OAAO,MAAMC,wBAA+E;IAC1FC,OAAO;IACPC,aAAa;IACbC,YAAY;AACd,EAAC;AAED,0FAA0F,GAC1F,SAASC,aAAaC,IAAY;QAEbA;IADnB,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,SAAQH,0BAAAA,KAAKI,WAAW,GAAGC,KAAK,CAAC,4naAAzBL,0BAAkD,EAAE,CAAE;YACxDC;QAAf,MAAMK,QAAQ,EAACL,cAAAA,OAAOM,GAAG,CAACJ,iBAAXF,cAAoB,KAAK;QACxC,IAAIK,SAAS,GAAG,OAAOH;QACvBF,OAAOO,GAAG,CAACL,MAAMG;IACnB;IACA,OAAO;AACT;AAEA,MAAMG,WAAW,CAACC,QAChB,OAAOA,UAAU,WAAWA,MAAMC,OAAO,CAAC,QAAQ,KAAKC,IAAI,KAAK;AAElE,MAAMZ,OAAO,CAACH,cAAyB,CAAA;QAAEgB,OAAO;YAAC;gBAAEC,MAAM;YAAS;YAAG;gBAAEA,MAAM;YAAO;SAAE;QAAEjB;IAAY,CAAA;AAEpG;;4EAE4E,GAE5E,OAAO,MAAMkB,0BAA0B,yBAAwB;AAE/D,oFAAoF,GACpF,OAAO,MAAMC,yBAA0D,IAAIC,IAAI;IAC7E;IACA;IACA;CACD,EAAC;AAEF,0EAA0E,GAC1E,OAAO,SAASC,wBAAwBC,MAAqC;IAC3E,MAAMC,SAAS,IAAIH,IAAIE;IACvB,OAAO,AAACE,OAAOC,IAAI,CAAC9B,oBAA6C+B,MAAM,CAAC,CAACC,MAAQJ,OAAOK,GAAG,CAACD;AAC9F;AAEA,yFAAyF,GACzF,OAAO,SAASE,gBAAgBP,MAAqC;IACnE,MAAMG,OAAOJ,wBAAwBC;IACrC,MAAMQ,aAAsC,CAAC;IAC7C,KAAK,MAAMH,OAAOF,KAAM;QACtB,MAAMM,QAAQpC,kBAAkB,CAACgC,IAAI;QACrCG,UAAU,CAACH,IAAI,GAAGxB,KAChB,GAAG4B,MAAMC,KAAK,CAAC,EAAE,EAAED,MAAME,OAAO,CAAC,SAAS,EAAEF,MAAMG,SAAS,CAAC,YAAY,CAAC,GACtEf,CAAAA,uBAAuBS,GAAG,CAACD,OAAO,KAAK,gEAA+D;IAE7G;IACA,OAAO;QACLQ,MAAMjB;QACNlB,aACE,sFACA;QACFoC,QAAQ;QACRC,aAAa;YACXpB,MAAM;YACNa;YACAQ,UAAUb;YACVc,sBAAsB;QACxB;IACF;AACF;AAWA,MAAMC,iBAAiB,CAAC3B,QAA0BA,MAAMC,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGR,WAAW;AAE/F;;;;CAIC,GACD,OAAO,SAASkC,iBACdC,MAA+B,EAC/BC,OAAgC;QAKHA;IAH7B,MAAMC,SAA2B,CAAC;IAClC,MAAMC,aAAoC,EAAE;IAC5C,MAAMC,YAAqC,CAAC;IAC5C,MAAMC,cAAc,IAAI3B,IAAI,EAACuB,uBAAAA,QAAQI,WAAW,YAAnBJ,uBAAuB,EAAE,EAAEK,GAAG,CAACR;IAC5D,KAAK,MAAMb,OAAON,wBAAwBsB,QAAQrB,MAAM,EAAG;YA6BpDqB;QA5BL,MAAMZ,QAAQpC,kBAAkB,CAACgC,IAAI;QACrC,MAAMsB,MAAMP,MAAM,CAACf,IAAI;QACvB,IAAIsB,QAAQ,QAAQA,QAAQC,aAAa,OAAOD,QAAQ,UAAU;YAChEJ,WAAWM,IAAI,CAAC;gBAAEC,MAAM;gBAAMC,MAAM;gBAAQC,SAAS,GAAGvB,MAAMC,KAAK,CAAC,sBAAsB,CAAC;YAAC;YAC5Fc,SAAS,CAACnB,IAAI,GAAGsB;YACjB;QACF;QACA,MAAMpC,QAAQD,SAASqC;QACvB,IAAI,CAACpC,OAAO;YACV,IAAIM,uBAAuBS,GAAG,CAACD,MAAM;gBACnCkB,WAAWM,IAAI,CAAC;oBAAEC,MAAM;oBAAMC,MAAM;oBAAWC,SAAS,GAAGvB,MAAMC,KAAK,CAAC,aAAa,CAAC;gBAAC;YACxF;YACA;QACF;QACA,qEAAqE;QACrE,uDAAuD;QACvD,IAAIL,QAAQ,cAAc,CAACgB,QAAQY,QAAQ,EAAE;QAC7C,IAAI1C,MAAM2C,MAAM,GAAGzB,MAAMG,SAAS,EAAE;YAClCW,WAAWM,IAAI,CAAC;gBACdC,MAAM;gBACNC,MAAM;gBACNC,SAAS,GAAGvB,MAAMC,KAAK,CAAC,IAAI,EAAEnB,MAAM2C,MAAM,CAAC,0BAA0B,EAAEzB,MAAMG,SAAS,CAAC,CAAC,CAAC;YAC3F;YACAY,SAAS,CAACnB,IAAI,GAAGd;YACjB;QACF;QACA,MAAM4C,QAAQ3D,qBAAqB,CAAC6B,IAAI;QACxC,MAAM+B,UAAUD,QACZ,EAACd,oBAAAA,QAAQgB,QAAQ,YAAhBhB,oBAAoB,EAAE,EAAEiB,IAAI,CAAC,CAACC,UAAYnE,gBAAgBmB,OAAOgD,WAAWJ,SAC7EP;QACJ,IAAIQ,SAAS;YACXb,WAAWM,IAAI,CAAC;gBACdC,MAAM;gBACNC,MAAM;gBACNC,SAAS,GAAGvB,MAAMC,KAAK,CAAC,UAAU,EAAE0B,QAAQ,yBAAyB,EAAED,UAAU,IAAI,SAAS,GAAGA,MAAM,MAAM,CAAC,CAAC,OAAO,CAAC;YACzH;YACAX,SAAS,CAACnB,IAAI,GAAGd;YACjB;QACF;QACA,MAAMiD,WAAWnC,QAAQ,aAAa,OAAOzB,aAAaW;QAC1D,IAAIiD,UAAU;YACZjB,WAAWM,IAAI,CAAC;gBAAEC,MAAM;gBAAMC,MAAM;gBAAcC,SAAS,GAAGvB,MAAMC,KAAK,CAAC,UAAU,EAAE8B,SAAS,cAAc,CAAC;YAAC;YAC/GhB,SAAS,CAACnB,IAAI,GAAGd;YACjB;QACF;QACA,IAAIc,QAAQ,WAAWoB,YAAYnB,GAAG,CAACY,eAAe3B,SAAS;YAC7DgC,WAAWM,IAAI,CAAC;gBACdC,MAAM;gBACNC,MAAM;gBACNC,SAAS,CAAC,WAAW,EAAEzC,MAAM,kCAAkC,CAAC;YAClE;YACAiC,SAAS,CAACnB,IAAI,GAAGd;YACjB;QACF;QACA+B,MAAM,CAACjB,IAAI,GAAGd;IAChB;IACA,OAAOgC,WAAWW,MAAM,GAAG;QAAE3C,OAAO;QAAMgC;QAAYC;IAAU,IAAI;QAAEjC,OAAO+B;QAAQC,YAAY,EAAE;IAAC;AACtG;AAEA;;4EAE4E,GAE5E,OAAO,MAAMkB,yBAAyB,oBAAmB;AAEzD;;;;CAIC,GACD,OAAO,MAAMC,2BAA2BC,KAAKC,GAAG,CAAC,KAAKzE,sBAAqB;AAE3E,oGAAoG,GACpG,OAAO,MAAM0E,6BAA6B,EAAC;AAE3C;;;;CAIC,GACD,OAAO,SAASC,eAAeC,IAAmC;IAChE,OAAOA,KAAKC,KAAK,CAACC,gBAAgB,CAAC7C,MAAM,CAAC,CAAC8C,QAAUA,MAAMC,QAAQ,EAAEC,KAAK,CAAC,GAAGP;AAChF;AAEA,kGAAkG,GAClG,OAAO,SAASQ,eAAeC,SAA4B;IACzD,OAAO;QACLzC,MAAM4B;QACN/D,aACE;QACFoC,QAAQ;QACRC,aAAa;YACXpB,MAAM;YACNa,YAAY;gBACV+C,OAAO;oBACL5D,MAAM;oBACN6D,OAAO;wBACL7D,MAAM;wBACNa,YAAY;4BACViD,UAAU;gCAAE9D,MAAM;gCAAU+D,MAAM;uCAAIJ;iCAAU;4BAAC;4BACjD7E,OAAOI,KAAK,GAAGR,mBAAmBI,KAAK,CAACkC,OAAO,CAAC,SAAS,EAAEtC,mBAAmBI,KAAK,CAACmC,SAAS,CAAC,YAAY,CAAC;4BAC3GlC,aAAaG,KACX,GAAGR,mBAAmBK,WAAW,CAACiC,OAAO,CAAC,SAAS,EAAEtC,mBAAmBK,WAAW,CAACkC,SAAS,CAAC,YAAY,CAAC;4BAE7G+C,IAAI9E,KAAK,CAAC,qCAAqC,EAAEN,eAAeqF,QAAQ,CAAC,YAAY,CAAC;4BACtFC,WAAW;gCACTlE,MAAM;gCACN6D,OAAO;oCACL7D,MAAM;oCACNa,YAAY;wCACVsD,QAAQ;4CAAEnE,MAAM;wCAAS;wCACzBoE,KAAK;4CACHpE,MAAM;4CACNjB,aAAa,CAAC,gCAAgC,EAAEgE,yBAAyB,YAAY,CAAC;wCACxF;oCACF;oCACA1B,UAAU;wCAAC;wCAAU;qCAAM;oCAC3BC,sBAAsB;gCACxB;4BACF;wBACF;wBACAD,UAAU;4BAAC;4BAAY;4BAAS;4BAAe;4BAAM;yBAAY;wBACjEC,sBAAsB;oBACxB;gBACF;YACF;YACAD,UAAU;gBAAC;aAAQ;YACnBC,sBAAsB;QACxB;IACF;AACF;AAaA,6DAA6D,GAC7D,OAAO,MAAM+C,mBAAmB;IAC9BvF,OAAO;QAAC;QAAiB;QAAkB;QAAmB;KAAkB;IAChFC,aAAa;QAAC;QAAuB;QAAwB;QAAyB;KAAkB;IACxGiF,IAAI;QAAC;QAAc;KAAU;AAC/B,EAA8D;AAS9D;;;;;CAKC,GACD,OAAO,SAASM,gBACd7C,MAA+B,EAC/BmC,KAAgC,EAChCW,eAAkC;IAElC,MAAMC,OAAO,IAAIpF,IAAIwE,MAAM7B,GAAG,CAAC,CAACqB,OAAS;YAACA,KAAKU,QAAQ;YAAEV;SAAK;IAC9D,MAAMxB,aAAoC,EAAE;IAC5C,MAAMC,YAA4C,EAAE;IACpD,MAAMjC,QAA0C,CAAC;IACjD,MAAM6E,QAAQ,IAAItE,IAAIoE,gBAAgBxC,GAAG,CAACR;IAC1C,MAAMmD,UAAUC,MAAMC,OAAO,CAACnD,MAAM,CAAC,QAAQ,IAAKA,MAAM,CAAC,QAAQ,GAAiB;IAClF,IAAI,CAACiD,SAAS;QACZ,OAAO;YAAE9E,OAAO;YAAMgC,YAAY;gBAAC;oBAAEO,MAAM;oBAAMC,MAAM;oBAASC,SAAS;gBAAkC;aAAE;QAAC;IAChH;IACA,MAAMwC,OAAO,CAACzB,MAAsB0B,QAClCA,MAAMC,IAAI,CAAC,CAAC3C,OAASgB,KAAK0B,KAAK,CAACnE,GAAG,CAACyB;IACtC,KAAK,MAAMJ,OAAO0C,QAAS;YAEIM;QAD7B,MAAMA,QAAShD,cAAAA,MAAO,CAAC;QACvB,MAAMoB,OAAOoB,KAAK/E,GAAG,CAACwF,QAAOD,kBAAAA,KAAK,CAAC,WAAW,YAAjBA,kBAAqB;QAClD,IAAI,CAAC5B,QAAQxD,KAAK,CAACwD,KAAKU,QAAQ,CAAC,EAAE;QACnC,MAAMoB,SAAmB,EAAE;QAC3B,MAAMC,MAAwB;YAAErG,OAAO;YAAMC,aAAa;YAAMiF,IAAI;YAAME,WAAW,EAAE;QAAC;QAExF,MAAMpF,QAAQa,SAASqF,KAAK,CAAC,QAAQ;QACrC,IAAIlG,SAAS+F,KAAKzB,MAAMiB,iBAAiBvF,KAAK,GAAG;YAC/C,IAAIA,MAAMyD,MAAM,GAAG7D,mBAAmBI,KAAK,CAACmC,SAAS,EAAE;gBACrDiE,OAAOhD,IAAI,CAAC,CAAC,SAAS,EAAEpD,MAAMyD,MAAM,CAAC,0BAA0B,EAAE7D,mBAAmBI,KAAK,CAACmC,SAAS,EAAE;YACvG,OAAO,IAAImC,KAAKV,QAAQ,CAACqC,IAAI,CAAC,CAACnC;oBAA+C/D;uBAAnCJ,gBAAgBK,OAAO8D,aAAY/D,+BAAAA,sBAAsBC,KAAK,YAA3BD,+BAA+B;gBAAK;gBAChHqG,OAAOhD,IAAI,CAAC;YACd,OAAO,IAAIuC,MAAM9D,GAAG,CAACY,eAAezC,SAAS;gBAC3CoG,OAAOhD,IAAI,CAAC,CAAC,OAAO,EAAEpD,MAAM,yBAAyB,CAAC;YACxD,OAAO;gBACLqG,IAAIrG,KAAK,GAAGA;gBACZ2F,MAAMW,GAAG,CAAC7D,eAAezC;YAC3B;QACF;QACA,MAAMC,cAAcY,SAASqF,KAAK,CAAC,cAAc;QACjD,IAAIjG,eAAe8F,KAAKzB,MAAMiB,iBAAiBtF,WAAW,GAAG;YAC3D,IAAIA,YAAYwD,MAAM,GAAG7D,mBAAmBK,WAAW,CAACkC,SAAS,EAAE;gBACjEiE,OAAOhD,IAAI,CACT,CAAC,eAAe,EAAEnD,YAAYwD,MAAM,CAAC,0BAA0B,EAAE7D,mBAAmBK,WAAW,CAACkC,SAAS,EAAE;YAE/G,OAAO,IACLmC,KAAKV,QAAQ,CAACqC,IAAI,CAAC,CAACnC;oBAAqD/D;uBAAzCJ,gBAAgBM,aAAa6D,aAAY/D,qCAAAA,sBAAsBE,WAAW,YAAjCF,qCAAqC;gBAC9G;gBACAqG,OAAOhD,IAAI,CAAC;YACd,OAAO;gBACLiD,IAAIpG,WAAW,GAAGA;YACpB;QACF;QACA,MAAMiF,KAAKrE,SAASqF,KAAK,CAAC,KAAK;QAC/B,IAAIhB,MAAMa,KAAKzB,MAAMiB,iBAAiBL,EAAE,GAAG;YACzC,IAAIA,GAAGzB,MAAM,GAAG3D,eAAeqF,QAAQ,EAAE;gBACvCiB,OAAOhD,IAAI,CAAC,CAAC,MAAM,EAAE8B,GAAGzB,MAAM,CAAC,0BAA0B,EAAE3D,eAAeqF,QAAQ,EAAE;YACtF,OAAO;gBACLkB,IAAInB,EAAE,GAAGA;YACX;QACF;QACA,MAAMqB,cAAc,IAAIlF,IAAIgD,eAAeC,MAAMrB,GAAG,CAAC,CAACwB,QAAUA,MAAMY,MAAM;QAC5E,KAAK,MAAMmB,UAAUX,MAAMC,OAAO,CAACI,KAAK,CAAC,YAAY,IAAKA,KAAK,CAAC,YAAY,GAAiB,EAAE,CAAE;gBAEzEO;YADtB,MAAMA,WAAYD,iBAAAA,SAAU,CAAC;YAC7B,MAAMnB,SAASc,QAAOM,mBAAAA,QAAQ,CAAC,SAAS,YAAlBA,mBAAsB;YAC5C,MAAMnB,MAAMzE,SAAS4F,QAAQ,CAAC,MAAM;YACpC,IAAI,CAACF,YAAY1E,GAAG,CAACwD,WAAW,CAACC,OAAOe,IAAIjB,SAAS,CAACa,IAAI,CAAC,CAACS,QAAUA,MAAMrB,MAAM,KAAKA,SAAS;YAChG,IAAIC,IAAI7B,MAAM,GAAGQ,0BAA0B;gBACzCmC,OAAOhD,IAAI,CAAC,CAAC,yBAAyB,EAAEiC,OAAO,SAAS,EAAEpB,yBAAyB,WAAW,CAAC;YACjG,OAAO;gBACLoC,IAAIjB,SAAS,CAAChC,IAAI,CAAC;oBAAEiC;oBAAQC;gBAAI;YACnC;QACF;QACA,IAAIc,OAAO3C,MAAM,EAAE;YACjBX,WAAWM,IAAI,IAAIgD,OAAOnD,GAAG,CAAC,CAACM,UAAa,CAAA;oBAAEF,MAAM;oBAAMC,MAAM;oBAAOC,SAAS,CAAC,KAAK,EAAEe,KAAKU,QAAQ,CAAC,EAAE,EAAEzB,QAAQ,CAAC,CAAC;gBAAC,CAAA;YACrHR,UAAUK,IAAI,CAAC8C;QACjB,OAAO;YACLpF,KAAK,CAACwD,KAAKU,QAAQ,CAAC,GAAGqB;QACzB;IACF;IACA,OAAOvD,WAAWW,MAAM,GAAG;QAAE3C,OAAO;QAAMgC;QAAYC,WAAW;YAAE+B,OAAO/B;QAAU;IAAE,IAAI;QAAEjC;QAAOgC,YAAY,EAAE;IAAC;AACpH;AAEA;;4EAE4E,GAE5E,OAAO,MAAM6D,wBAAwB,uBAAsB;AAE3D,uEAAuE,GACvE,OAAO,MAAMC,sCAAsC,IAAG;AAEtD,0DAA0D,GAC1D,OAAO,MAAMC,kCAAkC,KAAK;AAEpD,uFAAuF,GACvF,OAAO,MAAMC,4BAA8D;IACzEC,cAAc,GAAGlH,eAAemH,YAAY,EAAE;IAC9CC,QAAQ,GAAGpH,eAAeqH,MAAM,EAAE;AACpC,EAAC;AAED,6FAA6F,GAC7F,OAAO,SAASC;IACd,MAAMpF,aAAa;QACjBqF,YAAY;YACVnG,OAAO;gBAAC;oBAAEC,MAAM;oBAAU+D,MAAMxD,OAAOC,IAAI,CAACoF;gBAA2B;gBAAG;oBAAE5F,MAAM;gBAAO;aAAE;YAC3FjB,aAAa;QACf;QACAoH,YAAYjH,KAAK;QACjBkH,mBAAmBlH,KACjB,CAAC,2EAA2E,EAAEwG,oCAAoC,YAAY,CAAC;QAEjIW,cAAcnH,KAAK;QACnBoH,kBAAkBpH,KAAK;QACvBqH,aAAarH,KAAK;QAClBsH,WAAWtH,KACT,CAAC,qFAAqF,EAAEyG,gCAAgC,YAAY,CAAC;QAEvIc,UAAUvH,KACR,CAAC,yEAAyE,EAAEyG,gCAAgC,YAAY,CAAC;IAE7H;IACA,OAAO;QACLzE,MAAMuE;QACN1G,aACE;QACFoC,QAAQ;QACRC,aAAa;YACXpB,MAAM;YACNa;YACAQ,UAAUd,OAAOC,IAAI,CAACK;YACtBS,sBAAsB;QACxB;IACF;AACF;AASA,MAAMoF,QAAQ;AAEd;;;;CAIC,GACD,OAAO,SAASC,eACdlF,MAA+B,EAC/BC,OAA8B;IAM9B,MAAMC,SAAsD,CAAC;IAC7D,MAAMiF,QAAkB,EAAE;IAC1B,MAAMhF,aAAoC,EAAE;IAC5C,MAAMC,YAAqC,CAAC;IAC5C,MAAMgF,MAAM,CAAC/F,OAA2BlB;QACtC,IAAIA,SAAS8B,QAAQoF,KAAK,CAACnG,GAAG,CAACG,QAAQa,MAAM,CAACb,MAAM,GAAGlB;IACzD;IAEA,MAAMI,OAAOL,SAAS8B,MAAM,CAAC,aAAa;IAC1C,IAAIzB,QAAQ4F,yBAAyB,CAAC5F,KAAK,EAAE6G,IAAI,mBAAmBjB,yBAAyB,CAAC5F,KAAK;IACnG6G,IAAI,mBAAmBlH,SAAS8B,MAAM,CAAC,aAAa,EAAEgC,KAAK,CAAC,GAAG;IAE/D,MAAM1E,cAAcY,SAAS8B,MAAM,CAAC,oBAAoB;IACxD,IAAI1C,YAAYwD,MAAM,GAAGmD,qCAAqC;QAC5D9D,WAAWM,IAAI,CAAC;YACdC,MAAM;YACNC,MAAM;YACNC,SAAS,CAAC,0BAA0B,EAAEtD,YAAYwD,MAAM,CAAC,0BAA0B,EAAEmD,oCAAoC,CAAC,CAAC;QAC7H;QACA7D,SAAS,CAAC,oBAAoB,GAAG9C;IACnC,OAAO;QACL8H,IAAI,0BAA0B9H;IAChC;IAEA,MAAMgI,WAAWrF,QAAQqF,QAAQ,CAACzH,WAAW;IAC7C,MAAM0H,aAAatF,QAAQqF,QAAQ,CAAClH,OAAO,CAAC,QAAQ;IACpD,MAAMoH,QAAQtH,SAAS8B,MAAM,CAAC,eAAe;IAC7C,IAAIwF,OAAO;QACT,IAAIP,MAAMQ,IAAI,CAACD,UAAUF,SAASI,QAAQ,CAACF,MAAM3H,WAAW,KAAKuH,IAAI,oBAAoBI;aACpFL,MAAM1E,IAAI,CAAC;IAClB;IACA,MAAMkF,YAAYzH,SAAS8B,MAAM,CAAC,mBAAmB;IACrD,IAAI2F,WAAW;QACb,MAAMC,SAASD,UAAUvH,OAAO,CAAC,QAAQ;QACzC,IAAIwH,OAAO9E,MAAM,IAAI,KAAKyE,WAAWG,QAAQ,CAACE,SAASR,IAAI,wBAAwBO;aAC9ER,MAAM1E,IAAI,CAAC;IAClB;IACA,IAAIP,MAAM,CAAC,mBAAmB,IAAIA,MAAM,CAAC,uBAAuB,EAAE;QAChEkF,IAAI,0BAA0BlH,SAAS8B,MAAM,CAAC,cAAc,EAAEgC,KAAK,CAAC,GAAG;IACzE;IAEA,KAAK,MAAM,CAAC/C,KAAKI,MAAM,IAAI;QACzB;YAAC;YAAa;SAAsB;QACpC;YAAC;YAAY;SAAqB;KACnC,CAAW;QACV,MAAMwG,WAAW3H,SAAS8B,MAAM,CAACf,IAAI;QACrC,IAAI4G,SAAS/E,MAAM,GAAGoD,iCAAiC;YACrD/D,WAAWM,IAAI,CAAC;gBACdC,MAAM;gBACNC,MAAM;gBACNC,SAAS,GAAG3B,IAAI,IAAI,EAAE4G,SAAS/E,MAAM,CAAC,0BAA0B,EAAEoD,gCAAgC,CAAC,CAAC;YACtG;YACA9D,SAAS,CAACnB,IAAI,GAAG4G;QACnB,OAAO;YACLT,IAAI/F,OAAOwG;QACb;IACF;IACA,OAAO1F,WAAWW,MAAM,GAAG;QAAE3C,OAAO;QAAMgC;QAAYC;IAAU,IAAI;QAAEjC,OAAO;YAAE+B;YAAQiF;QAAM;QAAGhF,YAAY,EAAE;IAAC;AACjH"}
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type TriggerCombinator, type TriggerConditionOp } from '@aglyn/aglyn/app-utils/
|
|
18
|
-
import { type ContactLifecycleStage, type CrmActivityKind, type CrmTaskKind } from '@aglyn/aglyn/app-utils/crm';
|
|
17
|
+
import { type TriggerCombinator, type TriggerConditionOp } from '@aglyn/aglyn/app-utils/site-interactions';
|
|
18
|
+
import { type ContactLifecycleStage, type CrmActivityDirection, type CrmActivityKind, type CrmTaskKind, type CrmTaskPriority } from '@aglyn/aglyn/app-utils/crm';
|
|
19
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';
|
|
@@ -53,8 +53,15 @@ import type { AiGenerationCheckResult } from '../runtime/ai-doctrine';
|
|
|
53
53
|
* - a variant serves every step type that carries the same fields, named for
|
|
54
54
|
* what they hold: the six steps that name a record share `reference`, and
|
|
55
55
|
* the three that carry one line share `text`;
|
|
56
|
-
* - every field stays required and every vocabulary stays an enum,
|
|
57
|
-
* model is still decoded into the Actions editor's words.
|
|
56
|
+
* - every field stays required and every vocabulary but one stays an enum,
|
|
57
|
+
* so the model is still decoded into the Actions editor's words. The one
|
|
58
|
+
* is the event a waitForEvent step waits for: the trigger's `event` already
|
|
59
|
+
* compiles the host events' enum, and a second copy of it is what a task's
|
|
60
|
+
* priority and a logged call's direction cost (AGL-3538). It is a string
|
|
61
|
+
* described as one of the trigger events, and the reader refuses any
|
|
62
|
+
* other naming every event, so the one re-ask can fix it;
|
|
63
|
+
* - a description is free: a compiler drops it (`aiToolSchemaCompiledBytes`),
|
|
64
|
+
* so the words a field needs go there and never into the grammar.
|
|
58
65
|
*
|
|
59
66
|
* Ten variants carry all seventeen step types, and the reader below maps the
|
|
60
67
|
* shared fields back onto the step's own key.
|
|
@@ -125,8 +132,12 @@ export interface AiAutomationAnswerStep {
|
|
|
125
132
|
tag: string | null;
|
|
126
133
|
owner: string | null;
|
|
127
134
|
taskKind: CrmTaskKind | null;
|
|
135
|
+
/** A created task's priority, by meaning (AGL-3538). */
|
|
136
|
+
priority: CrmTaskPriority | null;
|
|
128
137
|
dueInDays: number | null;
|
|
129
138
|
activityKind: CrmActivityKind | null;
|
|
139
|
+
/** Which way a logged call or email went (AGL-3538); `null` for every other kind. */
|
|
140
|
+
direction: CrmActivityDirection | null;
|
|
130
141
|
}
|
|
131
142
|
export interface AiAutomationAnswer {
|
|
132
143
|
name: string;
|