@aglyn/plugins-ai 1.0.0-beta.272 → 1.0.0-beta.274
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-site-start-card.component.js +237 -234
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/jobs/ai-empty-site-sweep.d.ts +12 -1
- package/src/lib/jobs/ai-empty-site-sweep.js +15 -3
- package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.js +21 -2
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-seo-step.js +8 -4
- package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-datasets.d.ts +39 -1
- package/src/lib/jobs/ai-job-site-datasets.js +111 -15
- package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +12 -3
- package/src/lib/jobs/ai-job-site-step.js +85 -11
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +8 -0
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.d.ts +3 -0
- package/src/lib/jobs/ai-jobs.js +4 -0
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-layout-owner-photos.js +4 -4
- package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -1
- package/src/lib/jobs/ai-layout-stock-photos.d.ts +35 -0
- package/src/lib/jobs/ai-layout-stock-photos.js +114 -43
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- package/src/lib/jobs/ai-site-late-photos.d.ts +143 -0
- package/src/lib/jobs/ai-site-late-photos.js +486 -0
- package/src/lib/jobs/ai-site-late-photos.js.map +1 -0
- package/src/lib/jobs/ai-site-publish.d.ts +18 -0
- package/src/lib/jobs/ai-site-publish.js +123 -5
- package/src/lib/jobs/ai-site-publish.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.d.ts +83 -0
- package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js +1553 -0
- package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js.map +1 -0
- package/src/lib/layout-language/ai-layout-pictures.d.ts +50 -13
- package/src/lib/layout-language/ai-layout-pictures.js +81 -19
- package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +10 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-site-photos.d.ts +119 -0
- package/src/lib/model/ai-site-photos.js +128 -0
- package/src/lib/model/ai-site-photos.js.map +1 -0
- package/src/lib/runtime/ai-doctrine-validators.d.ts +3 -1
- package/src/lib/runtime/ai-doctrine-validators.js +3 -1
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/seo-fields.d.ts +29 -4
- package/src/lib/runtime/seo-fields.js +58 -11
- package/src/lib/runtime/seo-fields.js.map +1 -1
- package/src/lib/tools/ai-seo-tool.d.ts +25 -1
- package/src/lib/tools/ai-seo-tool.js +61 -0
- package/src/lib/tools/ai-seo-tool.js.map +1 -1
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields';
|
|
18
18
|
import type { AiSeoFieldValues } from '../model/ai-seo';
|
|
19
19
|
import type { AiStepKind } from '../providers/catalog';
|
|
20
|
-
import type
|
|
20
|
+
import { type AiProvider } from '../providers/contract';
|
|
21
21
|
import { type AiPluginSettings } from '../providers/routing';
|
|
22
22
|
import { type AiValidatedGeneration } from './ai-doctrine';
|
|
23
|
+
import type { AiDoctrineViolation } from './ai-doctrine-validators';
|
|
23
24
|
import type { AiSystemBlock } from './ai-runtime';
|
|
24
25
|
/**
|
|
25
26
|
* SEO fields by AI (AGL-2910): a search listing — title, description,
|
|
@@ -44,6 +45,14 @@ import type { AiSystemBlock } from './ai-runtime';
|
|
|
44
45
|
* page's text, its current listing, the site's name and the target keywords
|
|
45
46
|
* ride in the user turn, so no byte of a site sits inside the cached prefix,
|
|
46
47
|
* and no site inventory is sent: a listing is written from its page.
|
|
48
|
+
*
|
|
49
|
+
* ## A length is fitted, not failed (AGL-4016)
|
|
50
|
+
*
|
|
51
|
+
* An answer is asked for once and re-asked once with the faults named. When
|
|
52
|
+
* the re-ask still breaks nothing but a length, the fields that ran over are
|
|
53
|
+
* cut to their limits (`aiFitSeoText`) and the whole check runs again, so a
|
|
54
|
+
* title five characters long no longer fails the listing. Any other fault, a
|
|
55
|
+
* numbered rule, or an answer cut off at its ceiling still ends `needs_input`.
|
|
47
56
|
*/
|
|
48
57
|
/**
|
|
49
58
|
* The output budget, from the routing table. The largest answer the tool
|
|
@@ -145,8 +154,24 @@ export declare const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[];
|
|
|
145
154
|
* page it builds (`fields: ['title', 'description']`), and the `seo` job
|
|
146
155
|
* calls it for a page's or a product's whole listing. It asks the routing
|
|
147
156
|
* table's `job.seo` model through the doctrine's generation call and resolves
|
|
148
|
-
* to its result — `ok` with the values
|
|
149
|
-
*
|
|
150
|
-
* declined. It
|
|
157
|
+
* to its result — `ok` with the values (a re-ask that still ran past a
|
|
158
|
+
* length fitted to it, AGL-4016), `needs_input` when the answer could not be
|
|
159
|
+
* held to the editor after one re-ask, `refused` when the model declined. It
|
|
160
|
+
* writes nothing.
|
|
151
161
|
*/
|
|
152
162
|
export declare function generateSeoFields(input: GenerateSeoFieldsInput): Promise<AiValidatedGeneration<AiSeoFieldValues>>;
|
|
163
|
+
/**
|
|
164
|
+
* A generation that ended `needs_input` over lengths alone, ended `ok` with
|
|
165
|
+
* those lengths fitted (AGL-4016); any other outcome as it came.
|
|
166
|
+
*
|
|
167
|
+
* The last answer is fitted only when no numbered rule was broken and the
|
|
168
|
+
* answer was not cut off at its ceiling (a cut-off answer is refused whatever
|
|
169
|
+
* its prefix reads as, AGL-3143), and it is kept only when the whole check
|
|
170
|
+
* then passes: a fault other than a length is still there after fitting, so
|
|
171
|
+
* the generation stays `needs_input` with its own reasons. Nothing is asked
|
|
172
|
+
* again and nothing more is spent.
|
|
173
|
+
*/
|
|
174
|
+
export declare function aiFitSeoOverrun<T>(kind: string, generation: AiValidatedGeneration<T>, fit: (answer: Record<string, unknown>) => Record<string, unknown> | null, check: (answer: Record<string, unknown>) => {
|
|
175
|
+
value: T | null;
|
|
176
|
+
violations: readonly AiDoctrineViolation[];
|
|
177
|
+
}): AiValidatedGeneration<T>;
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { aiStoppedAtCeiling } from "../providers/contract.js";
|
|
17
18
|
import { AI_ROUTING_TABLE, aiRouteSettings } from "../providers/routing.js";
|
|
18
|
-
import { AI_SEO_FIELDS_TOOL_NAME, aiSeoFieldsTool, checkAiSeoFields, orderedSeoListingFields } from "../tools/ai-seo-tool.js";
|
|
19
|
+
import { AI_SEO_FIELDS_TOOL_NAME, aiSeoFieldsTool, checkAiSeoFields, fitAiSeoFieldLengths, orderedSeoListingFields } from "../tools/ai-seo-tool.js";
|
|
19
20
|
import { runValidatedGeneration } from "./ai-doctrine.js";
|
|
20
21
|
/**
|
|
21
22
|
* SEO fields by AI (AGL-2910): a search listing — title, description,
|
|
@@ -40,6 +41,14 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
|
|
|
40
41
|
* page's text, its current listing, the site's name and the target keywords
|
|
41
42
|
* ride in the user turn, so no byte of a site sits inside the cached prefix,
|
|
42
43
|
* and no site inventory is sent: a listing is written from its page.
|
|
44
|
+
*
|
|
45
|
+
* ## A length is fitted, not failed (AGL-4016)
|
|
46
|
+
*
|
|
47
|
+
* An answer is asked for once and re-asked once with the faults named. When
|
|
48
|
+
* the re-ask still breaks nothing but a length, the fields that ran over are
|
|
49
|
+
* cut to their limits (`aiFitSeoText`) and the whole check runs again, so a
|
|
50
|
+
* title five characters long no longer fails the listing. Any other fault, a
|
|
51
|
+
* numbered rule, or an answer cut off at its ceiling still ends `needs_input`.
|
|
43
52
|
*/ /* ------------------------------------------------------------------------ *
|
|
44
53
|
* The request
|
|
45
54
|
* ------------------------------------------------------------------------ */ /**
|
|
@@ -166,9 +175,10 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
|
|
|
166
175
|
* page it builds (`fields: ['title', 'description']`), and the `seo` job
|
|
167
176
|
* calls it for a page's or a product's whole listing. It asks the routing
|
|
168
177
|
* table's `job.seo` model through the doctrine's generation call and resolves
|
|
169
|
-
* to its result — `ok` with the values
|
|
170
|
-
*
|
|
171
|
-
* declined. It
|
|
178
|
+
* to its result — `ok` with the values (a re-ask that still ran past a
|
|
179
|
+
* length fitted to it, AGL-4016), `needs_input` when the answer could not be
|
|
180
|
+
* held to the editor after one re-ask, `refused` when the model declined. It
|
|
181
|
+
* writes nothing.
|
|
172
182
|
*/ export async function generateSeoFields(input) {
|
|
173
183
|
var _input_fields, _input_keywords, _input_step, _input_maxTokens;
|
|
174
184
|
var _input_otherTitles;
|
|
@@ -176,7 +186,13 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
|
|
|
176
186
|
const fields = orderedSeoListingFields((_input_fields = input.fields) != null ? _input_fields : AI_SEO_DEFAULT_FIELDS).filter((key)=>key !== 'imageAlt' || hasImage);
|
|
177
187
|
const keywords = (_input_keywords = input.keywords) != null ? _input_keywords : [];
|
|
178
188
|
const step = (_input_step = input.step) != null ? _input_step : 'job.seo';
|
|
179
|
-
|
|
189
|
+
const check = (answer)=>checkAiSeoFields(answer, {
|
|
190
|
+
fields,
|
|
191
|
+
hasImage,
|
|
192
|
+
keywords,
|
|
193
|
+
otherTitles: input.otherTitles
|
|
194
|
+
});
|
|
195
|
+
const generation = await runValidatedGeneration('seo-fields', _extends({
|
|
180
196
|
step
|
|
181
197
|
}, aiRouteSettings(step), input.settings ? {
|
|
182
198
|
settings: input.settings
|
|
@@ -204,13 +220,44 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
|
|
|
204
220
|
} : {}, input.provider ? {
|
|
205
221
|
provider: input.provider
|
|
206
222
|
} : {}, {
|
|
207
|
-
check
|
|
208
|
-
fields,
|
|
209
|
-
hasImage,
|
|
210
|
-
keywords,
|
|
211
|
-
otherTitles: input.otherTitles
|
|
212
|
-
})
|
|
223
|
+
check
|
|
213
224
|
}));
|
|
225
|
+
return aiFitSeoOverrun('seo-fields', generation, (answer)=>fitAiSeoFieldLengths(answer, fields), check);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* A generation that ended `needs_input` over lengths alone, ended `ok` with
|
|
229
|
+
* those lengths fitted (AGL-4016); any other outcome as it came.
|
|
230
|
+
*
|
|
231
|
+
* The last answer is fitted only when no numbered rule was broken and the
|
|
232
|
+
* answer was not cut off at its ceiling (a cut-off answer is refused whatever
|
|
233
|
+
* its prefix reads as, AGL-3143), and it is kept only when the whole check
|
|
234
|
+
* then passes: a fault other than a length is still there after fitting, so
|
|
235
|
+
* the generation stays `needs_input` with its own reasons. Nothing is asked
|
|
236
|
+
* again and nothing more is spent.
|
|
237
|
+
*/ export function aiFitSeoOverrun(kind, generation, fit, check) {
|
|
238
|
+
if (generation.status !== 'needs_input' || !generation.answer) return generation;
|
|
239
|
+
if (aiStoppedAtCeiling(generation.stopReason)) return generation;
|
|
240
|
+
if (generation.violations.some((violation)=>violation.rule !== null)) return generation;
|
|
241
|
+
const fitted = fit(generation.answer);
|
|
242
|
+
if (!fitted) return generation;
|
|
243
|
+
const checked = check(fitted);
|
|
244
|
+
if (checked.value === null || checked.violations.length) return generation;
|
|
245
|
+
console.info('ai seo answer fitted to its lengths', {
|
|
246
|
+
kind,
|
|
247
|
+
model: generation.model,
|
|
248
|
+
faults: generation.violations.map((violation)=>violation.code)
|
|
249
|
+
});
|
|
250
|
+
const { attempts, usage, estCostUsd, model, stopReason, effort } = generation;
|
|
251
|
+
return {
|
|
252
|
+
attempts,
|
|
253
|
+
usage,
|
|
254
|
+
estCostUsd,
|
|
255
|
+
model,
|
|
256
|
+
stopReason,
|
|
257
|
+
effort,
|
|
258
|
+
status: 'ok',
|
|
259
|
+
value: checked.value
|
|
260
|
+
};
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
//# sourceMappingURL=seo-fields.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.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 type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport type { AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, aiRouteSettings, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values, `needs_input` when the answer could\n * not be held to the editor after one re-ask, `refused` when the model\n * declined. It writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n const step = input.step ?? 'job.seo'\n return runValidatedGeneration('seo-fields', {\n step,\n // The row's thinking and effort (AGL-3781): a listing's ceiling holds the\n // answer alone.\n ...aiRouteSettings(step),\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check: (answer) =>\n checkAiSeoFields(answer, {\n fields,\n hasImage,\n keywords,\n otherTitles: input.otherTitles,\n }),\n })\n}\n"],"names":["AI_ROUTING_TABLE","aiRouteSettings","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","settings","model","instructions","messages","role","content","tool","signal","provider","check","answer"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.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 type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport { aiStoppedAtCeiling, type AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, aiRouteSettings, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n fitAiSeoFieldLengths,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiDoctrineViolation } from './ai-doctrine-validators'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n *\n * ## A length is fitted, not failed (AGL-4016)\n *\n * An answer is asked for once and re-asked once with the faults named. When\n * the re-ask still breaks nothing but a length, the fields that ran over are\n * cut to their limits (`aiFitSeoText`) and the whole check runs again, so a\n * title five characters long no longer fails the listing. Any other fault, a\n * numbered rule, or an answer cut off at its ceiling still ends `needs_input`.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values (a re-ask that still ran past a\n * length fitted to it, AGL-4016), `needs_input` when the answer could not be\n * held to the editor after one re-ask, `refused` when the model declined. It\n * writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n const step = input.step ?? 'job.seo'\n const check = (answer: Record<string, unknown>) =>\n checkAiSeoFields(answer, { fields, hasImage, keywords, otherTitles: input.otherTitles })\n const generation = await runValidatedGeneration('seo-fields', {\n step,\n // The row's thinking and effort (AGL-3781): a listing's ceiling holds the\n // answer alone.\n ...aiRouteSettings(step),\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check,\n })\n return aiFitSeoOverrun('seo-fields', generation, (answer) => fitAiSeoFieldLengths(answer, fields), check)\n}\n\n/**\n * A generation that ended `needs_input` over lengths alone, ended `ok` with\n * those lengths fitted (AGL-4016); any other outcome as it came.\n *\n * The last answer is fitted only when no numbered rule was broken and the\n * answer was not cut off at its ceiling (a cut-off answer is refused whatever\n * its prefix reads as, AGL-3143), and it is kept only when the whole check\n * then passes: a fault other than a length is still there after fitting, so\n * the generation stays `needs_input` with its own reasons. Nothing is asked\n * again and nothing more is spent.\n */\nexport function aiFitSeoOverrun<T>(\n kind: string,\n generation: AiValidatedGeneration<T>,\n fit: (answer: Record<string, unknown>) => Record<string, unknown> | null,\n check: (answer: Record<string, unknown>) => { value: T | null; violations: readonly AiDoctrineViolation[] },\n): AiValidatedGeneration<T> {\n if (generation.status !== 'needs_input' || !generation.answer) return generation\n if (aiStoppedAtCeiling(generation.stopReason)) return generation\n if (generation.violations.some((violation) => violation.rule !== null)) return generation\n const fitted = fit(generation.answer)\n if (!fitted) return generation\n const checked = check(fitted)\n if (checked.value === null || checked.violations.length) return generation\n console.info('ai seo answer fitted to its lengths', {\n kind,\n model: generation.model,\n faults: generation.violations.map((violation) => violation.code),\n })\n const { attempts, usage, estCostUsd, model, stopReason, effort } = generation\n return { attempts, usage, estCostUsd, model, stopReason, effort, status: 'ok', value: checked.value }\n}\n"],"names":["aiStoppedAtCeiling","AI_ROUTING_TABLE","aiRouteSettings","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","fitAiSeoFieldLengths","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","check","answer","generation","settings","model","instructions","messages","role","content","tool","signal","provider","aiFitSeoOverrun","fit","status","stopReason","violations","some","violation","fitted","checked","console","info","faults","code","attempts","usage","estCostUsd","effort"],"mappings":"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"}
|
|
@@ -19,7 +19,7 @@ import { type SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fiel
|
|
|
19
19
|
import type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts';
|
|
20
20
|
import type { AiSeoFieldValues, AiSeoSiteFormField } from '../model/ai-seo';
|
|
21
21
|
import type { AiTool } from '../providers/contract';
|
|
22
|
-
import type
|
|
22
|
+
import { type AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
|
|
23
23
|
/**
|
|
24
24
|
* The strict tools SEO by AI answers through (AGL-2910), and the checks that
|
|
25
25
|
* hold each answer to the editor it proposes for.
|
|
@@ -40,6 +40,24 @@ import type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
|
|
|
40
40
|
*/
|
|
41
41
|
/** The most times one keyword may appear, per field, before it reads as stuffing. */
|
|
42
42
|
export declare const AI_SEO_KEYWORD_LIMITS: Readonly<Partial<Record<SeoListingFieldKey, number>>>;
|
|
43
|
+
/**
|
|
44
|
+
* One text held to `max` characters, cut the way the page step cuts the
|
|
45
|
+
* listing it stores (`aiClipAtWord`, AGL-3813): at a word, with no joining
|
|
46
|
+
* mark or dangling word left at the end.
|
|
47
|
+
*
|
|
48
|
+
* The model is told every length and re-asked once with the count it ran
|
|
49
|
+
* past, and a fast model still overruns by a few characters: counting
|
|
50
|
+
* characters is the one thing it reliably does badly. MindFullMe's listing
|
|
51
|
+
* failed on 2026-10-20 over a 65-character title, twice. So an answer whose
|
|
52
|
+
* only fault is a length is fitted rather than thrown away.
|
|
53
|
+
*/
|
|
54
|
+
export declare function aiFitSeoText(value: string, max: number): string;
|
|
55
|
+
/**
|
|
56
|
+
* A listing answer with every text field past its length fitted to it, or
|
|
57
|
+
* `null` when no field ran over. Nothing else in the answer changes; the
|
|
58
|
+
* caller runs `checkAiSeoFields` on the result again.
|
|
59
|
+
*/
|
|
60
|
+
export declare function fitAiSeoFieldLengths(answer: Record<string, unknown>, fields: readonly SeoListingFieldKey[]): Record<string, unknown> | null;
|
|
43
61
|
export declare const AI_SEO_FIELDS_TOOL_NAME = "propose_search_listing";
|
|
44
62
|
/** The fields a listing proposal must fill; the image description may be left out. */
|
|
45
63
|
export declare const AI_SEO_REQUIRED_FIELDS: ReadonlySet<SeoListingFieldKey>;
|
|
@@ -124,6 +142,12 @@ export declare function checkAiSeoFixes(answer: Record<string, unknown>, pages:
|
|
|
124
142
|
violations: AiDoctrineViolation[];
|
|
125
143
|
offending?: Record<string, unknown>;
|
|
126
144
|
};
|
|
145
|
+
/**
|
|
146
|
+
* A batch answer with each page's title, description, heading and image
|
|
147
|
+
* descriptions fitted to their lengths (AGL-4016), or `null` when nothing
|
|
148
|
+
* ran over. The caller runs `checkAiSeoFixes` on the result again.
|
|
149
|
+
*/
|
|
150
|
+
export declare function fitAiSeoFixLengths(answer: Record<string, unknown>): Record<string, unknown> | null;
|
|
127
151
|
export declare const AI_SEO_SITE_TOOL_NAME = "propose_site_listing";
|
|
128
152
|
/** The longest structured-data description the site SEO form accepts. */
|
|
129
153
|
export declare const AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS = 300;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -18,6 +19,7 @@ import { seoKeywordCount } from "@aglyn/aglyn/app-utils/seo-keywords";
|
|
|
18
19
|
import { SEO_LISTING_FIELDS } from "@aglyn/aglyn/app-utils/seo-listing-fields";
|
|
19
20
|
import { HostEntityType } from "@aglyn/aglyn/foundation/definitions/platform.types";
|
|
20
21
|
import { AI_TEXT_LIMITS } from "../runtime/ai-palette.js";
|
|
22
|
+
import { aiClipAtWord } from "../runtime/ai-doctrine-validators.js";
|
|
21
23
|
/**
|
|
22
24
|
* The strict tools SEO by AI answers through (AGL-2910), and the checks that
|
|
23
25
|
* hold each answer to the editor it proposes for.
|
|
@@ -66,6 +68,36 @@ const text = (description)=>({
|
|
|
66
68
|
],
|
|
67
69
|
description
|
|
68
70
|
});
|
|
71
|
+
/* ------------------------------------------------------------------------ *
|
|
72
|
+
* Fitting an answer that ran past a length (AGL-4016)
|
|
73
|
+
* ------------------------------------------------------------------------ */ /**
|
|
74
|
+
* One text held to `max` characters, cut the way the page step cuts the
|
|
75
|
+
* listing it stores (`aiClipAtWord`, AGL-3813): at a word, with no joining
|
|
76
|
+
* mark or dangling word left at the end.
|
|
77
|
+
*
|
|
78
|
+
* The model is told every length and re-asked once with the count it ran
|
|
79
|
+
* past, and a fast model still overruns by a few characters: counting
|
|
80
|
+
* characters is the one thing it reliably does badly. MindFullMe's listing
|
|
81
|
+
* failed on 2026-10-20 over a 65-character title, twice. So an answer whose
|
|
82
|
+
* only fault is a length is fitted rather than thrown away.
|
|
83
|
+
*/ export function aiFitSeoText(value, max) {
|
|
84
|
+
return aiClipAtWord(collapse(value), max);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A listing answer with every text field past its length fitted to it, or
|
|
88
|
+
* `null` when no field ran over. Nothing else in the answer changes; the
|
|
89
|
+
* caller runs `checkAiSeoFields` on the result again.
|
|
90
|
+
*/ export function fitAiSeoFieldLengths(answer, fields) {
|
|
91
|
+
let fitted = null;
|
|
92
|
+
for (const key of orderedSeoListingFields(fields)){
|
|
93
|
+
const raw = answer[key];
|
|
94
|
+
const { maxLength } = SEO_LISTING_FIELDS[key];
|
|
95
|
+
if (typeof raw !== 'string' || collapse(raw).length <= maxLength) continue;
|
|
96
|
+
fitted != null ? fitted : fitted = _extends({}, answer);
|
|
97
|
+
fitted[key] = aiFitSeoText(raw, maxLength);
|
|
98
|
+
}
|
|
99
|
+
return fitted;
|
|
100
|
+
}
|
|
69
101
|
/* ------------------------------------------------------------------------ *
|
|
70
102
|
* One listing, from the editor's field list
|
|
71
103
|
* ------------------------------------------------------------------------ */ export const AI_SEO_FIELDS_TOOL_NAME = 'propose_search_listing';
|
|
@@ -395,6 +427,35 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
|
395
427
|
violations: []
|
|
396
428
|
};
|
|
397
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* A batch answer with each page's title, description, heading and image
|
|
432
|
+
* descriptions fitted to their lengths (AGL-4016), or `null` when nothing
|
|
433
|
+
* ran over. The caller runs `checkAiSeoFixes` on the result again.
|
|
434
|
+
*/ export function fitAiSeoFixLengths(answer) {
|
|
435
|
+
if (!Array.isArray(answer['pages'])) return null;
|
|
436
|
+
let changed = false;
|
|
437
|
+
const fit = (raw, max)=>{
|
|
438
|
+
if (typeof raw !== 'string' || collapse(raw).length <= max) return raw;
|
|
439
|
+
changed = true;
|
|
440
|
+
return aiFitSeoText(raw, max);
|
|
441
|
+
};
|
|
442
|
+
const pages = answer['pages'].map((raw)=>{
|
|
443
|
+
if (!raw || typeof raw !== 'object') return raw;
|
|
444
|
+
const entry = raw;
|
|
445
|
+
return _extends({}, entry, {
|
|
446
|
+
title: fit(entry['title'], SEO_LISTING_FIELDS.title.maxLength),
|
|
447
|
+
description: fit(entry['description'], SEO_LISTING_FIELDS.description.maxLength),
|
|
448
|
+
h1: fit(entry['h1'], AI_TEXT_LIMITS.headline)
|
|
449
|
+
}, Array.isArray(entry['imageAlts']) ? {
|
|
450
|
+
imageAlts: entry['imageAlts'].map((alt)=>alt && typeof alt === 'object' ? _extends({}, alt, {
|
|
451
|
+
alt: fit(alt['alt'], AI_SEO_FIX_ALT_MAX_CHARS)
|
|
452
|
+
}) : alt)
|
|
453
|
+
} : {});
|
|
454
|
+
});
|
|
455
|
+
return changed ? _extends({}, answer, {
|
|
456
|
+
pages
|
|
457
|
+
}) : null;
|
|
458
|
+
}
|
|
398
459
|
/* ------------------------------------------------------------------------ *
|
|
399
460
|
* The site: structured data and guidance for AI agents
|
|
400
461
|
* ------------------------------------------------------------------------ */ export const AI_SEO_SITE_TOOL_NAME = 'propose_site_listing';
|
|
@@ -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/**\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":"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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 { aiClipAtWord, 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 * Fitting an answer that ran past a length (AGL-4016)\n * ------------------------------------------------------------------------ */\n\n/**\n * One text held to `max` characters, cut the way the page step cuts the\n * listing it stores (`aiClipAtWord`, AGL-3813): at a word, with no joining\n * mark or dangling word left at the end.\n *\n * The model is told every length and re-asked once with the count it ran\n * past, and a fast model still overruns by a few characters: counting\n * characters is the one thing it reliably does badly. MindFullMe's listing\n * failed on 2026-10-20 over a 65-character title, twice. So an answer whose\n * only fault is a length is fitted rather than thrown away.\n */\nexport function aiFitSeoText(value: string, max: number): string {\n return aiClipAtWord(collapse(value), max)\n}\n\n/**\n * A listing answer with every text field past its length fitted to it, or\n * `null` when no field ran over. Nothing else in the answer changes; the\n * caller runs `checkAiSeoFields` on the result again.\n */\nexport function fitAiSeoFieldLengths(\n answer: Record<string, unknown>,\n fields: readonly SeoListingFieldKey[],\n): Record<string, unknown> | null {\n let fitted: Record<string, unknown> | null = null\n for (const key of orderedSeoListingFields(fields)) {\n const raw = answer[key]\n const { maxLength } = SEO_LISTING_FIELDS[key]\n if (typeof raw !== 'string' || collapse(raw).length <= maxLength) continue\n fitted ??= { ...answer }\n fitted[key] = aiFitSeoText(raw, maxLength)\n }\n return fitted\n}\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 * A batch answer with each page's title, description, heading and image\n * descriptions fitted to their lengths (AGL-4016), or `null` when nothing\n * ran over. The caller runs `checkAiSeoFixes` on the result again.\n */\nexport function fitAiSeoFixLengths(answer: Record<string, unknown>): Record<string, unknown> | null {\n if (!Array.isArray(answer['pages'])) return null\n let changed = false\n const fit = (raw: unknown, max: number) => {\n if (typeof raw !== 'string' || collapse(raw).length <= max) return raw\n changed = true\n return aiFitSeoText(raw, max)\n }\n const pages = (answer['pages'] as unknown[]).map((raw) => {\n if (!raw || typeof raw !== 'object') return raw\n const entry = raw as Record<string, unknown>\n return {\n ...entry,\n title: fit(entry['title'], SEO_LISTING_FIELDS.title.maxLength),\n description: fit(entry['description'], SEO_LISTING_FIELDS.description.maxLength),\n h1: fit(entry['h1'], AI_TEXT_LIMITS.headline),\n ...(Array.isArray(entry['imageAlts'])\n ? {\n imageAlts: (entry['imageAlts'] as unknown[]).map((alt) =>\n alt && typeof alt === 'object'\n ? {\n ...(alt as Record<string, unknown>),\n alt: fit((alt as Record<string, unknown>)['alt'], AI_SEO_FIX_ALT_MAX_CHARS),\n }\n : alt,\n ),\n }\n : {}),\n }\n })\n return changed ? { ...answer, pages } : null\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","aiClipAtWord","AI_SEO_KEYWORD_LIMITS","title","description","breadcrumb","repeatedWord","text","counts","Map","word","toLowerCase","match","count","get","set","collapse","value","replace","trim","anyOf","type","aiFitSeoText","max","fitAiSeoFieldLengths","answer","fields","fitted","key","orderedSeoListingFields","raw","maxLength","length","AI_SEO_FIELDS_TOOL_NAME","AI_SEO_REQUIRED_FIELDS","Set","wanted","Object","keys","filter","has","aiSeoFieldsTool","properties","field","label","purpose","name","strict","inputSchema","required","additionalProperties","normalizeTitle","checkAiSeoFields","context","values","violations","offending","otherTitles","map","undefined","push","rule","code","message","hasImage","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","fitAiSeoFixLengths","changed","fit","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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"}
|