pagegraph 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -4
- package/dist/cli.js +1691 -623
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -20616,7 +20616,7 @@ const Boolean$5 = class extends ASTNodeImpl {
|
|
|
20616
20616
|
* @category constructors
|
|
20617
20617
|
* @since 4.0.0
|
|
20618
20618
|
*/
|
|
20619
|
-
const boolean = /*#__PURE__*/ new Boolean$5();
|
|
20619
|
+
const boolean$1 = /*#__PURE__*/ new Boolean$5();
|
|
20620
20620
|
/**
|
|
20621
20621
|
* Constructs a {@link BigInt}.
|
|
20622
20622
|
*
|
|
@@ -25834,7 +25834,7 @@ const Number$2 = /*#__PURE__*/ make$14(number);
|
|
|
25834
25834
|
* @category schemas
|
|
25835
25835
|
* @since 4.0.0
|
|
25836
25836
|
*/
|
|
25837
|
-
const Boolean$4 = /*#__PURE__*/ make$14(boolean);
|
|
25837
|
+
const Boolean$4 = /*#__PURE__*/ make$14(boolean$1);
|
|
25838
25838
|
/**
|
|
25839
25839
|
* Schema for `bigint` values. Validates that the input is `typeof` `"bigint"`.
|
|
25840
25840
|
*
|
|
@@ -26424,6 +26424,28 @@ const makeFilter = makeFilter$1;
|
|
|
26424
26424
|
*/
|
|
26425
26425
|
const Finite$3 = /*#__PURE__*/ make$14(finite);
|
|
26426
26426
|
/**
|
|
26427
|
+
* Creates a greater-than-or-equal-to (`>=`) check for any ordered type from an
|
|
26428
|
+
* `Order.Order` instance.
|
|
26429
|
+
*
|
|
26430
|
+
* @category validation
|
|
26431
|
+
* @since 4.0.0
|
|
26432
|
+
*/
|
|
26433
|
+
function makeIsGreaterThanOrEqualTo(options) {
|
|
26434
|
+
const gte = isGreaterThanOrEqualTo$1(options.order);
|
|
26435
|
+
const formatter = options.formatter ?? format$2;
|
|
26436
|
+
return (minimum, annotations) => {
|
|
26437
|
+
return makeFilter((input) => gte(input, minimum), {
|
|
26438
|
+
expected: `a value greater than or equal to ${formatter(minimum)}`,
|
|
26439
|
+
arbitraryConstraint: {
|
|
26440
|
+
order: options.order,
|
|
26441
|
+
minimum
|
|
26442
|
+
},
|
|
26443
|
+
...options.annotate?.(minimum),
|
|
26444
|
+
...annotations
|
|
26445
|
+
});
|
|
26446
|
+
};
|
|
26447
|
+
}
|
|
26448
|
+
/**
|
|
26427
26449
|
* Creates an inclusive or exclusive range check for any ordered type from an
|
|
26428
26450
|
* `Order.Order` instance.
|
|
26429
26451
|
*
|
|
@@ -26458,6 +26480,35 @@ function encodeNumberPayload(number) {
|
|
|
26458
26480
|
return number;
|
|
26459
26481
|
}
|
|
26460
26482
|
/**
|
|
26483
|
+
* Validates that a number is greater than or equal to the specified value
|
|
26484
|
+
* (inclusive).
|
|
26485
|
+
*
|
|
26486
|
+
* **Details**
|
|
26487
|
+
*
|
|
26488
|
+
* JSON Schema:
|
|
26489
|
+
*
|
|
26490
|
+
* This check corresponds to the `minimum` constraint in JSON Schema.
|
|
26491
|
+
*
|
|
26492
|
+
* Arbitrary:
|
|
26493
|
+
*
|
|
26494
|
+
* During arbitrary generation, this applies a `minimum` constraint
|
|
26495
|
+
* to ensure generated numbers are greater than or equal to the specified value.
|
|
26496
|
+
*
|
|
26497
|
+
* @category validation
|
|
26498
|
+
* @since 4.0.0
|
|
26499
|
+
*/
|
|
26500
|
+
const isGreaterThanOrEqualTo = /*#__PURE__*/ makeIsGreaterThanOrEqualTo({
|
|
26501
|
+
order: Number$6,
|
|
26502
|
+
annotate: (minimum) => ({
|
|
26503
|
+
representation: {
|
|
26504
|
+
id: "effect/schema/isGreaterThanOrEqualTo",
|
|
26505
|
+
payload: { minimum: encodeNumberPayload(minimum) }
|
|
26506
|
+
},
|
|
26507
|
+
toJsonSchema: () => ({ minimum }),
|
|
26508
|
+
toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualTo(${format$2(minimum)})` })
|
|
26509
|
+
})
|
|
26510
|
+
});
|
|
26511
|
+
/**
|
|
26461
26512
|
* Validates that a number is within a specified range. The range boundaries can
|
|
26462
26513
|
* be inclusive or exclusive based on the provided options.
|
|
26463
26514
|
*
|
|
@@ -26538,6 +26589,19 @@ function isInt(annotations) {
|
|
|
26538
26589
|
* @since 3.10.0
|
|
26539
26590
|
*/
|
|
26540
26591
|
const Int$4 = /*#__PURE__*/ Number$2.check(/*#__PURE__*/ isInt());
|
|
26592
|
+
/**
|
|
26593
|
+
* Schema for non-negative safe integers, including zero.
|
|
26594
|
+
*
|
|
26595
|
+
* **When to use**
|
|
26596
|
+
*
|
|
26597
|
+
* Use when you need a count, index, or size that cannot be negative.
|
|
26598
|
+
*
|
|
26599
|
+
* @see {@link Int} for safe integers that may be negative
|
|
26600
|
+
*
|
|
26601
|
+
* @category schemas
|
|
26602
|
+
* @since 4.0.0
|
|
26603
|
+
*/
|
|
26604
|
+
const Natural = /*#__PURE__*/ Int$4.check(/*#__PURE__*/ isGreaterThanOrEqualTo(0));
|
|
26541
26605
|
const getErrorOptionsKey = (options) => (options?.includeStack === true ? 1 : 0) | (options?.excludeCause === true ? 2 : 0);
|
|
26542
26606
|
const getErrorOptions = (key) => {
|
|
26543
26607
|
switch (key) {
|
|
@@ -34612,7 +34676,7 @@ const runWith = (command, config) => {
|
|
|
34612
34676
|
};
|
|
34613
34677
|
//#endregion
|
|
34614
34678
|
//#region package.json
|
|
34615
|
-
var version = "0.
|
|
34679
|
+
var version = "0.7.0";
|
|
34616
34680
|
//#endregion
|
|
34617
34681
|
//#region src/core/projections.ts
|
|
34618
34682
|
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -35051,7 +35115,7 @@ const CONFIG_FILENAMES = [
|
|
|
35051
35115
|
"seo.config.js",
|
|
35052
35116
|
"seo.config.mjs"
|
|
35053
35117
|
];
|
|
35054
|
-
const messageOf$
|
|
35118
|
+
const messageOf$2 = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
35055
35119
|
/** First `seo.config.*` at or above `from`, or undefined at the filesystem root. */
|
|
35056
35120
|
const findConfigFile = (from) => {
|
|
35057
35121
|
let directory = resolve(from);
|
|
@@ -35079,7 +35143,7 @@ const loadConfigFile = (configPath) => gen(function* () {
|
|
|
35079
35143
|
yield* logDebug(`Loading SEO config from ${configPath}`);
|
|
35080
35144
|
const module = yield* tryPromise({
|
|
35081
35145
|
try: () => import(pathToFileURL(configPath).href),
|
|
35082
|
-
catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$
|
|
35146
|
+
catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$2(cause)}` })
|
|
35083
35147
|
});
|
|
35084
35148
|
if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
|
|
35085
35149
|
return module.default;
|
|
@@ -35114,8 +35178,8 @@ const acquireGraph = (config) => gen(function* () {
|
|
|
35114
35178
|
yield* logDebug("Loading the SEO graph…");
|
|
35115
35179
|
const loaded = yield* acquireRelease(tryPromise({
|
|
35116
35180
|
try: () => config.loadGraph(),
|
|
35117
|
-
catch: (cause) => new SeoCliError({ message: messageOf$
|
|
35118
|
-
}), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$
|
|
35181
|
+
catch: (cause) => new SeoCliError({ message: messageOf$2(cause) })
|
|
35182
|
+
}), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$2(defect)}`))));
|
|
35119
35183
|
yield* logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
|
|
35120
35184
|
return loaded.graph;
|
|
35121
35185
|
});
|
|
@@ -35351,8 +35415,8 @@ const VERDICT_LABELS = {
|
|
|
35351
35415
|
review: "Review"
|
|
35352
35416
|
};
|
|
35353
35417
|
const decisionLine = (record) => [
|
|
35354
|
-
` ${record.
|
|
35355
|
-
` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)}`,
|
|
35418
|
+
` ${record.from} ${record.direction === "both" ? "↔" : "→"} ${record.to}`,
|
|
35419
|
+
` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)} · direction ${record.direction} · relevance ${record.relevance.label}`,
|
|
35356
35420
|
` "${record.sourceText}"`
|
|
35357
35421
|
];
|
|
35358
35422
|
const verdictBlock = (verdict, records) => {
|
|
@@ -35368,8 +35432,9 @@ const verdictBlock = (verdict, records) => {
|
|
|
35368
35432
|
*/
|
|
35369
35433
|
const renderLinksDecideReport = (report) => {
|
|
35370
35434
|
const { counts } = report;
|
|
35435
|
+
const budget = report.budget === null ? "none" : `${report.budget}/source`;
|
|
35371
35436
|
const lines = [
|
|
35372
|
-
`${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
|
|
35437
|
+
`${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)} · budget ${budget} · dropped ${report.dropped}`,
|
|
35373
35438
|
"",
|
|
35374
35439
|
` recommend ${counts.recommend}`,
|
|
35375
35440
|
` present ${counts.present}`,
|
|
@@ -37748,7 +37813,7 @@ const requestTimeoutMs$1 = Int("request-timeout-ms").pipe(withDescription$2("HTT
|
|
|
37748
37813
|
const scannerTimeoutMs = Int("scanner-timeout-ms").pipe(withDescription$2("Lighthouse and hosted scanner timeout in milliseconds"), withDefault(18e4));
|
|
37749
37814
|
const maxBodyBytes$1 = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes"), withDefault(2e6));
|
|
37750
37815
|
const outputDir = String$2("output-dir").pipe(withDescription$2("Atomically write timestamped JSON and Markdown artifacts"), optional$1);
|
|
37751
|
-
const positive$
|
|
37816
|
+
const positive$2 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
37752
37817
|
const auditCommand = make$9("audit", {
|
|
37753
37818
|
urls,
|
|
37754
37819
|
json: jsonFlag,
|
|
@@ -37776,11 +37841,11 @@ const auditCommand = make$9("audit", {
|
|
|
37776
37841
|
description: "Audit a local app"
|
|
37777
37842
|
}
|
|
37778
37843
|
]), withHandler(fn("SeoCli.audit")(function* (options) {
|
|
37779
|
-
const checkedRuns = yield* positive$
|
|
37780
|
-
const checkedConcurrency = yield* positive$
|
|
37781
|
-
const checkedRequestTimeout = yield* positive$
|
|
37782
|
-
const checkedScannerTimeout = yield* positive$
|
|
37783
|
-
const checkedMaxBody = yield* positive$
|
|
37844
|
+
const checkedRuns = yield* positive$2("runs", options.runs);
|
|
37845
|
+
const checkedConcurrency = yield* positive$2("concurrency", options.concurrency);
|
|
37846
|
+
const checkedRequestTimeout = yield* positive$2("request-timeout-ms", options.requestTimeoutMs);
|
|
37847
|
+
const checkedScannerTimeout = yield* positive$2("scanner-timeout-ms", options.scannerTimeoutMs);
|
|
37848
|
+
const checkedMaxBody = yield* positive$2("max-body-bytes", options.maxBodyBytes);
|
|
37784
37849
|
const scanners = [
|
|
37785
37850
|
makeHttpScanner({
|
|
37786
37851
|
allowPrivate: options.allowPrivate,
|
|
@@ -37830,342 +37895,48 @@ const auditCommand = make$9("audit", {
|
|
|
37830
37895
|
if (structural.length > 0 || http.every((result) => result.status === "error")) return yield* new SeoCliError({ message: `${structural.length} structural finding(s); see the report above.` });
|
|
37831
37896
|
})));
|
|
37832
37897
|
//#endregion
|
|
37833
|
-
//#region
|
|
37834
|
-
const
|
|
37835
|
-
const
|
|
37836
|
-
|
|
37837
|
-
|
|
37838
|
-
|
|
37839
|
-
|
|
37840
|
-
|
|
37841
|
-
|
|
37842
|
-
|
|
37843
|
-
|
|
37844
|
-
|
|
37845
|
-
|
|
37846
|
-
|
|
37847
|
-
|
|
37848
|
-
|
|
37849
|
-
description: "Emit one versioned JSON diff on stdout"
|
|
37850
|
-
}]), withHandler(fn("SeoCli.diff")(function* (options) {
|
|
37851
|
-
const beforeReport = yield* readAuditReport("before", options.before);
|
|
37852
|
-
const afterReport = yield* readAuditReport("after", options.after);
|
|
37853
|
-
const comparison = compareAuditReports(beforeReport, afterReport);
|
|
37854
|
-
if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
|
|
37855
|
-
if (options.json) yield* printJson(comparison.diff);
|
|
37856
|
-
else yield* printText(renderAuditDiff(comparison.diff));
|
|
37857
|
-
if (comparison.diff.outcome === "regressed") {
|
|
37858
|
-
const summary = comparison.diff.summary;
|
|
37859
|
-
return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
|
|
37860
|
-
}
|
|
37861
|
-
})));
|
|
37862
|
-
//#endregion
|
|
37863
|
-
//#region src/cli/serialize.ts
|
|
37864
|
-
const serializeCrumb = (crumb) => {
|
|
37865
|
-
if (crumb === void 0) return void 0;
|
|
37866
|
-
return typeof crumb === "function" ? "(dynamic)" : crumb;
|
|
37898
|
+
//#region node_modules/effect/dist/unstable/http/HttpBody.js
|
|
37899
|
+
const TypeId$9 = "~effect/http/HttpBody";
|
|
37900
|
+
const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
|
|
37901
|
+
/**
|
|
37902
|
+
* Error produced while constructing an HTTP body from JSON or schema-encoded input.
|
|
37903
|
+
*
|
|
37904
|
+
* @category errors
|
|
37905
|
+
* @since 4.0.0
|
|
37906
|
+
*/
|
|
37907
|
+
var HttpBodyError = class extends TaggedError$1("HttpBodyError") {
|
|
37908
|
+
/**
|
|
37909
|
+
* Marks this value as an HTTP body error for runtime guards.
|
|
37910
|
+
*
|
|
37911
|
+
* @since 4.0.0
|
|
37912
|
+
*/
|
|
37913
|
+
[HttpBodyErrorTypeId] = HttpBodyErrorTypeId;
|
|
37867
37914
|
};
|
|
37868
|
-
|
|
37869
|
-
|
|
37870
|
-
|
|
37871
|
-
|
|
37872
|
-
source: node.source,
|
|
37873
|
-
policy: {
|
|
37874
|
-
kind: node.policy.kind,
|
|
37875
|
-
crumb: serializeCrumb(node.policy.crumb),
|
|
37876
|
-
sitemap: node.policy.sitemap,
|
|
37877
|
-
robots: node.policy.robots,
|
|
37878
|
-
related: node.policy.related,
|
|
37879
|
-
link: node.policy.link,
|
|
37880
|
-
redirectTo: node.policy.redirectTo
|
|
37881
|
-
},
|
|
37882
|
-
instance: node.instance
|
|
37883
|
-
});
|
|
37884
|
-
/** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
|
|
37885
|
-
const serializeGraph = (graph) => ({
|
|
37886
|
-
nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
|
|
37887
|
-
edges: graph.edges
|
|
37888
|
-
});
|
|
37889
|
-
const graphCommand = make$9("graph", {
|
|
37890
|
-
format: Literals("format", [
|
|
37891
|
-
"tree",
|
|
37892
|
-
"mermaid",
|
|
37893
|
-
"json"
|
|
37894
|
-
]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
|
|
37895
|
-
orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
|
|
37896
|
-
}).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
|
|
37897
|
-
{
|
|
37898
|
-
command: "pagegraph graph",
|
|
37899
|
-
description: "The graph as an indented path tree"
|
|
37900
|
-
},
|
|
37901
|
-
{
|
|
37902
|
-
command: "pagegraph graph --format mermaid",
|
|
37903
|
-
description: "A Mermaid diagram of nodes and edges"
|
|
37904
|
-
},
|
|
37905
|
-
{
|
|
37906
|
-
command: "pagegraph graph --orphans",
|
|
37907
|
-
description: "Only nodes with no incoming edge"
|
|
37908
|
-
},
|
|
37909
|
-
{
|
|
37910
|
-
command: "pagegraph graph --format json",
|
|
37911
|
-
description: "The serialized graph on stdout"
|
|
37915
|
+
var Proto$3 = class {
|
|
37916
|
+
[TypeId$9];
|
|
37917
|
+
constructor() {
|
|
37918
|
+
this[TypeId$9] = TypeId$9;
|
|
37912
37919
|
}
|
|
37913
|
-
]
|
|
37914
|
-
|
|
37915
|
-
const graph = yield* scoped(acquireGraph(config));
|
|
37916
|
-
if (format === "json") {
|
|
37917
|
-
const serialized = serializeGraph(graph);
|
|
37918
|
-
if (!orphans) return yield* printJson(serialized);
|
|
37919
|
-
const orphanSet = orphanPaths(graph);
|
|
37920
|
-
return yield* printJson({
|
|
37921
|
-
nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
|
|
37922
|
-
edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
|
|
37923
|
-
});
|
|
37920
|
+
[NodeInspectSymbol]() {
|
|
37921
|
+
return this.toJSON();
|
|
37924
37922
|
}
|
|
37925
|
-
|
|
37926
|
-
|
|
37927
|
-
})));
|
|
37928
|
-
//#endregion
|
|
37929
|
-
//#region src/core/inspect-html.ts
|
|
37930
|
-
const ENTITIES = {
|
|
37931
|
-
"&": "&",
|
|
37932
|
-
"<": "<",
|
|
37933
|
-
">": ">",
|
|
37934
|
-
""": "\"",
|
|
37935
|
-
"'": "'",
|
|
37936
|
-
"'": "'"
|
|
37937
|
-
};
|
|
37938
|
-
const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
|
|
37939
|
-
/** Pull double/single-quoted attributes off a single tag string. */
|
|
37940
|
-
const parseAttrs = (tag) => {
|
|
37941
|
-
const attrs = {};
|
|
37942
|
-
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
37943
|
-
let match;
|
|
37944
|
-
while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
|
|
37945
|
-
return attrs;
|
|
37946
|
-
};
|
|
37947
|
-
/** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
|
|
37948
|
-
const typeName = (value) => {
|
|
37949
|
-
if (typeof value === "string") return value;
|
|
37950
|
-
if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
|
|
37951
|
-
return "unknown";
|
|
37952
|
-
};
|
|
37953
|
-
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37954
|
-
/** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
|
|
37955
|
-
const collectItems = (parsed) => {
|
|
37956
|
-
if (Array.isArray(parsed)) return parsed.filter(isObject);
|
|
37957
|
-
if (isObject(parsed)) {
|
|
37958
|
-
if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
|
|
37959
|
-
return [parsed];
|
|
37923
|
+
toString() {
|
|
37924
|
+
return format$2(this, { ignoreToString: true });
|
|
37960
37925
|
}
|
|
37961
|
-
return [];
|
|
37962
|
-
};
|
|
37963
|
-
const countQuestions = (mainEntity) => {
|
|
37964
|
-
if (!Array.isArray(mainEntity)) return 0;
|
|
37965
|
-
return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
|
|
37966
|
-
};
|
|
37967
|
-
const validateItemListElements = (value) => {
|
|
37968
|
-
if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
|
|
37969
|
-
const errors = [];
|
|
37970
|
-
value.forEach((entry, index) => {
|
|
37971
|
-
if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
|
|
37972
|
-
errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
|
|
37973
|
-
return;
|
|
37974
|
-
}
|
|
37975
|
-
if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
|
|
37976
|
-
if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
|
|
37977
|
-
});
|
|
37978
|
-
return errors;
|
|
37979
37926
|
};
|
|
37980
|
-
/**
|
|
37981
|
-
|
|
37982
|
-
|
|
37983
|
-
|
|
37984
|
-
|
|
37985
|
-
|
|
37986
|
-
|
|
37987
|
-
|
|
37988
|
-
|
|
37989
|
-
|
|
37990
|
-
|
|
37991
|
-
|
|
37992
|
-
|
|
37993
|
-
errors.push(...validateItemListElements(item["itemListElement"]));
|
|
37994
|
-
const items = item["itemListElement"];
|
|
37995
|
-
if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
|
|
37996
|
-
}
|
|
37997
|
-
return {
|
|
37998
|
-
type,
|
|
37999
|
-
valid: errors.length === 0,
|
|
38000
|
-
errors
|
|
38001
|
-
};
|
|
38002
|
-
};
|
|
38003
|
-
const validateLdJson = (raw) => {
|
|
38004
|
-
let parsed;
|
|
38005
|
-
try {
|
|
38006
|
-
parsed = JSON.parse(raw);
|
|
38007
|
-
} catch (cause) {
|
|
38008
|
-
return [{
|
|
38009
|
-
type: "unparseable",
|
|
38010
|
-
valid: false,
|
|
38011
|
-
errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
|
|
38012
|
-
}];
|
|
38013
|
-
}
|
|
38014
|
-
const items = collectItems(parsed);
|
|
38015
|
-
if (items.length === 0) return [{
|
|
38016
|
-
type: "unknown",
|
|
38017
|
-
valid: false,
|
|
38018
|
-
errors: ["No JSON-LD object found in block."]
|
|
38019
|
-
}];
|
|
38020
|
-
return items.map(validateItem);
|
|
38021
|
-
};
|
|
38022
|
-
/** Parse a rendered HTML document's `<head>` into a report. Pure. */
|
|
38023
|
-
const inspectHtml = (url, status, html) => {
|
|
38024
|
-
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
38025
|
-
const head = headMatch ? headMatch[1] : html;
|
|
38026
|
-
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
38027
|
-
const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
|
|
38028
|
-
const og = {};
|
|
38029
|
-
const twitter = {};
|
|
38030
|
-
let description;
|
|
38031
|
-
let robots;
|
|
38032
|
-
for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
38033
|
-
const attrs = parseAttrs(tag);
|
|
38034
|
-
const content = attrs["content"];
|
|
38035
|
-
if (content === void 0) continue;
|
|
38036
|
-
const property = attrs["property"];
|
|
38037
|
-
const name = attrs["name"];
|
|
38038
|
-
if (property?.startsWith("og:")) og[property] = content;
|
|
38039
|
-
else if (name?.startsWith("twitter:")) twitter[name] = content;
|
|
38040
|
-
else if (name === "description") description = content;
|
|
38041
|
-
else if (name === "robots") robots = content;
|
|
38042
|
-
}
|
|
38043
|
-
let canonical;
|
|
38044
|
-
for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
|
|
38045
|
-
const attrs = parseAttrs(tag);
|
|
38046
|
-
if (attrs["rel"] === "canonical") canonical = attrs["href"];
|
|
38047
|
-
}
|
|
38048
|
-
const jsonLd = [];
|
|
38049
|
-
const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
38050
|
-
let scriptMatch;
|
|
38051
|
-
while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
|
|
38052
|
-
const issues = [];
|
|
38053
|
-
if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
|
|
38054
|
-
if (!title) issues.push("Missing <title>.");
|
|
38055
|
-
if (!description) issues.push("Missing meta description.");
|
|
38056
|
-
if (!canonical) issues.push("Missing canonical link.");
|
|
38057
|
-
for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
|
|
38058
|
-
return {
|
|
38059
|
-
url,
|
|
38060
|
-
status,
|
|
38061
|
-
title,
|
|
38062
|
-
description,
|
|
38063
|
-
canonical,
|
|
38064
|
-
robots,
|
|
38065
|
-
og,
|
|
38066
|
-
twitter,
|
|
38067
|
-
jsonLd,
|
|
38068
|
-
issues
|
|
38069
|
-
};
|
|
38070
|
-
};
|
|
38071
|
-
/** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
|
|
38072
|
-
const hasBlockingIssues = (report) => report.issues.length > 0;
|
|
38073
|
-
//#endregion
|
|
38074
|
-
//#region src/cli/live-inspect.ts
|
|
38075
|
-
/**
|
|
38076
|
-
* `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
|
|
38077
|
-
* validator in `../inspect-html`. The fetch is all that lives here — the
|
|
38078
|
-
* validation is a library capability, not a CLI one, so it stays out of the
|
|
38079
|
-
* Effect-bearing half of the package.
|
|
38080
|
-
*/
|
|
38081
|
-
/** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
|
|
38082
|
-
const fetchAndInspect = (url) => tryPromise({
|
|
38083
|
-
try: async () => {
|
|
38084
|
-
const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
|
|
38085
|
-
const html = await response.text();
|
|
38086
|
-
return inspectHtml(url, response.status, html);
|
|
38087
|
-
},
|
|
38088
|
-
catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
38089
|
-
});
|
|
38090
|
-
const inspectCommand = make$9("inspect", {
|
|
38091
|
-
target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
|
|
38092
|
-
live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
|
|
38093
|
-
json: jsonFlag
|
|
38094
|
-
}).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
|
|
38095
|
-
{
|
|
38096
|
-
command: "pagegraph inspect /pricing",
|
|
38097
|
-
description: "The graph node, policy, and edges for a path"
|
|
38098
|
-
},
|
|
38099
|
-
{
|
|
38100
|
-
command: "pagegraph inspect https://example.com/pricing --live",
|
|
38101
|
-
description: "Fetch the page and validate its head tags + JSON-LD"
|
|
38102
|
-
},
|
|
38103
|
-
{
|
|
38104
|
-
command: "pagegraph inspect /blog/some-post --json",
|
|
38105
|
-
description: "The node report as JSON"
|
|
38106
|
-
}
|
|
38107
|
-
]), withHandler(fnUntraced(function* ({ target, live, json }) {
|
|
38108
|
-
if (live) {
|
|
38109
|
-
const report = yield* fetchAndInspect(target);
|
|
38110
|
-
if (json) yield* printJson(report);
|
|
38111
|
-
else yield* printText(renderLiveReport(report));
|
|
38112
|
-
if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
|
|
38113
|
-
return;
|
|
38114
|
-
}
|
|
38115
|
-
const config = yield* loadSeoConfig;
|
|
38116
|
-
const report = inspectNode(yield* scoped(acquireGraph(config)), target);
|
|
38117
|
-
if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
|
|
38118
|
-
if (json) return yield* printJson({
|
|
38119
|
-
node: serializeNode(report.node),
|
|
38120
|
-
inSitemap: report.inSitemap,
|
|
38121
|
-
incoming: report.incoming,
|
|
38122
|
-
outgoing: report.outgoing
|
|
38123
|
-
});
|
|
38124
|
-
return yield* printText(renderNodeReport(report));
|
|
38125
|
-
})));
|
|
38126
|
-
//#endregion
|
|
38127
|
-
//#region node_modules/effect/dist/unstable/http/HttpBody.js
|
|
38128
|
-
const TypeId$9 = "~effect/http/HttpBody";
|
|
38129
|
-
const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
|
|
38130
|
-
/**
|
|
38131
|
-
* Error produced while constructing an HTTP body from JSON or schema-encoded input.
|
|
38132
|
-
*
|
|
38133
|
-
* @category errors
|
|
38134
|
-
* @since 4.0.0
|
|
38135
|
-
*/
|
|
38136
|
-
var HttpBodyError = class extends TaggedError$1("HttpBodyError") {
|
|
38137
|
-
/**
|
|
38138
|
-
* Marks this value as an HTTP body error for runtime guards.
|
|
38139
|
-
*
|
|
38140
|
-
* @since 4.0.0
|
|
38141
|
-
*/
|
|
38142
|
-
[HttpBodyErrorTypeId] = HttpBodyErrorTypeId;
|
|
38143
|
-
};
|
|
38144
|
-
var Proto$3 = class {
|
|
38145
|
-
[TypeId$9];
|
|
38146
|
-
constructor() {
|
|
38147
|
-
this[TypeId$9] = TypeId$9;
|
|
38148
|
-
}
|
|
38149
|
-
[NodeInspectSymbol]() {
|
|
38150
|
-
return this.toJSON();
|
|
38151
|
-
}
|
|
38152
|
-
toString() {
|
|
38153
|
-
return format$2(this, { ignoreToString: true });
|
|
38154
|
-
}
|
|
38155
|
-
};
|
|
38156
|
-
/**
|
|
38157
|
-
* HTTP body variant representing the absence of request content.
|
|
38158
|
-
*
|
|
38159
|
-
* @category models
|
|
38160
|
-
* @since 4.0.0
|
|
38161
|
-
*/
|
|
38162
|
-
var Empty = class extends Proto$3 {
|
|
38163
|
-
_tag = "Empty";
|
|
38164
|
-
toJSON() {
|
|
38165
|
-
return {
|
|
38166
|
-
_id: "effect/HttpBody",
|
|
38167
|
-
_tag: "Empty"
|
|
38168
|
-
};
|
|
37927
|
+
/**
|
|
37928
|
+
* HTTP body variant representing the absence of request content.
|
|
37929
|
+
*
|
|
37930
|
+
* @category models
|
|
37931
|
+
* @since 4.0.0
|
|
37932
|
+
*/
|
|
37933
|
+
var Empty = class extends Proto$3 {
|
|
37934
|
+
_tag = "Empty";
|
|
37935
|
+
toJSON() {
|
|
37936
|
+
return {
|
|
37937
|
+
_id: "effect/HttpBody",
|
|
37938
|
+
_tag: "Empty"
|
|
37939
|
+
};
|
|
38169
37940
|
}
|
|
38170
37941
|
};
|
|
38171
37942
|
/**
|
|
@@ -40911,7 +40682,7 @@ const invalidOutput = (description) => make$5({
|
|
|
40911
40682
|
reason: new InvalidOutputError({ description })
|
|
40912
40683
|
});
|
|
40913
40684
|
const isFiniteNumber = (value) => isNumber(value) && Number.isFinite(value);
|
|
40914
|
-
const isUnitInterval = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
|
40685
|
+
const isUnitInterval$1 = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
|
|
40915
40686
|
const validateDistribution = (key, labels, answer) => {
|
|
40916
40687
|
const raw = answer.probabilities;
|
|
40917
40688
|
if (!isObject$1(raw)) return invalidOutput(`Provider returned no probabilities for decision "${key}"`);
|
|
@@ -40919,7 +40690,7 @@ const validateDistribution = (key, labels, answer) => {
|
|
|
40919
40690
|
let total = 0;
|
|
40920
40691
|
for (const label of labels) {
|
|
40921
40692
|
const value = raw[label];
|
|
40922
|
-
if (!isUnitInterval(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
|
|
40693
|
+
if (!isUnitInterval$1(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
|
|
40923
40694
|
probabilities[label] = value;
|
|
40924
40695
|
total += value;
|
|
40925
40696
|
}
|
|
@@ -40928,7 +40699,7 @@ const validateDistribution = (key, labels, answer) => {
|
|
|
40928
40699
|
};
|
|
40929
40700
|
const validateConfidence = (key, answer) => {
|
|
40930
40701
|
if (answer.confidence === void 0) return;
|
|
40931
|
-
return isUnitInterval(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
|
|
40702
|
+
return isUnitInterval$1(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
|
|
40932
40703
|
};
|
|
40933
40704
|
const validateAnswer = (key, decision, answer) => {
|
|
40934
40705
|
if (!isObject$1(answer)) return invalidOutput(`Provider returned no answer for decision "${key}"`);
|
|
@@ -40964,7 +40735,7 @@ const validateAnswer = (key, decision, answer) => {
|
|
|
40964
40735
|
};
|
|
40965
40736
|
}
|
|
40966
40737
|
case "Probability":
|
|
40967
|
-
if (!isUnitInterval(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
|
|
40738
|
+
if (!isUnitInterval$1(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
|
|
40968
40739
|
return { probability: answer.probability };
|
|
40969
40740
|
}
|
|
40970
40741
|
};
|
|
@@ -41229,76 +41000,1571 @@ const make$1 = /*#__PURE__*/ fnUntraced(function* (options) {
|
|
|
41229
41000
|
*/
|
|
41230
41001
|
const layer = (options) => effect(DecisionModel, make$1(options));
|
|
41231
41002
|
//#endregion
|
|
41232
|
-
//#region
|
|
41233
|
-
/**
|
|
41234
|
-
|
|
41235
|
-
|
|
41236
|
-
|
|
41237
|
-
|
|
41238
|
-
|
|
41239
|
-
|
|
41240
|
-
|
|
41241
|
-
|
|
41242
|
-
|
|
41243
|
-
|
|
41244
|
-
|
|
41245
|
-
|
|
41246
|
-
|
|
41247
|
-
|
|
41248
|
-
|
|
41249
|
-
|
|
41250
|
-
|
|
41251
|
-
|
|
41252
|
-
|
|
41003
|
+
//#region node_modules/effect/dist/unstable/ai/Decision.js
|
|
41004
|
+
/**
|
|
41005
|
+
* Brand for decision definitions.
|
|
41006
|
+
*
|
|
41007
|
+
* @category type IDs
|
|
41008
|
+
* @since 4.0.0
|
|
41009
|
+
*/
|
|
41010
|
+
const TypeId = "~effect/ai/Decision";
|
|
41011
|
+
/**
|
|
41012
|
+
* Creates a classification decision from labelled criteria.
|
|
41013
|
+
* Throws if fewer than two labels are supplied.
|
|
41014
|
+
*
|
|
41015
|
+
* **Example** (Choosing a department)
|
|
41016
|
+
*
|
|
41017
|
+
* ```ts
|
|
41018
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41019
|
+
*
|
|
41020
|
+
* const department = Decision.classify({
|
|
41021
|
+
* instructions: "Which team should handle this",
|
|
41022
|
+
* criteria: {
|
|
41023
|
+
* billing: "payments",
|
|
41024
|
+
* technical: "bugs",
|
|
41025
|
+
* sales: "pricing"
|
|
41026
|
+
* }
|
|
41027
|
+
* })
|
|
41028
|
+
* ```
|
|
41029
|
+
*
|
|
41030
|
+
* @see {@link rate} for an ordered scale
|
|
41031
|
+
* @see {@link probability} for a single yes or no likelihood
|
|
41032
|
+
*
|
|
41033
|
+
* @category constructors
|
|
41034
|
+
* @since 4.0.0
|
|
41035
|
+
*/
|
|
41036
|
+
const classify = (options) => {
|
|
41037
|
+
if (Object.keys(options.criteria).length < 2) throw new Error("Decision.classify: criteria must contain at least two labels");
|
|
41038
|
+
return {
|
|
41039
|
+
_tag: "Classify",
|
|
41040
|
+
instructions: options.instructions,
|
|
41041
|
+
criteria: options.criteria
|
|
41042
|
+
};
|
|
41253
41043
|
};
|
|
41254
41044
|
/**
|
|
41255
|
-
*
|
|
41256
|
-
*
|
|
41257
|
-
*
|
|
41045
|
+
* Creates a rating decision from an ordered list of criteria.
|
|
41046
|
+
* Throws if fewer than two levels or duplicate levels are supplied.
|
|
41047
|
+
*
|
|
41048
|
+
* **Example** (Rating frustration)
|
|
41049
|
+
*
|
|
41050
|
+
* ```ts
|
|
41051
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41052
|
+
*
|
|
41053
|
+
* const frustration = Decision.rate({
|
|
41054
|
+
* instructions: "How frustrated",
|
|
41055
|
+
* criteria: ["calm", "frustrated", "angry"]
|
|
41056
|
+
* })
|
|
41057
|
+
* ```
|
|
41058
|
+
*
|
|
41059
|
+
* @see {@link classify} for unordered labels
|
|
41060
|
+
*
|
|
41061
|
+
* @category constructors
|
|
41062
|
+
* @since 4.0.0
|
|
41258
41063
|
*/
|
|
41259
|
-
const
|
|
41260
|
-
|
|
41261
|
-
|
|
41262
|
-
|
|
41263
|
-
|
|
41264
|
-
|
|
41265
|
-
|
|
41266
|
-
if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
|
|
41267
|
-
if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
|
|
41268
|
-
const origin = seedUrl.origin;
|
|
41269
|
-
const probeOptions = {
|
|
41270
|
-
allowPrivate: options.allowPrivate,
|
|
41271
|
-
timeoutMs: options.timeoutMs,
|
|
41272
|
-
maxBodyBytes: options.maxBodyBytes,
|
|
41273
|
-
captureAnchors: true
|
|
41064
|
+
const rate = (options) => {
|
|
41065
|
+
if (options.criteria.length < 2) throw new Error("Decision.rate: criteria must contain at least two levels");
|
|
41066
|
+
if (new Set(options.criteria).size !== options.criteria.length) throw new Error("Decision.rate: criteria must contain distinct levels");
|
|
41067
|
+
return {
|
|
41068
|
+
_tag: "Rate",
|
|
41069
|
+
instructions: options.instructions,
|
|
41070
|
+
criteria: options.criteria
|
|
41274
41071
|
};
|
|
41275
|
-
|
|
41276
|
-
|
|
41277
|
-
|
|
41278
|
-
|
|
41279
|
-
|
|
41280
|
-
|
|
41281
|
-
|
|
41282
|
-
|
|
41283
|
-
|
|
41284
|
-
|
|
41285
|
-
|
|
41286
|
-
|
|
41287
|
-
|
|
41288
|
-
|
|
41289
|
-
|
|
41290
|
-
|
|
41291
|
-
|
|
41292
|
-
|
|
41293
|
-
|
|
41294
|
-
|
|
41295
|
-
|
|
41296
|
-
|
|
41297
|
-
|
|
41298
|
-
|
|
41299
|
-
|
|
41300
|
-
|
|
41301
|
-
|
|
41072
|
+
};
|
|
41073
|
+
/**
|
|
41074
|
+
* Creates a probability decision from descriptions of both outcomes.
|
|
41075
|
+
*
|
|
41076
|
+
* **Example** (Estimating urgency)
|
|
41077
|
+
*
|
|
41078
|
+
* ```ts
|
|
41079
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41080
|
+
*
|
|
41081
|
+
* const urgent = Decision.probability({
|
|
41082
|
+
* instructions: "The message is time-sensitive",
|
|
41083
|
+
* criteria: {
|
|
41084
|
+
* false: "No time pressure",
|
|
41085
|
+
* true: "Needs action now"
|
|
41086
|
+
* }
|
|
41087
|
+
* })
|
|
41088
|
+
* ```
|
|
41089
|
+
*
|
|
41090
|
+
* @see {@link classify} for more than two outcomes
|
|
41091
|
+
*
|
|
41092
|
+
* @category constructors
|
|
41093
|
+
* @since 4.0.0
|
|
41094
|
+
*/
|
|
41095
|
+
const probability = (options) => ({
|
|
41096
|
+
_tag: "Probability",
|
|
41097
|
+
instructions: options.instructions,
|
|
41098
|
+
criteria: options.criteria
|
|
41099
|
+
});
|
|
41100
|
+
/**
|
|
41101
|
+
* Creates a decision definition from an input schema and named decisions.
|
|
41102
|
+
* `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
|
|
41103
|
+
* calling the provider. Answer keys and types are inferred from the decisions.
|
|
41104
|
+
* Throws if `decisions` is empty.
|
|
41105
|
+
*
|
|
41106
|
+
* **Example** (Defining ticket triage)
|
|
41107
|
+
*
|
|
41108
|
+
* ```ts
|
|
41109
|
+
* import { Schema } from "effect"
|
|
41110
|
+
* import { Decision } from "effect/unstable/ai"
|
|
41111
|
+
*
|
|
41112
|
+
* const Ticket = Schema.Struct({
|
|
41113
|
+
* subject: Schema.String,
|
|
41114
|
+
* body: Schema.String
|
|
41115
|
+
* })
|
|
41116
|
+
*
|
|
41117
|
+
* const TicketTriage = Decision.make({
|
|
41118
|
+
* input: Ticket,
|
|
41119
|
+
* decisions: {
|
|
41120
|
+
* department: Decision.classify({
|
|
41121
|
+
* instructions: "Which team should handle this",
|
|
41122
|
+
* criteria: { billing: "payments", technical: "bugs" }
|
|
41123
|
+
* }),
|
|
41124
|
+
* urgent: Decision.probability({
|
|
41125
|
+
* instructions: "The message is time-sensitive",
|
|
41126
|
+
* criteria: { false: "No time pressure", true: "Needs action now" }
|
|
41127
|
+
* })
|
|
41128
|
+
* }
|
|
41129
|
+
* })
|
|
41130
|
+
* ```
|
|
41131
|
+
*
|
|
41132
|
+
* @see {@link Definition} for the returned shape
|
|
41133
|
+
*
|
|
41134
|
+
* @category constructors
|
|
41135
|
+
* @since 4.0.0
|
|
41136
|
+
*/
|
|
41137
|
+
const make = (options) => {
|
|
41138
|
+
if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
|
|
41139
|
+
return {
|
|
41140
|
+
[TypeId]: TypeId,
|
|
41141
|
+
input: options.input,
|
|
41142
|
+
decisions: options.decisions
|
|
41143
|
+
};
|
|
41144
|
+
};
|
|
41145
|
+
//#endregion
|
|
41146
|
+
//#region src/decide/cache.ts
|
|
41147
|
+
/**
|
|
41148
|
+
* Answer cache keyed by input hash. One JSON file per key under `--cache <dir>`.
|
|
41149
|
+
*
|
|
41150
|
+
* Only model answers are cached — verdicts are recomputed from answers and the
|
|
41151
|
+
* current threshold, so raising or lowering `--threshold` never serves a stale
|
|
41152
|
+
* verdict. Cache files are plain data: a missing or unreadable file is a miss,
|
|
41153
|
+
* never an error that fails the run.
|
|
41154
|
+
*/
|
|
41155
|
+
/** Read cached answers for `key`, or `undefined` on any miss (absent or unreadable). */
|
|
41156
|
+
const cacheGet = (cacheDir, key) => {
|
|
41157
|
+
const file = join(cacheDir, `${key}.json`);
|
|
41158
|
+
if (!existsSync(file)) return void 0;
|
|
41159
|
+
try {
|
|
41160
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
41161
|
+
} catch {
|
|
41162
|
+
return;
|
|
41163
|
+
}
|
|
41164
|
+
};
|
|
41165
|
+
/** Persist answers for `key`; creates `cacheDir` on first write. */
|
|
41166
|
+
const cachePut = (cacheDir, key, answers) => {
|
|
41167
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
41168
|
+
writeFileSync(join(cacheDir, `${key}.json`), `${JSON.stringify(answers, null, 2)}\n`, "utf8");
|
|
41169
|
+
};
|
|
41170
|
+
//#endregion
|
|
41171
|
+
//#region src/decide/run.ts
|
|
41172
|
+
/**
|
|
41173
|
+
* The decision runner. A family supplies its input schema, a definition built
|
|
41174
|
+
* from the input (families with dynamic labels build one per input), a way to
|
|
41175
|
+
* reference the input in a report, and a pure `evaluate` that maps the model's
|
|
41176
|
+
* answers to a verdict. The runner hashes, caches, dispatches with bounded
|
|
41177
|
+
* concurrency, and buckets confident answers apart from `review`.
|
|
41178
|
+
*
|
|
41179
|
+
* This module reaches Effect and the provider, so it is CLI-internal: the
|
|
41180
|
+
* library entries stay Effect-free and only the bundled `pagegraph` bin ships it.
|
|
41181
|
+
*/
|
|
41182
|
+
const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
41183
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41184
|
+
/** A probability distribution over exactly `labels`: unit-range, summing to 1. */
|
|
41185
|
+
const isDistribution = (labels, value) => {
|
|
41186
|
+
if (!isPlainObject(value) || Object.keys(value).length !== labels.length) return false;
|
|
41187
|
+
let total = 0;
|
|
41188
|
+
for (const label of labels) {
|
|
41189
|
+
const probability = value[label];
|
|
41190
|
+
if (!isUnitInterval(probability)) return false;
|
|
41191
|
+
total += probability;
|
|
41192
|
+
}
|
|
41193
|
+
return Math.abs(total - 1) <= 1e-6;
|
|
41194
|
+
};
|
|
41195
|
+
/**
|
|
41196
|
+
* Whether cached answers have the shape `DecisionModel` would have validated —
|
|
41197
|
+
* the same labels, ranges, and distributions. A cache file is only trusted when
|
|
41198
|
+
* it round-trips through this check, so a partially malformed entry cannot
|
|
41199
|
+
* produce a confident verdict.
|
|
41200
|
+
*/
|
|
41201
|
+
const validCachedAnswers = (decisions, answers) => {
|
|
41202
|
+
if (!isPlainObject(answers)) return false;
|
|
41203
|
+
for (const key of Object.keys(decisions)) {
|
|
41204
|
+
const answer = answers[key];
|
|
41205
|
+
if (!isPlainObject(answer)) return false;
|
|
41206
|
+
const decision = decisions[key];
|
|
41207
|
+
switch (decision._tag) {
|
|
41208
|
+
case "Probability":
|
|
41209
|
+
if (!isUnitInterval(answer.probability)) return false;
|
|
41210
|
+
break;
|
|
41211
|
+
case "Classify":
|
|
41212
|
+
if (typeof answer.label !== "string" || !Object.hasOwn(decision.criteria, answer.label) || !isDistribution(Object.keys(decision.criteria), answer.probabilities)) return false;
|
|
41213
|
+
break;
|
|
41214
|
+
case "Rate": if (typeof answer.rating !== "number" || !Number.isFinite(answer.rating) || answer.rating < 0 || answer.rating > decision.criteria.length - 1 || typeof answer.label !== "string" || !decision.criteria.includes(answer.label) || !isDistribution(decision.criteria, answer.probabilities)) return false;
|
|
41215
|
+
}
|
|
41216
|
+
}
|
|
41217
|
+
return true;
|
|
41218
|
+
};
|
|
41219
|
+
/** A probability is confident when it is at or beyond `threshold` on either side. */
|
|
41220
|
+
const isConfident$1 = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41221
|
+
/** True when a probability sits inside the review band `(1 - threshold, threshold)`. */
|
|
41222
|
+
const inReviewBand = (probability, threshold) => !isConfident$1(probability, threshold);
|
|
41223
|
+
/** True when any probability sits inside the review band. */
|
|
41224
|
+
const anyInReviewBand = (probabilities, threshold) => probabilities.some((probability) => inReviewBand(probability, threshold));
|
|
41225
|
+
/** sha256 of the canonical input JSON — deterministic for a schema-decoded input. */
|
|
41226
|
+
const inputHash = (input) => createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
|
41227
|
+
/**
|
|
41228
|
+
* Cache identity. Answers depend on the family and the model as well as the
|
|
41229
|
+
* input, so keying by the input hash alone would let a different family or model
|
|
41230
|
+
* reuse incompatible answers. The answer-schema version guards shape changes.
|
|
41231
|
+
*/
|
|
41232
|
+
const cacheKey = (family, model, answerHash) => createHash("sha256").update(`${family}\u0000${model}\u0000v1\u0000${answerHash}`).digest("hex");
|
|
41233
|
+
/** Group records into resolved/review buckets and count each verdict. */
|
|
41234
|
+
const buildDecisionReport = (options) => {
|
|
41235
|
+
const verdicts = {};
|
|
41236
|
+
const resolved = [];
|
|
41237
|
+
const review = [];
|
|
41238
|
+
for (const record of options.records) {
|
|
41239
|
+
verdicts[record.verdict] = (verdicts[record.verdict] ?? 0) + 1;
|
|
41240
|
+
if (record.review) review.push(record);
|
|
41241
|
+
else resolved.push(record);
|
|
41242
|
+
}
|
|
41243
|
+
return {
|
|
41244
|
+
kind: "decide",
|
|
41245
|
+
schemaVersion: 1,
|
|
41246
|
+
family: options.family,
|
|
41247
|
+
model: options.model,
|
|
41248
|
+
threshold: options.threshold,
|
|
41249
|
+
counts: {
|
|
41250
|
+
inputs: options.records.length,
|
|
41251
|
+
resolved: resolved.length,
|
|
41252
|
+
review: review.length
|
|
41253
|
+
},
|
|
41254
|
+
verdicts,
|
|
41255
|
+
resolved,
|
|
41256
|
+
review
|
|
41257
|
+
};
|
|
41258
|
+
};
|
|
41259
|
+
/**
|
|
41260
|
+
* Answer every input through the ambient `DecisionModel` and bucket the results.
|
|
41261
|
+
* One provider call answers every decision for an input; calls run with bounded
|
|
41262
|
+
* concurrency, and a warm cache skips the provider entirely.
|
|
41263
|
+
*/
|
|
41264
|
+
const runDecisions = (options) => gen(function* () {
|
|
41265
|
+
const records = yield* forEach$1(options.inputs, (input, index) => gen(function* () {
|
|
41266
|
+
const hash = inputHash(input);
|
|
41267
|
+
const key = cacheKey(options.family.name, options.model, hash);
|
|
41268
|
+
const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
|
|
41269
|
+
let answers;
|
|
41270
|
+
let usage;
|
|
41271
|
+
let verdict;
|
|
41272
|
+
if (cached !== void 0) {
|
|
41273
|
+
const decisions = options.family.definitionFor(input).decisions;
|
|
41274
|
+
if (validCachedAnswers(decisions, cached)) {
|
|
41275
|
+
answers = cached;
|
|
41276
|
+
verdict = options.family.evaluate(input, cached, options.threshold);
|
|
41277
|
+
}
|
|
41278
|
+
}
|
|
41279
|
+
if (verdict === void 0) {
|
|
41280
|
+
const response = yield* decide(options.family.definitionFor(input), { input });
|
|
41281
|
+
answers = response.answers;
|
|
41282
|
+
usage = {
|
|
41283
|
+
inputTokens: response.usage.inputTokens,
|
|
41284
|
+
outputTokens: response.usage.outputTokens
|
|
41285
|
+
};
|
|
41286
|
+
if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
|
|
41287
|
+
verdict = options.family.evaluate(input, answers, options.threshold);
|
|
41288
|
+
}
|
|
41289
|
+
return {
|
|
41290
|
+
decisionId: `${options.family.name}:${index}`,
|
|
41291
|
+
schemaVersion: 1,
|
|
41292
|
+
family: options.family.name,
|
|
41293
|
+
model: options.model,
|
|
41294
|
+
threshold: options.threshold,
|
|
41295
|
+
inputHash: hash,
|
|
41296
|
+
inputRef: options.family.inputRef(input),
|
|
41297
|
+
verdict,
|
|
41298
|
+
review: verdict === "review",
|
|
41299
|
+
answers,
|
|
41300
|
+
...usage === void 0 ? {} : { usage }
|
|
41301
|
+
};
|
|
41302
|
+
}), { concurrency: options.concurrency ?? 4 });
|
|
41303
|
+
return buildDecisionReport({
|
|
41304
|
+
family: options.family.name,
|
|
41305
|
+
model: options.model,
|
|
41306
|
+
threshold: options.threshold,
|
|
41307
|
+
records
|
|
41308
|
+
});
|
|
41309
|
+
});
|
|
41310
|
+
/** Decode raw batch inputs against a family's schema. Throws on the first invalid input. */
|
|
41311
|
+
/** `decodeUnknownSync` with an unconstrained schema type; the family types the result. */
|
|
41312
|
+
const decodeUnknown = decodeUnknownSync;
|
|
41313
|
+
const decodeFamilyInputs = (family, raw) => raw.map((value) => decodeUnknown(family.input)(value));
|
|
41314
|
+
//#endregion
|
|
41315
|
+
//#region src/decide/families/authority.ts
|
|
41316
|
+
/**
|
|
41317
|
+
* The authority family: is this a real link opportunity, a spam trap, or not
|
|
41318
|
+
* worth outreach?
|
|
41319
|
+
*
|
|
41320
|
+
* Three probabilities judge the domain and one classification places its fit.
|
|
41321
|
+
* A confident spam verdict is `spam`, a confident legitimate-and-worth-while fit
|
|
41322
|
+
* is `accept`, and everything uncertain is `review` — link outreach is never
|
|
41323
|
+
* auto-applied.
|
|
41324
|
+
*/
|
|
41325
|
+
const AUTHORITY_FIT_CRITERIA = {
|
|
41326
|
+
directory: "A directory or listing page that accepts submissions.",
|
|
41327
|
+
partner: "A partner or integration page that links relevant products.",
|
|
41328
|
+
editorial: "Editorial coverage: an article or guide that could cite this page.",
|
|
41329
|
+
community: "A community space: forum, Q&A, or discussion group.",
|
|
41330
|
+
none: "No genuine fit; outreach here would be noise."
|
|
41331
|
+
};
|
|
41332
|
+
/** One link target plus the evidence the model reads. */
|
|
41333
|
+
const AuthorityInput = Struct({
|
|
41334
|
+
domain: String$6,
|
|
41335
|
+
url: String$6,
|
|
41336
|
+
anchor: String$6,
|
|
41337
|
+
/** The surrounding copy on our page that would carry the link. */
|
|
41338
|
+
context: String$6,
|
|
41339
|
+
targetPath: String$6,
|
|
41340
|
+
/** Referring domains, when known. */
|
|
41341
|
+
rd: optional$3(Number$2),
|
|
41342
|
+
/** Extra observed signals, e.g. `["no-outbound", "paid-links"]`. */
|
|
41343
|
+
signals: optional$3(ArraySchema(String$6))
|
|
41344
|
+
});
|
|
41345
|
+
/** One provider call answers the three probabilities and the fit classification. */
|
|
41346
|
+
const AuthorityDecision = make({
|
|
41347
|
+
input: AuthorityInput,
|
|
41348
|
+
decisions: {
|
|
41349
|
+
legitimate: probability({
|
|
41350
|
+
instructions: "The target is a legitimate site a reader would trust: real editorial or community content, not a link farm, PBN, or paid-link network.",
|
|
41351
|
+
criteria: {
|
|
41352
|
+
false: "The target looks like spam, a link farm, or a paid-link network.",
|
|
41353
|
+
true: "The target is a legitimate site."
|
|
41354
|
+
}
|
|
41355
|
+
}),
|
|
41356
|
+
spam: probability({
|
|
41357
|
+
instructions: "The target shows spam signals: thin auto-generated content, exact-match anchor farms, or a link-selling footprint.",
|
|
41358
|
+
criteria: {
|
|
41359
|
+
false: "No meaningful spam signals.",
|
|
41360
|
+
true: "Clear spam or link-selling signals."
|
|
41361
|
+
}
|
|
41362
|
+
}),
|
|
41363
|
+
outreachWorth: probability({
|
|
41364
|
+
instructions: "Outreach to this target is worth the effort: a human could plausibly place a reader-useful link.",
|
|
41365
|
+
criteria: {
|
|
41366
|
+
false: "Outreach is not worth the effort.",
|
|
41367
|
+
true: "Outreach could place a useful link."
|
|
41368
|
+
}
|
|
41369
|
+
}),
|
|
41370
|
+
fit: classify({
|
|
41371
|
+
instructions: "Classify the relationship this target could support.",
|
|
41372
|
+
criteria: AUTHORITY_FIT_CRITERIA
|
|
41373
|
+
})
|
|
41374
|
+
}
|
|
41375
|
+
});
|
|
41376
|
+
/**
|
|
41377
|
+
* `spam` when spam is confident; `accept` only when legitimate, outreach-worthy,
|
|
41378
|
+
* and a non-`none` fit are all confident; otherwise `review`.
|
|
41379
|
+
*/
|
|
41380
|
+
const classifyAuthorityVerdict = (answers, threshold) => {
|
|
41381
|
+
if (anyInReviewBand([
|
|
41382
|
+
answers.legitimate.probability,
|
|
41383
|
+
answers.spam.probability,
|
|
41384
|
+
answers.outreachWorth.probability
|
|
41385
|
+
], threshold)) return "review";
|
|
41386
|
+
if (answers.spam.probability >= threshold) return "spam";
|
|
41387
|
+
const fitProbability = answers.fit.probabilities[answers.fit.label] ?? 0;
|
|
41388
|
+
if (answers.fit.label === "none" || fitProbability < threshold || answers.legitimate.probability < threshold || answers.outreachWorth.probability < threshold) return "review";
|
|
41389
|
+
return "accept";
|
|
41390
|
+
};
|
|
41391
|
+
const authorityFamily = {
|
|
41392
|
+
name: "authority",
|
|
41393
|
+
input: AuthorityInput,
|
|
41394
|
+
definitionFor: () => AuthorityDecision,
|
|
41395
|
+
inputRef: (input) => input.domain,
|
|
41396
|
+
evaluate: (_input, answers, threshold) => classifyAuthorityVerdict(answers, threshold)
|
|
41397
|
+
};
|
|
41398
|
+
//#endregion
|
|
41399
|
+
//#region src/decide/families/content.ts
|
|
41400
|
+
/**
|
|
41401
|
+
* The content family: does this page carry enough original substance?
|
|
41402
|
+
*
|
|
41403
|
+
* Seven probabilities score the page's editorial quality and one rating places
|
|
41404
|
+
* its overall value on `thin | adequate | strong`. A confident page with a
|
|
41405
|
+
* `thin` value or any failed probability is `flag`; an uncertain answer is
|
|
41406
|
+
* `review`. Nothing is rewritten here — the verdict is the plan.
|
|
41407
|
+
*/
|
|
41408
|
+
/** Ordered value scale from least to most substance. */
|
|
41409
|
+
const CONTENT_VALUES = [
|
|
41410
|
+
"thin",
|
|
41411
|
+
"adequate",
|
|
41412
|
+
"strong"
|
|
41413
|
+
];
|
|
41414
|
+
/** One page plus the evidence the rubric reads. */
|
|
41415
|
+
const ContentInput = Struct({
|
|
41416
|
+
url: String$6,
|
|
41417
|
+
/** The query the page targets; intent rubrics cannot be judged without it. */
|
|
41418
|
+
query: String$6,
|
|
41419
|
+
title: String$6,
|
|
41420
|
+
h1: String$6,
|
|
41421
|
+
first150Words: String$6,
|
|
41422
|
+
headings: ArraySchema(String$6),
|
|
41423
|
+
wordCount: Natural,
|
|
41424
|
+
/** schema.org types present on the page, e.g. `["Article", "FAQPage"]`. */
|
|
41425
|
+
structuredData: ArraySchema(String$6),
|
|
41426
|
+
/** The topic the page must stay inside; drifting outside it is a failure. */
|
|
41427
|
+
categoryLock: String$6,
|
|
41428
|
+
/** Sibling pages' intents, needed to judge `distinctIntent`. */
|
|
41429
|
+
siblingIntents: optional$3(ArraySchema(String$6)),
|
|
41430
|
+
competitorExcerpts: optional$3(ArraySchema(String$6))
|
|
41431
|
+
});
|
|
41432
|
+
const boolean = (instructions, falseText, trueText) => probability({
|
|
41433
|
+
instructions,
|
|
41434
|
+
criteria: {
|
|
41435
|
+
false: falseText,
|
|
41436
|
+
true: trueText
|
|
41437
|
+
}
|
|
41438
|
+
});
|
|
41439
|
+
/** One provider call answers the seven rubric probabilities and the value rating. */
|
|
41440
|
+
const ContentDecision = make({
|
|
41441
|
+
input: ContentInput,
|
|
41442
|
+
decisions: {
|
|
41443
|
+
answersFirst: boolean("The first 150 words answer the reader's question directly, before background or preamble.", "The opening delays the answer.", "The opening answers the question up front."),
|
|
41444
|
+
originalValue: boolean("The page adds original value — first-hand experience, data, or a distinct point of view — beyond restating common knowledge.", "The page restates what every other result already says.", "The page adds information a reader cannot get elsewhere."),
|
|
41445
|
+
competitiveSubstance: boolean("The page has enough depth and specificity to compete with the current results for this topic.", "The page is shallower or less specific than the competition.", "The page matches or exceeds the competition's substance."),
|
|
41446
|
+
descriptiveTitle: boolean("The title is descriptive and specific to the page's content rather than vague or clickbait.", "The title is vague or misleading.", "The title describes the page's actual content."),
|
|
41447
|
+
structuredDataMatches: boolean("The page's structured data matches its content type and the supplied query's intent.", "Structured data is missing, wrong, or mismatched.", "Structured data correctly describes the page."),
|
|
41448
|
+
categoryConsistency: boolean("The page stays inside its locked category; its headings and body do not drift into an adjacent topic.", "The page drifts outside the locked category.", "The page stays inside the locked category."),
|
|
41449
|
+
distinctIntent: boolean("The page serves a distinct intent rather than duplicating a sibling page's intent. Judge only the sibling intents supplied; if none are supplied, treat this as unresolved.", "The page duplicates a sibling page's intent.", "The page serves an intent no supplied sibling covers."),
|
|
41450
|
+
value: rate({
|
|
41451
|
+
instructions: "Overall reader value of this page: thin restates what exists, adequate is competitive, strong is the best available answer.",
|
|
41452
|
+
criteria: CONTENT_VALUES
|
|
41453
|
+
})
|
|
41454
|
+
}
|
|
41455
|
+
});
|
|
41456
|
+
/** The seven rubric probabilities that decide pass/flag once confidence is settled. */
|
|
41457
|
+
const RUBRIC_KEYS = [
|
|
41458
|
+
"answersFirst",
|
|
41459
|
+
"originalValue",
|
|
41460
|
+
"competitiveSubstance",
|
|
41461
|
+
"descriptiveTitle",
|
|
41462
|
+
"structuredDataMatches",
|
|
41463
|
+
"categoryConsistency",
|
|
41464
|
+
"distinctIntent"
|
|
41465
|
+
];
|
|
41466
|
+
/** Evidence present by default; a caller with no evidence can still test the band logic. */
|
|
41467
|
+
const ALL_EVIDENCE = {
|
|
41468
|
+
distinctIntent: true,
|
|
41469
|
+
competitiveSubstance: true
|
|
41470
|
+
};
|
|
41471
|
+
/**
|
|
41472
|
+
* `flag` when the value rating is `thin` or any rubric probability is
|
|
41473
|
+
* confidently false; `review` when any probability (rubric or value
|
|
41474
|
+
* distribution) is inside the band, or an evidence-dependent rubric is
|
|
41475
|
+
* confidently positive without its evidence; `pass` otherwise. A confident
|
|
41476
|
+
* negative on an unevidenced rubric stays `flag` — it is the conservative side.
|
|
41477
|
+
*/
|
|
41478
|
+
const classifyContentVerdict = (answers, threshold, evidence = ALL_EVIDENCE) => {
|
|
41479
|
+
const rubric = RUBRIC_KEYS.map((key) => answers[key].probability);
|
|
41480
|
+
const distribution = Object.values(answers.value.probabilities);
|
|
41481
|
+
if (anyInReviewBand([...rubric, ...distribution], threshold)) return "review";
|
|
41482
|
+
if (!evidence.distinctIntent && answers.distinctIntent.probability >= threshold) return "review";
|
|
41483
|
+
if (!evidence.competitiveSubstance && answers.competitiveSubstance.probability >= threshold) return "review";
|
|
41484
|
+
if (answers.value.label === "thin") return "flag";
|
|
41485
|
+
if (rubric.some((probability) => probability <= 1 - threshold)) return "flag";
|
|
41486
|
+
return "pass";
|
|
41487
|
+
};
|
|
41488
|
+
const contentFamily = {
|
|
41489
|
+
name: "content",
|
|
41490
|
+
input: ContentInput,
|
|
41491
|
+
definitionFor: () => ContentDecision,
|
|
41492
|
+
inputRef: (input) => input.url,
|
|
41493
|
+
evaluate: (input, answers, threshold) => classifyContentVerdict(answers, threshold, {
|
|
41494
|
+
distinctIntent: input.siblingIntents !== void 0 && input.siblingIntents.length > 0,
|
|
41495
|
+
competitiveSubstance: input.competitorExcerpts !== void 0 && input.competitorExcerpts.length > 0
|
|
41496
|
+
})
|
|
41497
|
+
};
|
|
41498
|
+
/** One query plus the candidate pages already on the site. */
|
|
41499
|
+
const FitInput = Struct({
|
|
41500
|
+
query: String$6,
|
|
41501
|
+
candidates: ArraySchema(Struct({
|
|
41502
|
+
id: String$6,
|
|
41503
|
+
title: String$6,
|
|
41504
|
+
excerpt: String$6
|
|
41505
|
+
}))
|
|
41506
|
+
});
|
|
41507
|
+
const candidateCriteria$1 = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.excerpt}`.slice(0, 255)]));
|
|
41508
|
+
/** One provider call answers `bestPage`, `competition`, and `needsNewPage`. */
|
|
41509
|
+
const makeFitDecision = (input) => make({
|
|
41510
|
+
input: FitInput,
|
|
41511
|
+
decisions: {
|
|
41512
|
+
bestPage: classify({
|
|
41513
|
+
instructions: "Choose the candidate page that best targets this query, or the closest starting point to update. Use the page's title and excerpt.",
|
|
41514
|
+
criteria: candidateCriteria$1(input.candidates)
|
|
41515
|
+
}),
|
|
41516
|
+
competition: probability({
|
|
41517
|
+
instructions: "Two or more of the supplied pages already target this query, so they risk cannibalizing each other.",
|
|
41518
|
+
criteria: {
|
|
41519
|
+
false: "At most one page targets the query.",
|
|
41520
|
+
true: "Multiple pages target the query."
|
|
41521
|
+
}
|
|
41522
|
+
}),
|
|
41523
|
+
needsNewPage: probability({
|
|
41524
|
+
instructions: "None of the supplied pages can serve this query well; a new page is the right answer.",
|
|
41525
|
+
criteria: {
|
|
41526
|
+
false: "An existing page can serve this query.",
|
|
41527
|
+
true: "No existing page can serve this query; a new page is needed."
|
|
41528
|
+
}
|
|
41529
|
+
})
|
|
41530
|
+
}
|
|
41531
|
+
});
|
|
41532
|
+
/** Reject inputs whose candidate ids cannot become provider labels. */
|
|
41533
|
+
const validateFitInput = (input) => {
|
|
41534
|
+
if (input.candidates.length < 2) return "decide fit needs at least two candidates: bestPage compares them.";
|
|
41535
|
+
const long = input.candidates.find((candidate) => candidate.id.length > 255);
|
|
41536
|
+
if (long !== void 0) return `decide fit candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
|
|
41537
|
+
if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide fit candidate ids must be unique: they become provider labels.";
|
|
41538
|
+
};
|
|
41539
|
+
/**
|
|
41540
|
+
* `gap` wins over `cannibalized` when both are confident: a new page is the more
|
|
41541
|
+
* decisive answer. `map` needs a confident `bestPage`; otherwise it is `review`.
|
|
41542
|
+
*/
|
|
41543
|
+
const classifyFitVerdict = (answers, threshold) => {
|
|
41544
|
+
if (anyInReviewBand([answers.competition.probability, answers.needsNewPage.probability], threshold)) return "review";
|
|
41545
|
+
if (answers.needsNewPage.probability >= threshold) return "gap";
|
|
41546
|
+
if (answers.competition.probability >= threshold) return "cannibalized";
|
|
41547
|
+
return (answers.bestPage.probabilities[answers.bestPage.label] ?? 0) >= threshold ? "map" : "review";
|
|
41548
|
+
};
|
|
41549
|
+
const fitFamily = {
|
|
41550
|
+
name: "fit",
|
|
41551
|
+
input: FitInput,
|
|
41552
|
+
definitionFor: makeFitDecision,
|
|
41553
|
+
inputRef: (input) => `query:${input.query}`,
|
|
41554
|
+
evaluate: (_input, answers, threshold) => classifyFitVerdict(answers, threshold),
|
|
41555
|
+
validate: validateFitInput
|
|
41556
|
+
};
|
|
41557
|
+
/** One page plus the metadata candidates to rank. */
|
|
41558
|
+
const MetaInput = Struct({
|
|
41559
|
+
url: String$6,
|
|
41560
|
+
intent: String$6,
|
|
41561
|
+
categoryLock: String$6,
|
|
41562
|
+
candidates: ArraySchema(Struct({
|
|
41563
|
+
id: String$6,
|
|
41564
|
+
title: String$6,
|
|
41565
|
+
description: String$6
|
|
41566
|
+
}))
|
|
41567
|
+
});
|
|
41568
|
+
const candidateCriteria = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.description}`.slice(0, 255)]));
|
|
41569
|
+
/** One provider call answers `best` and the four concern probabilities. */
|
|
41570
|
+
const makeMetaDecision = (input) => make({
|
|
41571
|
+
input: MetaInput,
|
|
41572
|
+
decisions: {
|
|
41573
|
+
best: classify({
|
|
41574
|
+
instructions: "Choose the candidate title/description that best serves the page's intent and stays inside its category. Judge the supplied candidates only.",
|
|
41575
|
+
criteria: candidateCriteria(input.candidates)
|
|
41576
|
+
}),
|
|
41577
|
+
intentFit: probability({
|
|
41578
|
+
instructions: "The chosen candidate's promise matches the page's intent.",
|
|
41579
|
+
criteria: {
|
|
41580
|
+
false: "The candidate promises something the page does not deliver.",
|
|
41581
|
+
true: "The candidate matches the page's intent."
|
|
41582
|
+
}
|
|
41583
|
+
}),
|
|
41584
|
+
lengthOk: probability({
|
|
41585
|
+
instructions: "The chosen candidate fits search-result limits: a title near 60 characters, a description near 160.",
|
|
41586
|
+
criteria: {
|
|
41587
|
+
false: "The candidate is too short or truncated.",
|
|
41588
|
+
true: "The candidate fits the result limits."
|
|
41589
|
+
}
|
|
41590
|
+
}),
|
|
41591
|
+
categoryDrift: probability({
|
|
41592
|
+
instructions: "The chosen candidate drifts outside the page's locked category.",
|
|
41593
|
+
criteria: {
|
|
41594
|
+
false: "The candidate stays inside the category.",
|
|
41595
|
+
true: "The candidate drifts outside the category."
|
|
41596
|
+
}
|
|
41597
|
+
}),
|
|
41598
|
+
clickbait: probability({
|
|
41599
|
+
instructions: "The chosen candidate uses clickbait or overpromises relative to the page's content.",
|
|
41600
|
+
criteria: {
|
|
41601
|
+
false: "The candidate is accurate and measured.",
|
|
41602
|
+
true: "The candidate overpromises or uses clickbait."
|
|
41603
|
+
}
|
|
41604
|
+
})
|
|
41605
|
+
}
|
|
41606
|
+
});
|
|
41607
|
+
/** Reject inputs whose candidate ids cannot become provider labels. */
|
|
41608
|
+
const validateMetaInput = (input) => {
|
|
41609
|
+
if (input.candidates.length < 2) return "decide meta needs at least two candidates: best ranks them.";
|
|
41610
|
+
const long = input.candidates.find((candidate) => candidate.id.length > 255);
|
|
41611
|
+
if (long !== void 0) return `decide meta candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
|
|
41612
|
+
if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide meta candidate ids must be unique: they become provider labels.";
|
|
41613
|
+
};
|
|
41614
|
+
/**
|
|
41615
|
+
* A clean candidate is `choose:<id>`. Any review-band probability, a low-probability
|
|
41616
|
+
* pick, confident drift, clickbait, or a mismatch on intent/length is `review`.
|
|
41617
|
+
*/
|
|
41618
|
+
const classifyMetaVerdict = (answers, threshold) => {
|
|
41619
|
+
if (anyInReviewBand([
|
|
41620
|
+
answers.intentFit.probability,
|
|
41621
|
+
answers.lengthOk.probability,
|
|
41622
|
+
answers.categoryDrift.probability,
|
|
41623
|
+
answers.clickbait.probability
|
|
41624
|
+
], threshold)) return "review";
|
|
41625
|
+
if ((answers.best.probabilities[answers.best.label] ?? 0) < threshold) return "review";
|
|
41626
|
+
if (answers.intentFit.probability < threshold || answers.lengthOk.probability < threshold || answers.categoryDrift.probability >= threshold || answers.clickbait.probability >= threshold) return "review";
|
|
41627
|
+
return `choose:${answers.best.label}`;
|
|
41628
|
+
};
|
|
41629
|
+
const metaFamily = {
|
|
41630
|
+
name: "meta",
|
|
41631
|
+
input: MetaInput,
|
|
41632
|
+
definitionFor: makeMetaDecision,
|
|
41633
|
+
inputRef: (input) => input.url,
|
|
41634
|
+
evaluate: (_input, answers, threshold) => classifyMetaVerdict(answers, threshold),
|
|
41635
|
+
validate: validateMetaInput
|
|
41636
|
+
};
|
|
41637
|
+
//#endregion
|
|
41638
|
+
//#region src/decide/families/serp.ts
|
|
41639
|
+
/**
|
|
41640
|
+
* The serp family: is our page the shape this results page rewards?
|
|
41641
|
+
*
|
|
41642
|
+
* The model classifies the dominant SERP format and answers three probabilities
|
|
41643
|
+
* about our page. A confident match is `aligned`, a confident miss is `mismatch`,
|
|
41644
|
+
* and any answer inside the confidence band is `review` — the format question is
|
|
41645
|
+
* the one a human is most often needed to settle.
|
|
41646
|
+
*/
|
|
41647
|
+
const SERP_FORMAT_CRITERIA = {
|
|
41648
|
+
definition: "A definition or explainer page: what X is and how it works.",
|
|
41649
|
+
tool: "An interactive tool, calculator, or generator.",
|
|
41650
|
+
comparison: "A comparison of options: X vs Y, or the best X.",
|
|
41651
|
+
listicle: "A ranked or numbered list of items.",
|
|
41652
|
+
docs: "Product, API, or reference documentation.",
|
|
41653
|
+
vendorLanding: "A vendor's marketing or landing page for its own product.",
|
|
41654
|
+
forum: "A community discussion: forum, Reddit, or Stack Overflow.",
|
|
41655
|
+
other: "None of the above."
|
|
41656
|
+
};
|
|
41657
|
+
/** One ranked result in the saved SERP snapshot. */
|
|
41658
|
+
const SerpItem = Struct({
|
|
41659
|
+
type: String$6,
|
|
41660
|
+
rank: Number$2,
|
|
41661
|
+
domain: String$6,
|
|
41662
|
+
title: optional$3(String$6),
|
|
41663
|
+
snippet: optional$3(String$6)
|
|
41664
|
+
});
|
|
41665
|
+
/** Our candidate page, plus the SERP snapshot for the query. */
|
|
41666
|
+
const SerpInput = Struct({
|
|
41667
|
+
query: String$6,
|
|
41668
|
+
ourPage: Struct({
|
|
41669
|
+
url: String$6,
|
|
41670
|
+
title: String$6,
|
|
41671
|
+
kind: String$6,
|
|
41672
|
+
firstWords: String$6
|
|
41673
|
+
}),
|
|
41674
|
+
serpItems: ArraySchema(SerpItem)
|
|
41675
|
+
});
|
|
41676
|
+
/** One provider call answers the SERP format and the three fit probabilities. */
|
|
41677
|
+
const SerpDecision = make({
|
|
41678
|
+
input: SerpInput,
|
|
41679
|
+
decisions: {
|
|
41680
|
+
serpFormat: classify({
|
|
41681
|
+
instructions: "Classify the dominant format of this results page from the ranking results. Weigh the top results most heavily.",
|
|
41682
|
+
criteria: SERP_FORMAT_CRITERIA
|
|
41683
|
+
}),
|
|
41684
|
+
ourFormatFit: probability({
|
|
41685
|
+
instructions: "Our page's format matches how this results page is won: the same shape a top result uses, not merely a related topic.",
|
|
41686
|
+
criteria: {
|
|
41687
|
+
false: "Our format is the wrong shape for this SERP.",
|
|
41688
|
+
true: "Our format is the shape this SERP rewards."
|
|
41689
|
+
}
|
|
41690
|
+
}),
|
|
41691
|
+
intentMatch: probability({
|
|
41692
|
+
instructions: "Our page satisfies the query's dominant intent: the reader's task after landing, not the literal keywords.",
|
|
41693
|
+
criteria: {
|
|
41694
|
+
false: "The query wants something our page does not provide.",
|
|
41695
|
+
true: "Our page provides what the query asks for."
|
|
41696
|
+
}
|
|
41697
|
+
}),
|
|
41698
|
+
titlePatternMatch: probability({
|
|
41699
|
+
instructions: "Our title follows the pattern the ranking titles use, including the qualifiers and framing a searcher expects.",
|
|
41700
|
+
criteria: {
|
|
41701
|
+
false: "Our title does not follow the ranking pattern.",
|
|
41702
|
+
true: "Our title follows the ranking pattern."
|
|
41703
|
+
}
|
|
41704
|
+
})
|
|
41705
|
+
}
|
|
41706
|
+
});
|
|
41707
|
+
/**
|
|
41708
|
+
* `aligned` needs both intent and format confident-positive; anything else that
|
|
41709
|
+
* is confident is `mismatch`. `titlePatternMatch` only widens the review band.
|
|
41710
|
+
*/
|
|
41711
|
+
const classifySerpVerdict = (answers, threshold) => {
|
|
41712
|
+
const probabilities = [
|
|
41713
|
+
answers.ourFormatFit.probability,
|
|
41714
|
+
answers.intentMatch.probability,
|
|
41715
|
+
answers.titlePatternMatch.probability
|
|
41716
|
+
];
|
|
41717
|
+
if (anyInReviewBand(probabilities, threshold)) return "review";
|
|
41718
|
+
return answers.intentMatch.probability >= threshold && answers.ourFormatFit.probability >= threshold ? "aligned" : "mismatch";
|
|
41719
|
+
};
|
|
41720
|
+
const serpFamily = {
|
|
41721
|
+
name: "serp",
|
|
41722
|
+
input: SerpInput,
|
|
41723
|
+
definitionFor: () => SerpDecision,
|
|
41724
|
+
inputRef: (input) => `${input.ourPage.url} · query:${input.query}`,
|
|
41725
|
+
evaluate: (_input, answers, threshold) => classifySerpVerdict(answers, threshold)
|
|
41726
|
+
};
|
|
41727
|
+
//#endregion
|
|
41728
|
+
//#region src/decide/inputs.ts
|
|
41729
|
+
/**
|
|
41730
|
+
* Batch input reading for `pagegraph decide <family>`. A batch is either a JSON
|
|
41731
|
+
* array, a `{ "inputs": [...] }` envelope, or newline-delimited JSON (one input
|
|
41732
|
+
* per non-empty line) — from a file argument or stdin. A TTY with no file is an
|
|
41733
|
+
* error: the command must never appear to hang waiting for input that will not
|
|
41734
|
+
* come from a pipe.
|
|
41735
|
+
*/
|
|
41736
|
+
/** Parse a batch of raw inputs from text. Throws a plain `Error` on malformed input. */
|
|
41737
|
+
const decodeInputsText = (text) => {
|
|
41738
|
+
const trimmed = text.trim();
|
|
41739
|
+
if (trimmed.length === 0) throw new Error("no input provided");
|
|
41740
|
+
let parsed;
|
|
41741
|
+
try {
|
|
41742
|
+
parsed = JSON.parse(trimmed);
|
|
41743
|
+
} catch (error) {
|
|
41744
|
+
const lines = trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
41745
|
+
if (lines.length > 1) try {
|
|
41746
|
+
return lines.map((line) => JSON.parse(line));
|
|
41747
|
+
} catch {
|
|
41748
|
+
throw error;
|
|
41749
|
+
}
|
|
41750
|
+
throw error;
|
|
41751
|
+
}
|
|
41752
|
+
if (Array.isArray(parsed)) return parsed;
|
|
41753
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
41754
|
+
const inputs = parsed.inputs;
|
|
41755
|
+
if (Array.isArray(inputs)) return inputs;
|
|
41756
|
+
return [parsed];
|
|
41757
|
+
}
|
|
41758
|
+
throw new Error("expected a JSON array, a { \"inputs\": [...] } envelope, or JSONL");
|
|
41759
|
+
};
|
|
41760
|
+
//#endregion
|
|
41761
|
+
//#region src/decide/render.ts
|
|
41762
|
+
/** One review-queue entry: what was asked, how it landed, and the raw answers. */
|
|
41763
|
+
const reviewLine = (record) => [` ${record.inputRef} · ${record.verdict}`, ` ${JSON.stringify(record.answers)}`].join("\n");
|
|
41764
|
+
/**
|
|
41765
|
+
* Human `decide` report: the batch header, a verdict breakdown, and the review
|
|
41766
|
+
* queue — the only part a human has to act on.
|
|
41767
|
+
*/
|
|
41768
|
+
const renderDecideReport = (report) => {
|
|
41769
|
+
const lines = [
|
|
41770
|
+
`${report.counts.inputs} input(s) · family ${report.family} · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
|
|
41771
|
+
"",
|
|
41772
|
+
` resolved ${report.counts.resolved}`,
|
|
41773
|
+
` review ${report.counts.review}`,
|
|
41774
|
+
""
|
|
41775
|
+
];
|
|
41776
|
+
const verdicts = Object.entries(report.verdicts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
41777
|
+
if (verdicts.length > 0) {
|
|
41778
|
+
lines.push("Verdicts:", "");
|
|
41779
|
+
for (const [verdict, count] of verdicts) lines.push(` ${verdict.padEnd(20)} ${count}`);
|
|
41780
|
+
lines.push("");
|
|
41781
|
+
}
|
|
41782
|
+
if (report.review.length === 0) lines.push("No inputs need review.");
|
|
41783
|
+
else {
|
|
41784
|
+
lines.push(`Review queue (${report.review.length}):`, "");
|
|
41785
|
+
for (const record of report.review) lines.push(reviewLine(record), "");
|
|
41786
|
+
lines.pop();
|
|
41787
|
+
}
|
|
41788
|
+
return lines.join("\n").trimEnd();
|
|
41789
|
+
};
|
|
41790
|
+
//#endregion
|
|
41791
|
+
//#region src/links/decide.ts
|
|
41792
|
+
/**
|
|
41793
|
+
* Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
|
|
41794
|
+
*
|
|
41795
|
+
* A candidate is a `(source → destination)` pair with the source copy. The model
|
|
41796
|
+
* answers two probabilities — does the source have a genuine reader-facing
|
|
41797
|
+
* reason to link to the destination, and is descriptive anchor text already in
|
|
41798
|
+
* the copy — and this module maps them to a verdict. Anything the model is not
|
|
41799
|
+
* confident about lands in the `review` bucket; nothing is applied automatically.
|
|
41800
|
+
*
|
|
41801
|
+
* This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
|
|
41802
|
+
* the library entries stay Effect-free and only the bundled `pagegraph` bin
|
|
41803
|
+
* ships it.
|
|
41804
|
+
*/
|
|
41805
|
+
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41806
|
+
const DEFAULT_LINK_THRESHOLD = .7;
|
|
41807
|
+
/** Ordered relevance scale for budget selection. */
|
|
41808
|
+
const LINK_RELEVANCE = [
|
|
41809
|
+
"irrelevant",
|
|
41810
|
+
"useful",
|
|
41811
|
+
"essential"
|
|
41812
|
+
];
|
|
41813
|
+
/** One proposed contextual link, plus the source copy the model reads. */
|
|
41814
|
+
const LinkCandidate = Struct({
|
|
41815
|
+
sourceUrl: String$6,
|
|
41816
|
+
destinationUrl: String$6,
|
|
41817
|
+
sourceText: String$6,
|
|
41818
|
+
/** Descriptive anchor text already present for the destination, when known. */
|
|
41819
|
+
existingAnchor: optional$3(String$6)
|
|
41820
|
+
});
|
|
41821
|
+
/**
|
|
41822
|
+
* The four decisions every candidate answers in one provider call: two
|
|
41823
|
+
* probabilities, a direction classification, and a relevance rating. The
|
|
41824
|
+
* probabilities decide the verdict; direction and relevance inform the plan and
|
|
41825
|
+
* the per-source budget.
|
|
41826
|
+
*/
|
|
41827
|
+
const LinkDecision = make({
|
|
41828
|
+
input: LinkCandidate,
|
|
41829
|
+
decisions: {
|
|
41830
|
+
realReason: probability({
|
|
41831
|
+
instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
|
|
41832
|
+
criteria: {
|
|
41833
|
+
false: "No genuine reason; the link would be noise or only keyword overlap.",
|
|
41834
|
+
true: "A reader of the source would plausibly want the destination next."
|
|
41835
|
+
}
|
|
41836
|
+
}),
|
|
41837
|
+
anchorPresent: probability({
|
|
41838
|
+
instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
|
|
41839
|
+
criteria: {
|
|
41840
|
+
false: "No existing anchor to the destination in the copy.",
|
|
41841
|
+
true: "Descriptive anchor text to the destination is already in the copy."
|
|
41842
|
+
}
|
|
41843
|
+
}),
|
|
41844
|
+
direction: classify({
|
|
41845
|
+
instructions: "Which direction between the candidate pair deserves a link: source → destination, destination → source, or both.",
|
|
41846
|
+
criteria: {
|
|
41847
|
+
a_to_b: "The source page should link to the destination page.",
|
|
41848
|
+
b_to_a: "The destination page should link to the source page.",
|
|
41849
|
+
both: "Both directions are reader-useful."
|
|
41850
|
+
}
|
|
41851
|
+
}),
|
|
41852
|
+
relevance: rate({
|
|
41853
|
+
instructions: "How relevant this link is to the reader: irrelevant is noise, useful is a sensible next step, essential is a link the page is incomplete without.",
|
|
41854
|
+
criteria: LINK_RELEVANCE
|
|
41855
|
+
})
|
|
41856
|
+
}
|
|
41857
|
+
});
|
|
41858
|
+
/** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
|
|
41859
|
+
const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41860
|
+
/**
|
|
41861
|
+
* Map the two probabilities to a verdict. A probability inside the review band
|
|
41862
|
+
* makes the whole candidate uncertain, so the confident half of the pair can
|
|
41863
|
+
* never auto-resolve it. Requires `threshold > 0.5`, so the review band
|
|
41864
|
+
* `(1 - threshold, threshold)` is non-empty.
|
|
41865
|
+
*
|
|
41866
|
+
* - `present` — descriptive anchor already in the copy; nothing to add.
|
|
41867
|
+
* - `recommend` — a real reason and no existing anchor; add a contextual link.
|
|
41868
|
+
* - `skip` — no real reason; leave the copy alone.
|
|
41869
|
+
* - `review` — at least one answer is inside the band; a human decides.
|
|
41870
|
+
*/
|
|
41871
|
+
const classifyVerdict = (realReason, anchorPresent, threshold) => {
|
|
41872
|
+
if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
|
|
41873
|
+
if (anchorPresent >= threshold) return "present";
|
|
41874
|
+
if (realReason >= threshold) return "recommend";
|
|
41875
|
+
return "skip";
|
|
41876
|
+
};
|
|
41877
|
+
/**
|
|
41878
|
+
* Keep the top `budget` candidates per outbound page by relevance rating,
|
|
41879
|
+
* preserving the original order. The outbound page is `record.from`, which
|
|
41880
|
+
* honors `direction` — grouping by the candidate's `sourceUrl` would misspend the
|
|
41881
|
+
* quota when the model recommends the reverse direction. Ties keep their
|
|
41882
|
+
* original order (stable sort). Pages with fewer candidates pass through.
|
|
41883
|
+
*/
|
|
41884
|
+
const applyBudget = (records, budget) => {
|
|
41885
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
41886
|
+
for (const record of records) {
|
|
41887
|
+
const bucket = bySource.get(record.from);
|
|
41888
|
+
if (bucket === void 0) bySource.set(record.from, [record]);
|
|
41889
|
+
else bucket.push(record);
|
|
41890
|
+
}
|
|
41891
|
+
const kept = /* @__PURE__ */ new Set();
|
|
41892
|
+
for (const bucket of bySource.values()) {
|
|
41893
|
+
const top = [...bucket].sort((a, b) => b.relevance.rating - a.relevance.rating).slice(0, budget);
|
|
41894
|
+
for (const record of top) kept.add(record);
|
|
41895
|
+
}
|
|
41896
|
+
return records.filter((record) => kept.has(record));
|
|
41897
|
+
};
|
|
41898
|
+
/** Group records into resolved/review buckets and count each verdict. */
|
|
41899
|
+
const buildLinksDecideReport = (options) => {
|
|
41900
|
+
const counts = {
|
|
41901
|
+
recommend: 0,
|
|
41902
|
+
present: 0,
|
|
41903
|
+
skip: 0,
|
|
41904
|
+
review: 0
|
|
41905
|
+
};
|
|
41906
|
+
const resolved = [];
|
|
41907
|
+
const review = [];
|
|
41908
|
+
for (const record of options.records) {
|
|
41909
|
+
counts[record.verdict] += 1;
|
|
41910
|
+
if (record.verdict === "review") review.push(record);
|
|
41911
|
+
else resolved.push(record);
|
|
41912
|
+
}
|
|
41913
|
+
return {
|
|
41914
|
+
kind: "links-decide",
|
|
41915
|
+
schemaVersion: 1,
|
|
41916
|
+
model: options.model,
|
|
41917
|
+
threshold: options.threshold,
|
|
41918
|
+
budget: options.budget ?? null,
|
|
41919
|
+
dropped: options.dropped ?? 0,
|
|
41920
|
+
counts: {
|
|
41921
|
+
candidates: options.records.length,
|
|
41922
|
+
...counts
|
|
41923
|
+
},
|
|
41924
|
+
resolved,
|
|
41925
|
+
review
|
|
41926
|
+
};
|
|
41927
|
+
};
|
|
41928
|
+
/**
|
|
41929
|
+
* The endpoints a `recommend` verdict actually directs. `b_to_a` reverses the
|
|
41930
|
+
* candidate pair, so the report names the direction the model chose rather than
|
|
41931
|
+
* always printing source → destination.
|
|
41932
|
+
*/
|
|
41933
|
+
const endpoints = (candidate, direction) => direction === "b_to_a" ? {
|
|
41934
|
+
from: candidate.destinationUrl,
|
|
41935
|
+
to: candidate.sourceUrl
|
|
41936
|
+
} : {
|
|
41937
|
+
from: candidate.sourceUrl,
|
|
41938
|
+
to: candidate.destinationUrl
|
|
41939
|
+
};
|
|
41940
|
+
const toRecord = (candidate, answers, threshold) => {
|
|
41941
|
+
const realReason = answers.realReason.probability;
|
|
41942
|
+
const anchorPresent = answers.anchorPresent.probability;
|
|
41943
|
+
const direction = answers.direction.label;
|
|
41944
|
+
return {
|
|
41945
|
+
sourceUrl: candidate.sourceUrl,
|
|
41946
|
+
destinationUrl: candidate.destinationUrl,
|
|
41947
|
+
sourceText: candidate.sourceText,
|
|
41948
|
+
existingAnchor: candidate.existingAnchor ?? null,
|
|
41949
|
+
realReason,
|
|
41950
|
+
anchorPresent,
|
|
41951
|
+
direction,
|
|
41952
|
+
relevance: {
|
|
41953
|
+
rating: answers.relevance.rating,
|
|
41954
|
+
label: answers.relevance.label,
|
|
41955
|
+
probabilities: answers.relevance.probabilities
|
|
41956
|
+
},
|
|
41957
|
+
...endpoints(candidate, direction),
|
|
41958
|
+
verdict: classifyVerdict(realReason, anchorPresent, threshold)
|
|
41959
|
+
};
|
|
41960
|
+
};
|
|
41961
|
+
/**
|
|
41962
|
+
* Answer every candidate through the ambient `DecisionModel` and bucket the
|
|
41963
|
+
* results. The model answers both decisions in one call per candidate; candidates
|
|
41964
|
+
* are dispatched with bounded concurrency. A warm cache skips the provider.
|
|
41965
|
+
*/
|
|
41966
|
+
const decideLinks = (candidates, options) => gen(function* () {
|
|
41967
|
+
const records = yield* forEach$1(candidates, (candidate) => gen(function* () {
|
|
41968
|
+
const hash = inputHash(candidate);
|
|
41969
|
+
const key = cacheKey("links", options.model, hash);
|
|
41970
|
+
const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
|
|
41971
|
+
if (cached !== void 0 && validCachedAnswers(LinkDecision.decisions, cached)) return toRecord(candidate, cached, options.threshold);
|
|
41972
|
+
const { answers } = yield* decide(LinkDecision, { input: candidate });
|
|
41973
|
+
if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
|
|
41974
|
+
return toRecord(candidate, answers, options.threshold);
|
|
41975
|
+
}), { concurrency: options.concurrency ?? 4 });
|
|
41976
|
+
const selected = options.budget === void 0 ? records : applyBudget(records, options.budget);
|
|
41977
|
+
return buildLinksDecideReport({
|
|
41978
|
+
model: options.model,
|
|
41979
|
+
threshold: options.threshold,
|
|
41980
|
+
records: selected,
|
|
41981
|
+
budget: options.budget,
|
|
41982
|
+
dropped: records.length - selected.length
|
|
41983
|
+
});
|
|
41984
|
+
});
|
|
41985
|
+
/** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
|
|
41986
|
+
const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
|
|
41987
|
+
//#endregion
|
|
41988
|
+
//#region src/cli/commands/decide.ts
|
|
41989
|
+
/**
|
|
41990
|
+
* `pagegraph decide <family>` — the decision runner over a batch. Each family
|
|
41991
|
+
* subcommand reads a batch of inputs, answers every decision for an input in one
|
|
41992
|
+
* provider call, and separates confident verdicts from `review`.
|
|
41993
|
+
*
|
|
41994
|
+
* `decide links` is the links family alias of the legacy `pagegraph links decide`.
|
|
41995
|
+
* Effect and the TypeSafe provider live only in this CLI-internal command; the
|
|
41996
|
+
* library entries stay Effect-free.
|
|
41997
|
+
*/
|
|
41998
|
+
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41999
|
+
const DEFAULT_DECIDE_THRESHOLD = .7;
|
|
42000
|
+
const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
42001
|
+
const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
42002
|
+
const checkThreshold$1 = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
|
|
42003
|
+
const decideFile$1 = String$1("file").pipe(withDescription("JSON batch: an array, { inputs: [...] }, or JSONL (default: stdin)"), optional);
|
|
42004
|
+
const decideThresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_DECIDE_THRESHOLD));
|
|
42005
|
+
const modelFlag$1 = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
|
|
42006
|
+
const concurrencyFlag$1 = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
|
|
42007
|
+
const cacheFlag = String$2("cache").pipe(withDescription$2("Reuse model answers by input hash; directory for cache files"), optional$1);
|
|
42008
|
+
const reviewOutFlag = String$2("review-out").pipe(withDescription$2("Write only below-threshold records to this JSON file"), optional$1);
|
|
42009
|
+
const budgetFlag$1 = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
|
|
42010
|
+
/** Read the batch text from a file argument or stdin; a TTY with no file is an error. */
|
|
42011
|
+
const readInputsText = (file) => try_({
|
|
42012
|
+
try: () => {
|
|
42013
|
+
if (file !== void 0) return readFileSync(file, "utf8");
|
|
42014
|
+
if (process.stdin.isTTY) throw new Error("no input file given and stdin is a TTY");
|
|
42015
|
+
return readFileSync(0, "utf8");
|
|
42016
|
+
},
|
|
42017
|
+
catch: (cause) => new SeoCliError({ message: `Could not read inputs: ${messageOf$1(cause)}` })
|
|
42018
|
+
});
|
|
42019
|
+
/** Parse the batch text, then schema-decode every input against the family. */
|
|
42020
|
+
const parseInputs = (family, text) => try_({
|
|
42021
|
+
try: () => decodeFamilyInputs(family, decodeInputsText(text)),
|
|
42022
|
+
catch: (cause) => new SeoCliError({ message: `Invalid inputs: ${messageOf$1(cause)}` })
|
|
42023
|
+
});
|
|
42024
|
+
/** Apply a family's optional input guard to every decoded input. */
|
|
42025
|
+
const validateInputs = (family, inputs) => gen(function* () {
|
|
42026
|
+
if (family.validate === void 0) return;
|
|
42027
|
+
for (const input of inputs) {
|
|
42028
|
+
const problem = family.validate(input);
|
|
42029
|
+
if (problem !== void 0) return yield* new SeoCliError({ message: problem });
|
|
42030
|
+
}
|
|
42031
|
+
});
|
|
42032
|
+
const requireKey = (family) => process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "" ? fail$3(new SeoCliError({ message: `TYPESAFE_API_KEY is not set; \`pagegraph decide ${family}\` answers through TypeSafe System One.` })) : void_;
|
|
42033
|
+
/** TypeSafe System One decision model over the shared fetch HTTP client. */
|
|
42034
|
+
const typeSafeDecisionModel$1 = (model$2) => model(model$2).pipe(provide$2(layerConfig()), provide$2(layer$1));
|
|
42035
|
+
const decisionErrorMessage$1 = (error) => error._tag === "AiError" ? new SeoCliError({ message: `Decision request failed (${error.reason._tag}): ${error.message}` }) : new SeoCliError({ message: `TypeSafe configuration failed: ${error.message}` });
|
|
42036
|
+
/** Write the below-threshold records to `file`, creating its parent directory. */
|
|
42037
|
+
const writeReviewRecords = (file, payload) => file === void 0 ? void_ : try_({
|
|
42038
|
+
try: () => {
|
|
42039
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
42040
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
42041
|
+
},
|
|
42042
|
+
catch: (cause) => new SeoCliError({ message: `Could not write review records to ${file}: ${messageOf$1(cause)}` })
|
|
42043
|
+
});
|
|
42044
|
+
/** Run one family over a decoded batch and print its report. */
|
|
42045
|
+
const runFamily = (family, options) => gen(function* () {
|
|
42046
|
+
const threshold = yield* checkThreshold$1(options.threshold);
|
|
42047
|
+
const concurrency = yield* positive$1("concurrency", options.concurrency);
|
|
42048
|
+
const text = yield* readInputsText(getOrUndefined$1(options.file));
|
|
42049
|
+
const inputs = yield* parseInputs(family, text);
|
|
42050
|
+
yield* validateInputs(family, inputs);
|
|
42051
|
+
yield* requireKey(family.name);
|
|
42052
|
+
const report = yield* runDecisions({
|
|
42053
|
+
family,
|
|
42054
|
+
inputs,
|
|
42055
|
+
model: options.model,
|
|
42056
|
+
threshold,
|
|
42057
|
+
concurrency,
|
|
42058
|
+
cacheDir: getOrUndefined$1(options.cache)
|
|
42059
|
+
}).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
|
|
42060
|
+
yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
|
|
42061
|
+
family: report.family,
|
|
42062
|
+
model: report.model,
|
|
42063
|
+
threshold: report.threshold,
|
|
42064
|
+
review: report.review
|
|
42065
|
+
});
|
|
42066
|
+
if (options.json) yield* printJson(report);
|
|
42067
|
+
else yield* printText(renderDecideReport(report));
|
|
42068
|
+
});
|
|
42069
|
+
/** Parse the legacy link-candidate batch (array or `{ inputs }`). */
|
|
42070
|
+
const parseLinks = (text) => try_({
|
|
42071
|
+
try: () => decodeLinkCandidates(decodeInputsText(text)),
|
|
42072
|
+
catch: (cause) => new SeoCliError({ message: `Invalid candidates: ${messageOf$1(cause)}` })
|
|
42073
|
+
});
|
|
42074
|
+
/** `decide links` — the links family alias over the legacy `links decide` report. */
|
|
42075
|
+
const runLinks = (options) => gen(function* () {
|
|
42076
|
+
const threshold = yield* checkThreshold$1(options.threshold);
|
|
42077
|
+
const concurrency = yield* positive$1("concurrency", options.concurrency);
|
|
42078
|
+
const budget = yield* positive$1("budget", options.budget);
|
|
42079
|
+
const text = yield* readInputsText(getOrUndefined$1(options.file));
|
|
42080
|
+
const candidates = yield* parseLinks(text);
|
|
42081
|
+
yield* requireKey("links");
|
|
42082
|
+
const report = yield* decideLinks(candidates, {
|
|
42083
|
+
model: options.model,
|
|
42084
|
+
threshold,
|
|
42085
|
+
concurrency,
|
|
42086
|
+
cacheDir: getOrUndefined$1(options.cache),
|
|
42087
|
+
budget
|
|
42088
|
+
}).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
|
|
42089
|
+
yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
|
|
42090
|
+
family: "links",
|
|
42091
|
+
model: report.model,
|
|
42092
|
+
threshold: report.threshold,
|
|
42093
|
+
review: report.review
|
|
42094
|
+
});
|
|
42095
|
+
if (options.json) yield* printJson(report);
|
|
42096
|
+
else yield* printText(renderLinksDecideReport(report));
|
|
42097
|
+
});
|
|
42098
|
+
const decideCommand = (config) => make$9(config.name, {
|
|
42099
|
+
file: decideFile$1,
|
|
42100
|
+
json: jsonFlag,
|
|
42101
|
+
threshold: decideThresholdFlag,
|
|
42102
|
+
model: modelFlag$1,
|
|
42103
|
+
concurrency: concurrencyFlag$1,
|
|
42104
|
+
cache: cacheFlag,
|
|
42105
|
+
reviewOut: reviewOutFlag
|
|
42106
|
+
}).pipe(withDescription$1(config.description), withExamples(config.examples), withHandler((options) => config.run(options)));
|
|
42107
|
+
/** Build a family subcommand from its family and help text. */
|
|
42108
|
+
const familySubcommand = (family, config) => decideCommand({
|
|
42109
|
+
name: family.name,
|
|
42110
|
+
description: config.description,
|
|
42111
|
+
examples: config.examples,
|
|
42112
|
+
run: (options) => runFamily(family, options)
|
|
42113
|
+
});
|
|
42114
|
+
const serpCommand = familySubcommand(serpFamily, {
|
|
42115
|
+
description: "Classify the SERP format and score our page's fit, intent, and title (aligned | mismatch | review)",
|
|
42116
|
+
examples: [{
|
|
42117
|
+
command: "pagegraph decide serp serp.json",
|
|
42118
|
+
description: "Decide a saved SERP snapshot and print the reviewable plan"
|
|
42119
|
+
}, {
|
|
42120
|
+
command: "cat serp.json | pagegraph decide serp --json | jq",
|
|
42121
|
+
description: "Read the SERP batch from stdin and emit versioned JSON"
|
|
42122
|
+
}]
|
|
42123
|
+
});
|
|
42124
|
+
const contentCommand = familySubcommand(contentFamily, {
|
|
42125
|
+
description: "Score a page's content rubric and place its value on thin | adequate | strong (pass | flag | review)",
|
|
42126
|
+
examples: [{
|
|
42127
|
+
command: "pagegraph decide content page.json",
|
|
42128
|
+
description: "Decide a page's content quality and print the reviewable plan"
|
|
42129
|
+
}, {
|
|
42130
|
+
command: "cat page.json | pagegraph decide content --json | jq",
|
|
42131
|
+
description: "Read the content batch from stdin and emit versioned JSON"
|
|
42132
|
+
}]
|
|
42133
|
+
});
|
|
42134
|
+
const fitCommand = familySubcommand(fitFamily, {
|
|
42135
|
+
description: "Choose the best existing page for a query, or flag cannibalization or a gap (map | cannibalized | gap | review)",
|
|
42136
|
+
examples: [{
|
|
42137
|
+
command: "pagegraph decide fit candidates.json",
|
|
42138
|
+
description: "Rank existing pages for a query and print the reviewable plan"
|
|
42139
|
+
}, {
|
|
42140
|
+
command: "cat candidates.json | pagegraph decide fit --json | jq",
|
|
42141
|
+
description: "Read the fit batch from stdin and emit versioned JSON"
|
|
42142
|
+
}]
|
|
42143
|
+
});
|
|
42144
|
+
const metaCommand = familySubcommand(metaFamily, {
|
|
42145
|
+
description: "Rank supplied title/description candidates for a page (choose:<id> | review)",
|
|
42146
|
+
examples: [{
|
|
42147
|
+
command: "pagegraph decide meta meta.json",
|
|
42148
|
+
description: "Rank metadata candidates and print the chosen one or a review"
|
|
42149
|
+
}, {
|
|
42150
|
+
command: "cat meta.json | pagegraph decide meta --json | jq",
|
|
42151
|
+
description: "Read the meta batch from stdin and emit versioned JSON"
|
|
42152
|
+
}]
|
|
42153
|
+
});
|
|
42154
|
+
const authorityCommand = familySubcommand(authorityFamily, {
|
|
42155
|
+
description: "Judge a link target's legitimacy, spam, and outreach worth, plus its fit (accept | spam | review)",
|
|
42156
|
+
examples: [{
|
|
42157
|
+
command: "pagegraph decide authority targets.json",
|
|
42158
|
+
description: "Judge a batch of link targets and print the reviewable plan"
|
|
42159
|
+
}, {
|
|
42160
|
+
command: "cat targets.json | pagegraph decide authority --json | jq",
|
|
42161
|
+
description: "Read the authority batch from stdin and emit versioned JSON"
|
|
42162
|
+
}]
|
|
42163
|
+
});
|
|
42164
|
+
const linksDecideCommand$1 = make$9("links", {
|
|
42165
|
+
file: decideFile$1,
|
|
42166
|
+
json: jsonFlag,
|
|
42167
|
+
threshold: decideThresholdFlag,
|
|
42168
|
+
model: modelFlag$1,
|
|
42169
|
+
concurrency: concurrencyFlag$1,
|
|
42170
|
+
cache: cacheFlag,
|
|
42171
|
+
reviewOut: reviewOutFlag,
|
|
42172
|
+
budget: budgetFlag$1
|
|
42173
|
+
}).pipe(withDescription$1("Answer the links family: real reason, anchor present, direction, and relevance per candidate"), withExamples([
|
|
42174
|
+
{
|
|
42175
|
+
command: "pagegraph decide links candidates.json",
|
|
42176
|
+
description: "Answer a candidate batch and print the reviewable plan"
|
|
42177
|
+
},
|
|
42178
|
+
{
|
|
42179
|
+
command: "pagegraph decide links candidates.json --budget 2",
|
|
42180
|
+
description: "Keep the top two candidates per source by relevance"
|
|
42181
|
+
},
|
|
42182
|
+
{
|
|
42183
|
+
command: "cat candidates.json | pagegraph decide links --json | jq",
|
|
42184
|
+
description: "Read candidates from stdin and emit versioned JSON"
|
|
42185
|
+
}
|
|
42186
|
+
]), withHandler((options) => runLinks(options)));
|
|
42187
|
+
/**
|
|
42188
|
+
* `pagegraph decide` groups the decision families. Each family answers one batch
|
|
42189
|
+
* of inputs through TypeSafe System One; confident verdicts resolve, answers in
|
|
42190
|
+
* the confidence band go to review, and nothing is applied automatically.
|
|
42191
|
+
*/
|
|
42192
|
+
const decideCommandGroup = make$9("decide").pipe(withDescription$1("Answer calibrated SEO decisions over a batch with Jev"), withExamples([{
|
|
42193
|
+
command: "pagegraph decide links candidates.json --json | jq",
|
|
42194
|
+
description: "Decide a link-candidate batch and emit versioned JSON"
|
|
42195
|
+
}]), withSubcommands([
|
|
42196
|
+
serpCommand,
|
|
42197
|
+
contentCommand,
|
|
42198
|
+
fitCommand,
|
|
42199
|
+
metaCommand,
|
|
42200
|
+
authorityCommand,
|
|
42201
|
+
linksDecideCommand$1
|
|
42202
|
+
]));
|
|
42203
|
+
//#endregion
|
|
42204
|
+
//#region src/cli/commands/diff.ts
|
|
42205
|
+
const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
|
|
42206
|
+
const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
|
|
42207
|
+
const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
|
|
42208
|
+
const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
|
|
42209
|
+
return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
|
|
42210
|
+
});
|
|
42211
|
+
const diffCommand = make$9("diff", {
|
|
42212
|
+
before,
|
|
42213
|
+
after,
|
|
42214
|
+
json: jsonFlag
|
|
42215
|
+
}).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
|
|
42216
|
+
command: "pagegraph diff before.json after.json",
|
|
42217
|
+
description: "Render a human-readable semantic comparison"
|
|
42218
|
+
}, {
|
|
42219
|
+
command: "pagegraph diff before.json after.json --json",
|
|
42220
|
+
description: "Emit one versioned JSON diff on stdout"
|
|
42221
|
+
}]), withHandler(fn("SeoCli.diff")(function* (options) {
|
|
42222
|
+
const beforeReport = yield* readAuditReport("before", options.before);
|
|
42223
|
+
const afterReport = yield* readAuditReport("after", options.after);
|
|
42224
|
+
const comparison = compareAuditReports(beforeReport, afterReport);
|
|
42225
|
+
if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
|
|
42226
|
+
if (options.json) yield* printJson(comparison.diff);
|
|
42227
|
+
else yield* printText(renderAuditDiff(comparison.diff));
|
|
42228
|
+
if (comparison.diff.outcome === "regressed") {
|
|
42229
|
+
const summary = comparison.diff.summary;
|
|
42230
|
+
return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
|
|
42231
|
+
}
|
|
42232
|
+
})));
|
|
42233
|
+
//#endregion
|
|
42234
|
+
//#region src/cli/serialize.ts
|
|
42235
|
+
const serializeCrumb = (crumb) => {
|
|
42236
|
+
if (crumb === void 0) return void 0;
|
|
42237
|
+
return typeof crumb === "function" ? "(dynamic)" : crumb;
|
|
42238
|
+
};
|
|
42239
|
+
/** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
|
|
42240
|
+
const serializeNode = (node) => ({
|
|
42241
|
+
path: node.path,
|
|
42242
|
+
kind: node.kind,
|
|
42243
|
+
source: node.source,
|
|
42244
|
+
policy: {
|
|
42245
|
+
kind: node.policy.kind,
|
|
42246
|
+
crumb: serializeCrumb(node.policy.crumb),
|
|
42247
|
+
sitemap: node.policy.sitemap,
|
|
42248
|
+
robots: node.policy.robots,
|
|
42249
|
+
related: node.policy.related,
|
|
42250
|
+
link: node.policy.link,
|
|
42251
|
+
redirectTo: node.policy.redirectTo
|
|
42252
|
+
},
|
|
42253
|
+
instance: node.instance
|
|
42254
|
+
});
|
|
42255
|
+
/** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
|
|
42256
|
+
const serializeGraph = (graph) => ({
|
|
42257
|
+
nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
|
|
42258
|
+
edges: graph.edges
|
|
42259
|
+
});
|
|
42260
|
+
const graphCommand = make$9("graph", {
|
|
42261
|
+
format: Literals("format", [
|
|
42262
|
+
"tree",
|
|
42263
|
+
"mermaid",
|
|
42264
|
+
"json"
|
|
42265
|
+
]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
|
|
42266
|
+
orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
|
|
42267
|
+
}).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
|
|
42268
|
+
{
|
|
42269
|
+
command: "pagegraph graph",
|
|
42270
|
+
description: "The graph as an indented path tree"
|
|
42271
|
+
},
|
|
42272
|
+
{
|
|
42273
|
+
command: "pagegraph graph --format mermaid",
|
|
42274
|
+
description: "A Mermaid diagram of nodes and edges"
|
|
42275
|
+
},
|
|
42276
|
+
{
|
|
42277
|
+
command: "pagegraph graph --orphans",
|
|
42278
|
+
description: "Only nodes with no incoming edge"
|
|
42279
|
+
},
|
|
42280
|
+
{
|
|
42281
|
+
command: "pagegraph graph --format json",
|
|
42282
|
+
description: "The serialized graph on stdout"
|
|
42283
|
+
}
|
|
42284
|
+
]), withHandler(fnUntraced(function* ({ format, orphans }) {
|
|
42285
|
+
const config = yield* loadSeoConfig;
|
|
42286
|
+
const graph = yield* scoped(acquireGraph(config));
|
|
42287
|
+
if (format === "json") {
|
|
42288
|
+
const serialized = serializeGraph(graph);
|
|
42289
|
+
if (!orphans) return yield* printJson(serialized);
|
|
42290
|
+
const orphanSet = orphanPaths(graph);
|
|
42291
|
+
return yield* printJson({
|
|
42292
|
+
nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
|
|
42293
|
+
edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
|
|
42294
|
+
});
|
|
42295
|
+
}
|
|
42296
|
+
if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
|
|
42297
|
+
return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
|
|
42298
|
+
})));
|
|
42299
|
+
//#endregion
|
|
42300
|
+
//#region src/core/inspect-html.ts
|
|
42301
|
+
const ENTITIES = {
|
|
42302
|
+
"&": "&",
|
|
42303
|
+
"<": "<",
|
|
42304
|
+
">": ">",
|
|
42305
|
+
""": "\"",
|
|
42306
|
+
"'": "'",
|
|
42307
|
+
"'": "'"
|
|
42308
|
+
};
|
|
42309
|
+
const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
|
|
42310
|
+
/** Pull double/single-quoted attributes off a single tag string. */
|
|
42311
|
+
const parseAttrs = (tag) => {
|
|
42312
|
+
const attrs = {};
|
|
42313
|
+
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
42314
|
+
let match;
|
|
42315
|
+
while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
|
|
42316
|
+
return attrs;
|
|
42317
|
+
};
|
|
42318
|
+
/** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
|
|
42319
|
+
const typeName = (value) => {
|
|
42320
|
+
if (typeof value === "string") return value;
|
|
42321
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
|
|
42322
|
+
return "unknown";
|
|
42323
|
+
};
|
|
42324
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42325
|
+
/** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
|
|
42326
|
+
const collectItems = (parsed) => {
|
|
42327
|
+
if (Array.isArray(parsed)) return parsed.filter(isObject);
|
|
42328
|
+
if (isObject(parsed)) {
|
|
42329
|
+
if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
|
|
42330
|
+
return [parsed];
|
|
42331
|
+
}
|
|
42332
|
+
return [];
|
|
42333
|
+
};
|
|
42334
|
+
const countQuestions = (mainEntity) => {
|
|
42335
|
+
if (!Array.isArray(mainEntity)) return 0;
|
|
42336
|
+
return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
|
|
42337
|
+
};
|
|
42338
|
+
const validateItemListElements = (value) => {
|
|
42339
|
+
if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
|
|
42340
|
+
const errors = [];
|
|
42341
|
+
value.forEach((entry, index) => {
|
|
42342
|
+
if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
|
|
42343
|
+
errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
|
|
42344
|
+
return;
|
|
42345
|
+
}
|
|
42346
|
+
if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
|
|
42347
|
+
if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
|
|
42348
|
+
});
|
|
42349
|
+
return errors;
|
|
42350
|
+
};
|
|
42351
|
+
/** Minimal per-type validation — enough to catch an empty or malformed block. */
|
|
42352
|
+
const validateItem = (item) => {
|
|
42353
|
+
const type = typeName(item["@type"]);
|
|
42354
|
+
const errors = [];
|
|
42355
|
+
if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
|
|
42356
|
+
if (!item["headline"]) errors.push("Article is missing `headline`.");
|
|
42357
|
+
if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
|
|
42358
|
+
} else if (type === "FAQPage") {
|
|
42359
|
+
if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
|
|
42360
|
+
} else if (type === "BreadcrumbList") {
|
|
42361
|
+
const items = item["itemListElement"];
|
|
42362
|
+
if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
|
|
42363
|
+
} else if (type === "ItemList") {
|
|
42364
|
+
errors.push(...validateItemListElements(item["itemListElement"]));
|
|
42365
|
+
const items = item["itemListElement"];
|
|
42366
|
+
if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
|
|
42367
|
+
}
|
|
42368
|
+
return {
|
|
42369
|
+
type,
|
|
42370
|
+
valid: errors.length === 0,
|
|
42371
|
+
errors
|
|
42372
|
+
};
|
|
42373
|
+
};
|
|
42374
|
+
const validateLdJson = (raw) => {
|
|
42375
|
+
let parsed;
|
|
42376
|
+
try {
|
|
42377
|
+
parsed = JSON.parse(raw);
|
|
42378
|
+
} catch (cause) {
|
|
42379
|
+
return [{
|
|
42380
|
+
type: "unparseable",
|
|
42381
|
+
valid: false,
|
|
42382
|
+
errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
|
|
42383
|
+
}];
|
|
42384
|
+
}
|
|
42385
|
+
const items = collectItems(parsed);
|
|
42386
|
+
if (items.length === 0) return [{
|
|
42387
|
+
type: "unknown",
|
|
42388
|
+
valid: false,
|
|
42389
|
+
errors: ["No JSON-LD object found in block."]
|
|
42390
|
+
}];
|
|
42391
|
+
return items.map(validateItem);
|
|
42392
|
+
};
|
|
42393
|
+
/** Parse a rendered HTML document's `<head>` into a report. Pure. */
|
|
42394
|
+
const inspectHtml = (url, status, html) => {
|
|
42395
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
42396
|
+
const head = headMatch ? headMatch[1] : html;
|
|
42397
|
+
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
42398
|
+
const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
|
|
42399
|
+
const og = {};
|
|
42400
|
+
const twitter = {};
|
|
42401
|
+
let description;
|
|
42402
|
+
let robots;
|
|
42403
|
+
for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
42404
|
+
const attrs = parseAttrs(tag);
|
|
42405
|
+
const content = attrs["content"];
|
|
42406
|
+
if (content === void 0) continue;
|
|
42407
|
+
const property = attrs["property"];
|
|
42408
|
+
const name = attrs["name"];
|
|
42409
|
+
if (property?.startsWith("og:")) og[property] = content;
|
|
42410
|
+
else if (name?.startsWith("twitter:")) twitter[name] = content;
|
|
42411
|
+
else if (name === "description") description = content;
|
|
42412
|
+
else if (name === "robots") robots = content;
|
|
42413
|
+
}
|
|
42414
|
+
let canonical;
|
|
42415
|
+
for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
|
|
42416
|
+
const attrs = parseAttrs(tag);
|
|
42417
|
+
if (attrs["rel"] === "canonical") canonical = attrs["href"];
|
|
42418
|
+
}
|
|
42419
|
+
const jsonLd = [];
|
|
42420
|
+
const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
42421
|
+
let scriptMatch;
|
|
42422
|
+
while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
|
|
42423
|
+
const issues = [];
|
|
42424
|
+
if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
|
|
42425
|
+
if (!title) issues.push("Missing <title>.");
|
|
42426
|
+
if (!description) issues.push("Missing meta description.");
|
|
42427
|
+
if (!canonical) issues.push("Missing canonical link.");
|
|
42428
|
+
for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
|
|
42429
|
+
return {
|
|
42430
|
+
url,
|
|
42431
|
+
status,
|
|
42432
|
+
title,
|
|
42433
|
+
description,
|
|
42434
|
+
canonical,
|
|
42435
|
+
robots,
|
|
42436
|
+
og,
|
|
42437
|
+
twitter,
|
|
42438
|
+
jsonLd,
|
|
42439
|
+
issues
|
|
42440
|
+
};
|
|
42441
|
+
};
|
|
42442
|
+
/** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
|
|
42443
|
+
const hasBlockingIssues = (report) => report.issues.length > 0;
|
|
42444
|
+
//#endregion
|
|
42445
|
+
//#region src/cli/live-inspect.ts
|
|
42446
|
+
/**
|
|
42447
|
+
* `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
|
|
42448
|
+
* validator in `../inspect-html`. The fetch is all that lives here — the
|
|
42449
|
+
* validation is a library capability, not a CLI one, so it stays out of the
|
|
42450
|
+
* Effect-bearing half of the package.
|
|
42451
|
+
*/
|
|
42452
|
+
/** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
|
|
42453
|
+
const fetchAndInspect = (url) => tryPromise({
|
|
42454
|
+
try: async () => {
|
|
42455
|
+
const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
|
|
42456
|
+
const html = await response.text();
|
|
42457
|
+
return inspectHtml(url, response.status, html);
|
|
42458
|
+
},
|
|
42459
|
+
catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
|
|
42460
|
+
});
|
|
42461
|
+
const inspectCommand = make$9("inspect", {
|
|
42462
|
+
target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
|
|
42463
|
+
live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
|
|
42464
|
+
json: jsonFlag
|
|
42465
|
+
}).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
|
|
42466
|
+
{
|
|
42467
|
+
command: "pagegraph inspect /pricing",
|
|
42468
|
+
description: "The graph node, policy, and edges for a path"
|
|
42469
|
+
},
|
|
42470
|
+
{
|
|
42471
|
+
command: "pagegraph inspect https://example.com/pricing --live",
|
|
42472
|
+
description: "Fetch the page and validate its head tags + JSON-LD"
|
|
42473
|
+
},
|
|
42474
|
+
{
|
|
42475
|
+
command: "pagegraph inspect /blog/some-post --json",
|
|
42476
|
+
description: "The node report as JSON"
|
|
42477
|
+
}
|
|
42478
|
+
]), withHandler(fnUntraced(function* ({ target, live, json }) {
|
|
42479
|
+
if (live) {
|
|
42480
|
+
const report = yield* fetchAndInspect(target);
|
|
42481
|
+
if (json) yield* printJson(report);
|
|
42482
|
+
else yield* printText(renderLiveReport(report));
|
|
42483
|
+
if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
|
|
42484
|
+
return;
|
|
42485
|
+
}
|
|
42486
|
+
const config = yield* loadSeoConfig;
|
|
42487
|
+
const report = inspectNode(yield* scoped(acquireGraph(config)), target);
|
|
42488
|
+
if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
|
|
42489
|
+
if (json) return yield* printJson({
|
|
42490
|
+
node: serializeNode(report.node),
|
|
42491
|
+
inSitemap: report.inSitemap,
|
|
42492
|
+
incoming: report.incoming,
|
|
42493
|
+
outgoing: report.outgoing
|
|
42494
|
+
});
|
|
42495
|
+
return yield* printText(renderNodeReport(report));
|
|
42496
|
+
})));
|
|
42497
|
+
//#endregion
|
|
42498
|
+
//#region src/audit/crawl.ts
|
|
42499
|
+
/** Anchors that are never HTML documents; skipped so they do not consume the budget. */
|
|
42500
|
+
const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
|
|
42501
|
+
/** The graph keys pages by path, so dedupe crawled URLs the same way. */
|
|
42502
|
+
const pageKey = (url) => normalizePath(url);
|
|
42503
|
+
/** Run `run` over `items` with at most `limit` in flight, preserving order. */
|
|
42504
|
+
const mapLimit = async (items, limit, run) => {
|
|
42505
|
+
const results = Array.from({ length: items.length });
|
|
42506
|
+
let index = 0;
|
|
42507
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
42508
|
+
for (;;) {
|
|
42509
|
+
const current = index++;
|
|
42510
|
+
if (current >= items.length) return;
|
|
42511
|
+
results[current] = await run(items[current]);
|
|
42512
|
+
}
|
|
42513
|
+
});
|
|
42514
|
+
await Promise.all(workers);
|
|
42515
|
+
return results.map((result, at) => {
|
|
42516
|
+
if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
|
|
42517
|
+
return result;
|
|
42518
|
+
});
|
|
42519
|
+
};
|
|
42520
|
+
/**
|
|
42521
|
+
* Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
|
|
42522
|
+
* successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
|
|
42523
|
+
* private and reserved destinations are rejected unless `allowPrivate` is set.
|
|
42524
|
+
*/
|
|
42525
|
+
const crawlRenderedPages = async (seed, options) => {
|
|
42526
|
+
let seedUrl;
|
|
42527
|
+
try {
|
|
42528
|
+
seedUrl = new URL(seed);
|
|
42529
|
+
} catch {
|
|
42530
|
+
throw new Error(`Not a valid URL: ${seed}`);
|
|
42531
|
+
}
|
|
42532
|
+
if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
|
|
42533
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
|
|
42534
|
+
const origin = seedUrl.origin;
|
|
42535
|
+
const probeOptions = {
|
|
42536
|
+
allowPrivate: options.allowPrivate,
|
|
42537
|
+
timeoutMs: options.timeoutMs,
|
|
42538
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
42539
|
+
captureAnchors: true
|
|
42540
|
+
};
|
|
42541
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
42542
|
+
const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
|
|
42543
|
+
/** Final paths already rendered, so a redirect cannot add the same page twice. */
|
|
42544
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
42545
|
+
const pages = [];
|
|
42546
|
+
const failures = [];
|
|
42547
|
+
const truncatedBodies = [];
|
|
42548
|
+
let attempts = 0;
|
|
42549
|
+
let truncated = false;
|
|
42550
|
+
let root = pageKey(seedUrl);
|
|
42551
|
+
let frontier = [{
|
|
42552
|
+
url: seedUrl,
|
|
42553
|
+
depth: 0
|
|
42554
|
+
}];
|
|
42555
|
+
const probePage = async (item) => {
|
|
42556
|
+
const request = {
|
|
42557
|
+
kind: "page-html",
|
|
42558
|
+
method: "GET",
|
|
42559
|
+
accept: "text/html, */*;q=0.1",
|
|
42560
|
+
url: item.url
|
|
42561
|
+
};
|
|
42562
|
+
const probe = await probeHttp(request, probeOptions);
|
|
42563
|
+
if (!probe.ok || probe.finalUrl === null) return {
|
|
42564
|
+
item,
|
|
42565
|
+
page: null,
|
|
42566
|
+
failure: {
|
|
42567
|
+
url: item.url.href,
|
|
41302
42568
|
error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
|
|
41303
42569
|
},
|
|
41304
42570
|
links: [],
|
|
@@ -41502,209 +42768,6 @@ const decodeRenderedEdges = (input) => {
|
|
|
41502
42768
|
});
|
|
41503
42769
|
};
|
|
41504
42770
|
//#endregion
|
|
41505
|
-
//#region node_modules/effect/dist/unstable/ai/Decision.js
|
|
41506
|
-
/**
|
|
41507
|
-
* Brand for decision definitions.
|
|
41508
|
-
*
|
|
41509
|
-
* @category type IDs
|
|
41510
|
-
* @since 4.0.0
|
|
41511
|
-
*/
|
|
41512
|
-
const TypeId = "~effect/ai/Decision";
|
|
41513
|
-
/**
|
|
41514
|
-
* Creates a probability decision from descriptions of both outcomes.
|
|
41515
|
-
*
|
|
41516
|
-
* **Example** (Estimating urgency)
|
|
41517
|
-
*
|
|
41518
|
-
* ```ts
|
|
41519
|
-
* import { Decision } from "effect/unstable/ai"
|
|
41520
|
-
*
|
|
41521
|
-
* const urgent = Decision.probability({
|
|
41522
|
-
* instructions: "The message is time-sensitive",
|
|
41523
|
-
* criteria: {
|
|
41524
|
-
* false: "No time pressure",
|
|
41525
|
-
* true: "Needs action now"
|
|
41526
|
-
* }
|
|
41527
|
-
* })
|
|
41528
|
-
* ```
|
|
41529
|
-
*
|
|
41530
|
-
* @see {@link classify} for more than two outcomes
|
|
41531
|
-
*
|
|
41532
|
-
* @category constructors
|
|
41533
|
-
* @since 4.0.0
|
|
41534
|
-
*/
|
|
41535
|
-
const probability = (options) => ({
|
|
41536
|
-
_tag: "Probability",
|
|
41537
|
-
instructions: options.instructions,
|
|
41538
|
-
criteria: options.criteria
|
|
41539
|
-
});
|
|
41540
|
-
/**
|
|
41541
|
-
* Creates a decision definition from an input schema and named decisions.
|
|
41542
|
-
* `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
|
|
41543
|
-
* calling the provider. Answer keys and types are inferred from the decisions.
|
|
41544
|
-
* Throws if `decisions` is empty.
|
|
41545
|
-
*
|
|
41546
|
-
* **Example** (Defining ticket triage)
|
|
41547
|
-
*
|
|
41548
|
-
* ```ts
|
|
41549
|
-
* import { Schema } from "effect"
|
|
41550
|
-
* import { Decision } from "effect/unstable/ai"
|
|
41551
|
-
*
|
|
41552
|
-
* const Ticket = Schema.Struct({
|
|
41553
|
-
* subject: Schema.String,
|
|
41554
|
-
* body: Schema.String
|
|
41555
|
-
* })
|
|
41556
|
-
*
|
|
41557
|
-
* const TicketTriage = Decision.make({
|
|
41558
|
-
* input: Ticket,
|
|
41559
|
-
* decisions: {
|
|
41560
|
-
* department: Decision.classify({
|
|
41561
|
-
* instructions: "Which team should handle this",
|
|
41562
|
-
* criteria: { billing: "payments", technical: "bugs" }
|
|
41563
|
-
* }),
|
|
41564
|
-
* urgent: Decision.probability({
|
|
41565
|
-
* instructions: "The message is time-sensitive",
|
|
41566
|
-
* criteria: { false: "No time pressure", true: "Needs action now" }
|
|
41567
|
-
* })
|
|
41568
|
-
* }
|
|
41569
|
-
* })
|
|
41570
|
-
* ```
|
|
41571
|
-
*
|
|
41572
|
-
* @see {@link Definition} for the returned shape
|
|
41573
|
-
*
|
|
41574
|
-
* @category constructors
|
|
41575
|
-
* @since 4.0.0
|
|
41576
|
-
*/
|
|
41577
|
-
const make = (options) => {
|
|
41578
|
-
if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
|
|
41579
|
-
return {
|
|
41580
|
-
[TypeId]: TypeId,
|
|
41581
|
-
input: options.input,
|
|
41582
|
-
decisions: options.decisions
|
|
41583
|
-
};
|
|
41584
|
-
};
|
|
41585
|
-
//#endregion
|
|
41586
|
-
//#region src/links/decide.ts
|
|
41587
|
-
/**
|
|
41588
|
-
* Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
|
|
41589
|
-
*
|
|
41590
|
-
* A candidate is a `(source → destination)` pair with the source copy. The model
|
|
41591
|
-
* answers two probabilities — does the source have a genuine reader-facing
|
|
41592
|
-
* reason to link to the destination, and is descriptive anchor text already in
|
|
41593
|
-
* the copy — and this module maps them to a verdict. Anything the model is not
|
|
41594
|
-
* confident about lands in the `review` bucket; nothing is applied automatically.
|
|
41595
|
-
*
|
|
41596
|
-
* This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
|
|
41597
|
-
* the library entries stay Effect-free and only the bundled `pagegraph` bin
|
|
41598
|
-
* ships it.
|
|
41599
|
-
*/
|
|
41600
|
-
/** Default probability boundary between a confident and an uncertain answer. */
|
|
41601
|
-
const DEFAULT_LINK_THRESHOLD = .7;
|
|
41602
|
-
/** One proposed contextual link, plus the source copy the model reads. */
|
|
41603
|
-
const LinkCandidate = Struct({
|
|
41604
|
-
sourceUrl: String$6,
|
|
41605
|
-
destinationUrl: String$6,
|
|
41606
|
-
sourceText: String$6,
|
|
41607
|
-
/** Descriptive anchor text already present for the destination, when known. */
|
|
41608
|
-
existingAnchor: optional$3(String$6)
|
|
41609
|
-
});
|
|
41610
|
-
/**
|
|
41611
|
-
* The two decisions every candidate answers. Both are probabilities, so the
|
|
41612
|
-
* model returns a calibrated number rather than generated prose.
|
|
41613
|
-
*/
|
|
41614
|
-
const LinkDecision = make({
|
|
41615
|
-
input: LinkCandidate,
|
|
41616
|
-
decisions: {
|
|
41617
|
-
realReason: probability({
|
|
41618
|
-
instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
|
|
41619
|
-
criteria: {
|
|
41620
|
-
false: "No genuine reason; the link would be noise or only keyword overlap.",
|
|
41621
|
-
true: "A reader of the source would plausibly want the destination next."
|
|
41622
|
-
}
|
|
41623
|
-
}),
|
|
41624
|
-
anchorPresent: probability({
|
|
41625
|
-
instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
|
|
41626
|
-
criteria: {
|
|
41627
|
-
false: "No existing anchor to the destination in the copy.",
|
|
41628
|
-
true: "Descriptive anchor text to the destination is already in the copy."
|
|
41629
|
-
}
|
|
41630
|
-
})
|
|
41631
|
-
}
|
|
41632
|
-
});
|
|
41633
|
-
/** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
|
|
41634
|
-
const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
|
|
41635
|
-
/**
|
|
41636
|
-
* Map the two probabilities to a verdict. A probability inside the review band
|
|
41637
|
-
* makes the whole candidate uncertain, so the confident half of the pair can
|
|
41638
|
-
* never auto-resolve it. Requires `threshold > 0.5`, so the review band
|
|
41639
|
-
* `(1 - threshold, threshold)` is non-empty.
|
|
41640
|
-
*
|
|
41641
|
-
* - `present` — descriptive anchor already in the copy; nothing to add.
|
|
41642
|
-
* - `recommend` — a real reason and no existing anchor; add a contextual link.
|
|
41643
|
-
* - `skip` — no real reason; leave the copy alone.
|
|
41644
|
-
* - `review` — at least one answer is inside the band; a human decides.
|
|
41645
|
-
*/
|
|
41646
|
-
const classifyVerdict = (realReason, anchorPresent, threshold) => {
|
|
41647
|
-
if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
|
|
41648
|
-
if (anchorPresent >= threshold) return "present";
|
|
41649
|
-
if (realReason >= threshold) return "recommend";
|
|
41650
|
-
return "skip";
|
|
41651
|
-
};
|
|
41652
|
-
/** Group records into resolved/review buckets and count each verdict. */
|
|
41653
|
-
const buildLinksDecideReport = (options) => {
|
|
41654
|
-
const counts = {
|
|
41655
|
-
recommend: 0,
|
|
41656
|
-
present: 0,
|
|
41657
|
-
skip: 0,
|
|
41658
|
-
review: 0
|
|
41659
|
-
};
|
|
41660
|
-
const resolved = [];
|
|
41661
|
-
const review = [];
|
|
41662
|
-
for (const record of options.records) {
|
|
41663
|
-
counts[record.verdict] += 1;
|
|
41664
|
-
if (record.verdict === "review") review.push(record);
|
|
41665
|
-
else resolved.push(record);
|
|
41666
|
-
}
|
|
41667
|
-
return {
|
|
41668
|
-
kind: "links-decide",
|
|
41669
|
-
schemaVersion: 1,
|
|
41670
|
-
model: options.model,
|
|
41671
|
-
threshold: options.threshold,
|
|
41672
|
-
counts: {
|
|
41673
|
-
candidates: options.records.length,
|
|
41674
|
-
...counts
|
|
41675
|
-
},
|
|
41676
|
-
resolved,
|
|
41677
|
-
review
|
|
41678
|
-
};
|
|
41679
|
-
};
|
|
41680
|
-
/**
|
|
41681
|
-
* Answer every candidate through the ambient `DecisionModel` and bucket the
|
|
41682
|
-
* results. The model answers both decisions in one call per candidate; candidates
|
|
41683
|
-
* are dispatched with bounded concurrency.
|
|
41684
|
-
*/
|
|
41685
|
-
const decideLinks = (candidates, options) => gen(function* () {
|
|
41686
|
-
const records = yield* forEach$1(candidates, (candidate) => decide(LinkDecision, { input: candidate }).pipe(map$5(({ answers }) => {
|
|
41687
|
-
const realReason = answers.realReason.probability;
|
|
41688
|
-
const anchorPresent = answers.anchorPresent.probability;
|
|
41689
|
-
return {
|
|
41690
|
-
sourceUrl: candidate.sourceUrl,
|
|
41691
|
-
destinationUrl: candidate.destinationUrl,
|
|
41692
|
-
sourceText: candidate.sourceText,
|
|
41693
|
-
existingAnchor: candidate.existingAnchor ?? null,
|
|
41694
|
-
realReason,
|
|
41695
|
-
anchorPresent,
|
|
41696
|
-
verdict: classifyVerdict(realReason, anchorPresent, options.threshold)
|
|
41697
|
-
};
|
|
41698
|
-
})), { concurrency: options.concurrency ?? 4 });
|
|
41699
|
-
return buildLinksDecideReport({
|
|
41700
|
-
model: options.model,
|
|
41701
|
-
threshold: options.threshold,
|
|
41702
|
-
records
|
|
41703
|
-
});
|
|
41704
|
-
});
|
|
41705
|
-
/** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
|
|
41706
|
-
const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
|
|
41707
|
-
//#endregion
|
|
41708
42771
|
//#region src/cli/commands/links.ts
|
|
41709
42772
|
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
41710
42773
|
const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
|
|
@@ -41958,6 +43021,7 @@ const decideFile = String$1("file").pipe(withDescription("JSON file with an arra
|
|
|
41958
43021
|
const thresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_LINK_THRESHOLD));
|
|
41959
43022
|
const modelFlag = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
|
|
41960
43023
|
const concurrencyFlag = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
|
|
43024
|
+
const budgetFlag = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
|
|
41961
43025
|
const checkThreshold = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
|
|
41962
43026
|
/** Read the candidate JSON from a file argument or stdin; a TTY with no file is an error. */
|
|
41963
43027
|
const readCandidatesText = (file) => try_({
|
|
@@ -41981,8 +43045,9 @@ const linksDecideCommand = make$9("decide", {
|
|
|
41981
43045
|
json: jsonFlag,
|
|
41982
43046
|
threshold: thresholdFlag,
|
|
41983
43047
|
model: modelFlag,
|
|
41984
|
-
concurrency: concurrencyFlag
|
|
41985
|
-
|
|
43048
|
+
concurrency: concurrencyFlag,
|
|
43049
|
+
budget: budgetFlag
|
|
43050
|
+
}).pipe(withDescription$1("Answer real-reason, anchor-present, direction, and relevance for candidate links; below-threshold candidates go to review"), withExamples([
|
|
41986
43051
|
{
|
|
41987
43052
|
command: "pagegraph links decide candidates.json",
|
|
41988
43053
|
description: "Decide a candidate set and print the reviewable plan"
|
|
@@ -41998,13 +43063,15 @@ const linksDecideCommand = make$9("decide", {
|
|
|
41998
43063
|
]), withHandler(fn("SeoCli.linksDecide")(function* (options) {
|
|
41999
43064
|
const threshold = yield* checkThreshold(options.threshold);
|
|
42000
43065
|
const concurrency = yield* positive("concurrency", options.concurrency);
|
|
43066
|
+
const budget = yield* positive("budget", options.budget);
|
|
42001
43067
|
const text = yield* readCandidatesText(getOrUndefined$1(options.file));
|
|
42002
43068
|
const candidates = yield* parseCandidates(text);
|
|
42003
43069
|
if (process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "") return yield* new SeoCliError({ message: "TYPESAFE_API_KEY is not set; `pagegraph links decide` answers through TypeSafe System One." });
|
|
42004
43070
|
const report = yield* decideLinks(candidates, {
|
|
42005
43071
|
model: options.model,
|
|
42006
43072
|
threshold,
|
|
42007
|
-
concurrency
|
|
43073
|
+
concurrency,
|
|
43074
|
+
budget
|
|
42008
43075
|
}).pipe(provide(typeSafeDecisionModel(options.model)), mapError(decisionErrorMessage));
|
|
42009
43076
|
if (options.json) yield* printJson(report);
|
|
42010
43077
|
else yield* printText(renderLinksDecideReport(report));
|
|
@@ -42167,6 +43234,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect and audit a TanS
|
|
|
42167
43234
|
}
|
|
42168
43235
|
]), withSubcommands([
|
|
42169
43236
|
auditCommand,
|
|
43237
|
+
decideCommandGroup,
|
|
42170
43238
|
diffCommand,
|
|
42171
43239
|
graphCommand,
|
|
42172
43240
|
inspectCommand,
|