@f5-sales-demo/xcsh 22.3.3 → 22.4.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/package.json +8 -8
- package/src/extensibility/plugins/marketplace/builtin-marketplace-snapshot.ts +1 -1
- package/src/extensibility/plugins/marketplace/builtin-marketplace.provenance.json +3 -3
- package/src/extensibility/slash-commands.ts +21 -0
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/documentation-metadata.ts +271 -0
- package/src/internal-urls/documentation-repository.ts +191 -54
- package/src/internal-urls/documentation-resolve.ts +123 -9
- package/src/internal-urls/documentation-snapshot.ts +167 -11
- package/src/modes/interactive-mode.ts +2 -5
- package/src/prompts/rules/llms-search.md +5 -3
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/slash-commands/builtin-registry.ts +2 -1
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "22.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "22.4.0",
|
|
21
|
+
"commit": "d4a6bd5c3d91616121cb5fb195c0dd0c2feb5bf7",
|
|
22
|
+
"shortCommit": "d4a6bd5",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v22.
|
|
25
|
-
"commitDate": "2026-09-
|
|
26
|
-
"buildDate": "2026-09-
|
|
24
|
+
"tag": "v22.4.0",
|
|
25
|
+
"commitDate": "2026-09-29T03:06:46Z",
|
|
26
|
+
"buildDate": "2026-09-29T03:44:37.730Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v22.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/d4a6bd5c3d91616121cb5fb195c0dd0c2feb5bf7",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v22.4.0"
|
|
33
33
|
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { DocumentationSource } from "./documentation-resolve";
|
|
3
|
+
|
|
4
|
+
export const DOCUMENTATION_CONTENT_TYPES = [
|
|
5
|
+
"product_overview",
|
|
6
|
+
"solution_overview",
|
|
7
|
+
"concept",
|
|
8
|
+
"how_to",
|
|
9
|
+
"reference",
|
|
10
|
+
"knowledge_article",
|
|
11
|
+
] as const;
|
|
12
|
+
export type DocumentationContentType = (typeof DOCUMENTATION_CONTENT_TYPES)[number];
|
|
13
|
+
|
|
14
|
+
export const DOCUMENTATION_TASK_TYPES = ["concept", "configure", "troubleshoot", "support", "reference"] as const;
|
|
15
|
+
export type DocumentationTaskType = (typeof DOCUMENTATION_TASK_TYPES)[number];
|
|
16
|
+
|
|
17
|
+
export const DOCUMENTATION_LIFECYCLES = ["current", "deprecated", "superseded"] as const;
|
|
18
|
+
export type DocumentationLifecycle = (typeof DOCUMENTATION_LIFECYCLES)[number];
|
|
19
|
+
|
|
20
|
+
export interface DocumentationRelatedDocument {
|
|
21
|
+
readonly relation: DocumentationTaskType;
|
|
22
|
+
readonly title: string;
|
|
23
|
+
readonly canonicalUrl: string;
|
|
24
|
+
readonly source: DocumentationSource;
|
|
25
|
+
readonly stablePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DocumentationMetadata {
|
|
29
|
+
readonly metadataSchema: 1;
|
|
30
|
+
readonly product: string | null;
|
|
31
|
+
readonly contentType: DocumentationContentType;
|
|
32
|
+
readonly taskType: DocumentationTaskType;
|
|
33
|
+
readonly canonicalUrl: string;
|
|
34
|
+
readonly lastUpdated: string | null;
|
|
35
|
+
readonly language: string;
|
|
36
|
+
readonly aliases: readonly string[];
|
|
37
|
+
readonly lifecycle: DocumentationLifecycle;
|
|
38
|
+
readonly replacementUrl: string | null;
|
|
39
|
+
readonly relatedDocuments: readonly DocumentationRelatedDocument[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DocumentationPassage {
|
|
43
|
+
readonly anchor: string;
|
|
44
|
+
readonly heading: string;
|
|
45
|
+
readonly markdown: string;
|
|
46
|
+
readonly ordinal: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DocumentationGraphNode {
|
|
50
|
+
readonly source: DocumentationSource;
|
|
51
|
+
readonly stablePath: string;
|
|
52
|
+
readonly title: string;
|
|
53
|
+
readonly metadata: DocumentationMetadata;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const CONTENT_TYPE_SET = new Set<string>(DOCUMENTATION_CONTENT_TYPES);
|
|
57
|
+
const TASK_TYPE_SET = new Set<string>(DOCUMENTATION_TASK_TYPES);
|
|
58
|
+
const LIFECYCLE_SET = new Set<string>(DOCUMENTATION_LIFECYCLES);
|
|
59
|
+
const LANGUAGE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
60
|
+
const PRODUCT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
61
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
62
|
+
const SAFE_PATH = /^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/;
|
|
63
|
+
const SOURCE_SET = new Set<DocumentationSource>(["docs-cloud-f5-com", "my-f5-com", "www-f5-com"]);
|
|
64
|
+
|
|
65
|
+
function record(value: unknown, field: string): Record<string, unknown> {
|
|
66
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object`);
|
|
67
|
+
return value as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nonEmpty(value: unknown, field: string): string {
|
|
71
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} must be a non-empty string`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nullableDate(value: unknown, field: string): string | null {
|
|
76
|
+
if (value === null) return null;
|
|
77
|
+
const result = nonEmpty(value, field);
|
|
78
|
+
const parsed = new Date(`${result}T00:00:00Z`);
|
|
79
|
+
if (
|
|
80
|
+
result.length !== 10 ||
|
|
81
|
+
!DATE.test(result) ||
|
|
82
|
+
Number.isNaN(parsed.valueOf()) ||
|
|
83
|
+
parsed.toISOString().slice(0, 10) !== result
|
|
84
|
+
)
|
|
85
|
+
throw new Error(`${field} is invalid`);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sortedStrings(value: unknown, field: string): readonly string[] {
|
|
90
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim())) {
|
|
91
|
+
throw new Error(`${field} must contain non-empty strings`);
|
|
92
|
+
}
|
|
93
|
+
const result = value as string[];
|
|
94
|
+
const expected = [...new Set(result)].sort((left, right) =>
|
|
95
|
+
compareText(left.toLocaleLowerCase("en-US"), right.toLocaleLowerCase("en-US")),
|
|
96
|
+
);
|
|
97
|
+
if (JSON.stringify(result) !== JSON.stringify(expected)) throw new Error(`${field} must be sorted and deduplicated`);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compareText(left: string, right: string): number {
|
|
102
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseDocumentationMetadata(
|
|
106
|
+
value: Record<string, unknown>,
|
|
107
|
+
validateUrl: (value: unknown, source: DocumentationSource | undefined, field: string) => string,
|
|
108
|
+
source: DocumentationSource,
|
|
109
|
+
field: string,
|
|
110
|
+
): DocumentationMetadata {
|
|
111
|
+
if (value.metadata_schema !== 1) throw new Error(`${field}.metadata_schema must be 1`);
|
|
112
|
+
const product = value.product;
|
|
113
|
+
if (product !== null && (typeof product !== "string" || !PRODUCT.test(product))) {
|
|
114
|
+
throw new Error(`${field}.product must be a stable slug or null`);
|
|
115
|
+
}
|
|
116
|
+
const contentType = nonEmpty(value.content_type, `${field}.content_type`);
|
|
117
|
+
if (!CONTENT_TYPE_SET.has(contentType)) throw new Error(`${field}.content_type is invalid`);
|
|
118
|
+
const taskType = nonEmpty(value.task_type, `${field}.task_type`);
|
|
119
|
+
if (!TASK_TYPE_SET.has(taskType)) throw new Error(`${field}.task_type is invalid`);
|
|
120
|
+
const canonicalUrl = validateUrl(value.canonical_url, source, `${field}.canonical_url`);
|
|
121
|
+
const lastUpdated = nullableDate(value.last_updated, `${field}.last_updated`);
|
|
122
|
+
const language = nonEmpty(value.language, `${field}.language`);
|
|
123
|
+
if (!LANGUAGE.test(language)) throw new Error(`${field}.language is invalid`);
|
|
124
|
+
const aliases = sortedStrings(value.aliases, `${field}.aliases`);
|
|
125
|
+
const lifecycle = nonEmpty(value.lifecycle, `${field}.lifecycle`);
|
|
126
|
+
if (!LIFECYCLE_SET.has(lifecycle)) throw new Error(`${field}.lifecycle is invalid`);
|
|
127
|
+
const replacementUrl =
|
|
128
|
+
value.replacement_url === null ? null : validateUrl(value.replacement_url, undefined, `${field}.replacement_url`);
|
|
129
|
+
if ((lifecycle === "superseded") !== (replacementUrl !== null)) {
|
|
130
|
+
throw new Error(`${field}.replacement_url must exist only for superseded documents`);
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(value.related_documents)) throw new Error(`${field}.related_documents must be a list`);
|
|
133
|
+
const relatedDocuments = value.related_documents.map((item, position) => {
|
|
134
|
+
const relation = record(item, `${field}.related_documents[${position}]`);
|
|
135
|
+
const relationType = nonEmpty(relation.relation, `${field}.related_documents[${position}].relation`);
|
|
136
|
+
if (!TASK_TYPE_SET.has(relationType))
|
|
137
|
+
throw new Error(`${field}.related_documents[${position}].relation is invalid`);
|
|
138
|
+
const relatedSource = nonEmpty(
|
|
139
|
+
relation.source_id,
|
|
140
|
+
`${field}.related_documents[${position}].source_id`,
|
|
141
|
+
) as DocumentationSource;
|
|
142
|
+
if (!SOURCE_SET.has(relatedSource))
|
|
143
|
+
throw new Error(`${field}.related_documents[${position}].source_id is invalid`);
|
|
144
|
+
const stablePath = nonEmpty(relation.stable_path, `${field}.related_documents[${position}].stable_path`);
|
|
145
|
+
if (!SAFE_PATH.test(stablePath))
|
|
146
|
+
throw new Error(`${field}.related_documents[${position}].stable_path is invalid`);
|
|
147
|
+
return {
|
|
148
|
+
relation: relationType as DocumentationTaskType,
|
|
149
|
+
title: nonEmpty(relation.title, `${field}.related_documents[${position}].title`),
|
|
150
|
+
canonicalUrl: validateUrl(
|
|
151
|
+
relation.canonical_url,
|
|
152
|
+
relatedSource,
|
|
153
|
+
`${field}.related_documents[${position}].canonical_url`,
|
|
154
|
+
),
|
|
155
|
+
source: relatedSource,
|
|
156
|
+
stablePath,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const relatedOrder = [...relatedDocuments].sort(
|
|
160
|
+
(left, right) =>
|
|
161
|
+
compareText(left.relation, right.relation) ||
|
|
162
|
+
compareText(left.title.toLocaleLowerCase("en-US"), right.title.toLocaleLowerCase("en-US")) ||
|
|
163
|
+
compareText(left.canonicalUrl, right.canonicalUrl) ||
|
|
164
|
+
compareText(left.source, right.source) ||
|
|
165
|
+
compareText(left.stablePath, right.stablePath),
|
|
166
|
+
);
|
|
167
|
+
if (JSON.stringify(relatedDocuments) !== JSON.stringify(relatedOrder)) {
|
|
168
|
+
throw new Error(`${field}.related_documents must be sorted`);
|
|
169
|
+
}
|
|
170
|
+
if (new Set(relatedDocuments.map(item => item.canonicalUrl)).size !== relatedDocuments.length) {
|
|
171
|
+
throw new Error(`${field}.related_documents must be deduplicated`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
metadataSchema: 1,
|
|
175
|
+
product: product as string | null,
|
|
176
|
+
contentType: contentType as DocumentationContentType,
|
|
177
|
+
taskType: taskType as DocumentationTaskType,
|
|
178
|
+
canonicalUrl,
|
|
179
|
+
lastUpdated,
|
|
180
|
+
language,
|
|
181
|
+
aliases,
|
|
182
|
+
lifecycle: lifecycle as DocumentationLifecycle,
|
|
183
|
+
replacementUrl,
|
|
184
|
+
relatedDocuments,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function githubHeadingAnchor(heading: string, occurrences: Map<string, number>): string {
|
|
189
|
+
const base = heading
|
|
190
|
+
.trim()
|
|
191
|
+
.toLocaleLowerCase("en-US")
|
|
192
|
+
.replace(/<[^>]*>/g, "")
|
|
193
|
+
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "")
|
|
194
|
+
.replace(/\s/g, "-");
|
|
195
|
+
const normalized = base || "section";
|
|
196
|
+
const count = occurrences.get(normalized) ?? 0;
|
|
197
|
+
occurrences.set(normalized, count + 1);
|
|
198
|
+
return count === 0 ? normalized : `${normalized}-${count}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function splitHeadingPassages(body: string): readonly DocumentationPassage[] {
|
|
202
|
+
const lines = body.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
203
|
+
const headings: Array<{ line: number; heading: string; anchor: string }> = [];
|
|
204
|
+
const occurrences = new Map<string, number>();
|
|
205
|
+
let fence: string | undefined;
|
|
206
|
+
for (const [line, value] of lines.entries()) {
|
|
207
|
+
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(value);
|
|
208
|
+
if (fenceMatch) {
|
|
209
|
+
const marker = fenceMatch[1]![0]!;
|
|
210
|
+
if (!fence) fence = marker;
|
|
211
|
+
else if (fence === marker) fence = undefined;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (fence) continue;
|
|
215
|
+
const match = /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(value);
|
|
216
|
+
if (!match) continue;
|
|
217
|
+
const heading = match[2]!.trim();
|
|
218
|
+
headings.push({ line, heading, anchor: githubHeadingAnchor(heading, occurrences) });
|
|
219
|
+
}
|
|
220
|
+
if (headings.length === 0)
|
|
221
|
+
return [{ anchor: "document", heading: "Document", markdown: `${body.trim()}\n`, ordinal: 0 }];
|
|
222
|
+
return headings.map((heading, ordinal) => {
|
|
223
|
+
const start = heading.line;
|
|
224
|
+
const end = headings[ordinal + 1]?.line ?? lines.length;
|
|
225
|
+
return {
|
|
226
|
+
anchor: heading.anchor,
|
|
227
|
+
heading: heading.heading,
|
|
228
|
+
markdown: `${lines.slice(start, end).join("\n").trim()}\n`,
|
|
229
|
+
ordinal,
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function passageHash(documentHash: string, anchor: string, markdown: string): string {
|
|
235
|
+
return createHash("sha256").update(`${documentHash}\0${anchor}\0${markdown}`).digest("hex");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function validateDocumentationGraph(documents: readonly DocumentationGraphNode[]): void {
|
|
239
|
+
const canonicalGroups = new Map<string, DocumentationGraphNode[]>();
|
|
240
|
+
for (const document of documents) {
|
|
241
|
+
canonicalGroups.set(document.metadata.canonicalUrl, [
|
|
242
|
+
...(canonicalGroups.get(document.metadata.canonicalUrl) ?? []),
|
|
243
|
+
document,
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
const uniqueTargets = new Map(
|
|
247
|
+
[...canonicalGroups]
|
|
248
|
+
.filter(([, matches]) => matches.length === 1)
|
|
249
|
+
.map(([url, matches]) => [url, matches[0]!] as const),
|
|
250
|
+
);
|
|
251
|
+
for (const document of documents) {
|
|
252
|
+
if (document.metadata.replacementUrl !== null && !uniqueTargets.has(document.metadata.replacementUrl)) {
|
|
253
|
+
throw new Error(`replacement target is missing or ambiguous: ${document.metadata.replacementUrl}`);
|
|
254
|
+
}
|
|
255
|
+
for (const related of document.metadata.relatedDocuments) {
|
|
256
|
+
const target = uniqueTargets.get(related.canonicalUrl);
|
|
257
|
+
if (!target) throw new Error(`related target is missing or ambiguous: ${related.canonicalUrl}`);
|
|
258
|
+
if (
|
|
259
|
+
target.source !== related.source ||
|
|
260
|
+
target.stablePath !== related.stablePath ||
|
|
261
|
+
target.title !== related.title ||
|
|
262
|
+
target.metadata.taskType !== related.relation
|
|
263
|
+
) {
|
|
264
|
+
throw new Error(`related target metadata mismatch: ${related.canonicalUrl}`);
|
|
265
|
+
}
|
|
266
|
+
if (related.canonicalUrl === document.metadata.canonicalUrl) {
|
|
267
|
+
throw new Error(`related target cannot reference itself: ${related.canonicalUrl}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -6,14 +6,15 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { gunzipSync } from "node:zlib";
|
|
8
8
|
import { createStore } from "@tobilu/qmd";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
import type {
|
|
10
|
+
DocumentationAsset,
|
|
11
|
+
DocumentationDocument,
|
|
12
|
+
DocumentationRepository,
|
|
13
|
+
DocumentationSearchFilters,
|
|
14
|
+
DocumentationSearchResult,
|
|
15
|
+
DocumentationSource,
|
|
16
16
|
} from "./documentation-resolve";
|
|
17
|
+
import { DOCUMENTATION_SOURCES } from "./documentation-resolve";
|
|
17
18
|
import { extractVerifiedDocumentationAsset, type VerifiedDocumentationAsset } from "./documentation-snapshot";
|
|
18
19
|
|
|
19
20
|
interface EmbeddedDocumentationAssetMetadata {
|
|
@@ -67,6 +68,24 @@ interface DocumentRow {
|
|
|
67
68
|
readonly title: string;
|
|
68
69
|
readonly original_url: string;
|
|
69
70
|
readonly markdown: string;
|
|
71
|
+
readonly canonical_url: string;
|
|
72
|
+
readonly last_updated: string | null;
|
|
73
|
+
readonly product: string | null;
|
|
74
|
+
readonly content_type: DocumentationSearchResult["contentType"];
|
|
75
|
+
readonly task_type: DocumentationSearchResult["taskType"];
|
|
76
|
+
readonly language: string;
|
|
77
|
+
readonly lifecycle: DocumentationSearchResult["lifecycle"];
|
|
78
|
+
readonly replacement_url: string | null;
|
|
79
|
+
readonly aliases_json: string;
|
|
80
|
+
readonly related_documents_json: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PassageRow extends DocumentRow {
|
|
84
|
+
readonly anchor: string;
|
|
85
|
+
readonly heading: string;
|
|
86
|
+
readonly passage_markdown: string;
|
|
87
|
+
readonly ordinal: number;
|
|
88
|
+
readonly score: number;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
interface AssetRow {
|
|
@@ -219,6 +238,7 @@ function verifyDatabase(database: Database, assets: EmbeddedDocumentationAssets)
|
|
|
219
238
|
asset_count: String(assets.assetCount),
|
|
220
239
|
document_count: String(assets.documentCount),
|
|
221
240
|
fingerprint: assets.fingerprint,
|
|
241
|
+
index_schema: "2",
|
|
222
242
|
release_tag: assets.releaseTag,
|
|
223
243
|
source_commit: assets.sourceCommit,
|
|
224
244
|
};
|
|
@@ -234,14 +254,6 @@ function verifyDatabase(database: Database, assets: EmbeddedDocumentationAssets)
|
|
|
234
254
|
}
|
|
235
255
|
}
|
|
236
256
|
|
|
237
|
-
function stablePathFromResult(displayPath: string, source: DocumentationSource): string | null {
|
|
238
|
-
const prefix = `${source}/`;
|
|
239
|
-
const suffix = "/index.md";
|
|
240
|
-
if (!displayPath.startsWith(prefix) || !displayPath.endsWith(suffix)) return null;
|
|
241
|
-
const stablePath = displayPath.slice(prefix.length, -suffix.length);
|
|
242
|
-
return stablePath || null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
257
|
function boundedSnippet(markdown: string, query: string): string {
|
|
246
258
|
const body = markdown
|
|
247
259
|
.replace(/^---\n.*?\n---\n?/s, "")
|
|
@@ -264,6 +276,122 @@ function lexicalDocumentationQuery(query: string): string {
|
|
|
264
276
|
return query.replace(/^\s*(?:what|who)\s+(?:is|are)\s+/i, "").trim() || query;
|
|
265
277
|
}
|
|
266
278
|
|
|
279
|
+
function ftsPhrase(value: string): string {
|
|
280
|
+
return value
|
|
281
|
+
.normalize("NFKC")
|
|
282
|
+
.replace(/[^\p{L}\p{N}_-]+/gu, " ")
|
|
283
|
+
.replace(/-/g, " ")
|
|
284
|
+
.trim()
|
|
285
|
+
.replace(/\s+/g, " ");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function aliasGroups(database: Database): readonly (readonly string[])[] {
|
|
289
|
+
const rows = database
|
|
290
|
+
.query("SELECT product, alias FROM documentation_aliases ORDER BY product, alias")
|
|
291
|
+
.all() as Array<{
|
|
292
|
+
product: string;
|
|
293
|
+
alias: string;
|
|
294
|
+
}>;
|
|
295
|
+
const groups = new Map<string, string[]>();
|
|
296
|
+
for (const row of rows) groups.set(row.product, [...(groups.get(row.product) ?? []), row.alias]);
|
|
297
|
+
return [...groups.values()].sort(
|
|
298
|
+
(left, right) => Math.max(...right.map(value => value.length)) - Math.max(...left.map(value => value.length)),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function escapedFtsQuery(database: Database, query: string): string {
|
|
303
|
+
let remaining = lexicalDocumentationQuery(query);
|
|
304
|
+
const expansions: string[] = [];
|
|
305
|
+
for (const aliases of aliasGroups(database)) {
|
|
306
|
+
const matched = [...aliases]
|
|
307
|
+
.sort((left, right) => right.length - left.length)
|
|
308
|
+
.find(alias =>
|
|
309
|
+
new RegExp(
|
|
310
|
+
`(^|[^\\p{L}\\p{N}])${alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`,
|
|
311
|
+
"iu",
|
|
312
|
+
).test(remaining),
|
|
313
|
+
);
|
|
314
|
+
if (!matched) continue;
|
|
315
|
+
remaining = remaining.replace(new RegExp(matched.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu"), " ");
|
|
316
|
+
expansions.push(`(${aliases.map(alias => `"${ftsPhrase(alias)}"`).join(" OR ")})`);
|
|
317
|
+
}
|
|
318
|
+
const terms =
|
|
319
|
+
remaining
|
|
320
|
+
.match(/[\p{L}\p{N}_-]+/gu)
|
|
321
|
+
?.map(term => ftsPhrase(term))
|
|
322
|
+
.filter(Boolean) ?? [];
|
|
323
|
+
return [...expansions, ...terms.map(term => `"${term}"*`)].join(" AND ");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function searchRows(
|
|
327
|
+
database: Database,
|
|
328
|
+
query: string,
|
|
329
|
+
source: DocumentationSource | undefined,
|
|
330
|
+
filters: DocumentationSearchFilters,
|
|
331
|
+
): PassageRow[] {
|
|
332
|
+
const clauses = ["documents_fts MATCH ?", "d.active = 1"];
|
|
333
|
+
const parameters: Array<string | null> = [escapedFtsQuery(database, query)];
|
|
334
|
+
const add = (column: string, value: string | undefined): void => {
|
|
335
|
+
if (value === undefined) return;
|
|
336
|
+
clauses.push(`${column} = ?`);
|
|
337
|
+
parameters.push(value);
|
|
338
|
+
};
|
|
339
|
+
add("dd.source", source);
|
|
340
|
+
add("dd.product", filters.product);
|
|
341
|
+
add("dd.content_type", filters.contentType);
|
|
342
|
+
add("dd.task_type", filters.taskType);
|
|
343
|
+
add("dd.language", filters.language);
|
|
344
|
+
add("dd.lifecycle", filters.lifecycle);
|
|
345
|
+
if (!parameters[0]) return [];
|
|
346
|
+
return database
|
|
347
|
+
.query(`WITH scored AS (
|
|
348
|
+
SELECT dd.*, p.anchor, p.heading, p.markdown AS passage_markdown, p.ordinal,
|
|
349
|
+
ABS(bm25(documents_fts, 1.5, 4.0, 1.0)) /
|
|
350
|
+
(1.0 + ABS(bm25(documents_fts, 1.5, 4.0, 1.0))) *
|
|
351
|
+
CASE dd.lifecycle WHEN 'deprecated' THEN 0.85 WHEN 'superseded' THEN 0.65 ELSE 1.0 END AS score
|
|
352
|
+
FROM documents_fts
|
|
353
|
+
JOIN documents d ON d.id = documents_fts.rowid
|
|
354
|
+
JOIN documentation_passages p ON p.source = d.collection AND p.qmd_path = d.path
|
|
355
|
+
JOIN documentation_documents dd ON dd.source = p.source AND dd.stable_path = p.stable_path
|
|
356
|
+
WHERE ${clauses.join(" AND ")}
|
|
357
|
+
), ranked AS (
|
|
358
|
+
SELECT *, ROW_NUMBER() OVER (
|
|
359
|
+
PARTITION BY source, stable_path ORDER BY score DESC, ordinal ASC
|
|
360
|
+
) AS page_rank FROM scored
|
|
361
|
+
)
|
|
362
|
+
SELECT * FROM ranked WHERE page_rank = 1`)
|
|
363
|
+
.all(...parameters) as PassageRow[];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function diversifySources(
|
|
367
|
+
results: readonly PassageRow[],
|
|
368
|
+
preferredSource: DocumentationSource | undefined,
|
|
369
|
+
limit: number,
|
|
370
|
+
): PassageRow[] {
|
|
371
|
+
const selected: PassageRow[] = [];
|
|
372
|
+
const seen = new Set<string>();
|
|
373
|
+
const sourceOrder = preferredSource
|
|
374
|
+
? [preferredSource, ...DOCUMENTATION_SOURCES.filter(source => source !== preferredSource)]
|
|
375
|
+
: [...DOCUMENTATION_SOURCES].sort((left, right) => {
|
|
376
|
+
const leftScore = results.find(result => result.source === left)?.score ?? -1;
|
|
377
|
+
const rightScore = results.find(result => result.source === right)?.score ?? -1;
|
|
378
|
+
return rightScore - leftScore || left.localeCompare(right);
|
|
379
|
+
});
|
|
380
|
+
for (const source of sourceOrder) {
|
|
381
|
+
const result = results.find(candidate => candidate.source === source);
|
|
382
|
+
if (!result) continue;
|
|
383
|
+
selected.push(result);
|
|
384
|
+
seen.add(`${result.source}\0${result.stable_path}`);
|
|
385
|
+
}
|
|
386
|
+
for (const result of results) {
|
|
387
|
+
const key = `${result.source}\0${result.stable_path}`;
|
|
388
|
+
if (seen.has(key)) continue;
|
|
389
|
+
selected.push(result);
|
|
390
|
+
seen.add(key);
|
|
391
|
+
}
|
|
392
|
+
return selected.slice(0, limit);
|
|
393
|
+
}
|
|
394
|
+
|
|
267
395
|
const SUPPORT_QUERY = /\b(?:troubleshoot|support|knowledge[- ]base|error|failure|issue|K[0-9]{6,})\b/i;
|
|
268
396
|
const CONFIGURATION_QUERY = /\b(?:configure|configuration|set\s*up|procedure|instructions?|how\s+(?:do|can|to))\b/i;
|
|
269
397
|
const CONCEPTUAL_QUERY = /\b(?:what\s+(?:is|are)|overview|product|solution|capabilities|benefits)\b/i;
|
|
@@ -313,58 +441,67 @@ export function createEmbeddedDocumentationRepository(
|
|
|
313
441
|
prime: async () => {
|
|
314
442
|
await state();
|
|
315
443
|
},
|
|
316
|
-
search: async (query, source, limit): Promise<readonly DocumentationSearchResult[]> => {
|
|
444
|
+
search: async (query, source, limit, filters = {}): Promise<readonly DocumentationSearchResult[]> => {
|
|
317
445
|
const current = await state();
|
|
318
|
-
const lexicalQuery = lexicalDocumentationQuery(query);
|
|
319
446
|
const preferredSource = source ? undefined : preferredDocumentationSource(query);
|
|
320
|
-
const results = source
|
|
321
|
-
? await current.store.searchLex(lexicalQuery, { limit, collection: source })
|
|
322
|
-
: (
|
|
323
|
-
await Promise.all(
|
|
324
|
-
DOCUMENTATION_SOURCES.map(collection =>
|
|
325
|
-
current.store.searchLex(lexicalQuery, { limit, collection }),
|
|
326
|
-
),
|
|
327
|
-
)
|
|
328
|
-
).flat();
|
|
447
|
+
const results = searchRows(current.database, query, source, filters);
|
|
329
448
|
results.sort((left, right) => {
|
|
330
|
-
const sourceOrder =
|
|
331
|
-
Number(right.collectionName === preferredSource) - Number(left.collectionName === preferredSource);
|
|
449
|
+
const sourceOrder = Number(right.source === preferredSource) - Number(left.source === preferredSource);
|
|
332
450
|
return (
|
|
333
451
|
sourceOrder ||
|
|
334
452
|
right.score - left.score ||
|
|
335
|
-
left.
|
|
336
|
-
left.
|
|
453
|
+
left.source.localeCompare(right.source) ||
|
|
454
|
+
left.stable_path.localeCompare(right.stable_path)
|
|
337
455
|
);
|
|
338
456
|
});
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
457
|
+
const selected = source ? results.slice(0, limit) : diversifySources(results, preferredSource, limit);
|
|
458
|
+
return selected.map(row => ({
|
|
459
|
+
title: row.title,
|
|
460
|
+
source: row.source,
|
|
461
|
+
originalUrl: row.original_url,
|
|
462
|
+
stablePath: row.stable_path,
|
|
463
|
+
snippet: boundedSnippet(row.passage_markdown, query),
|
|
464
|
+
score: Number(row.score.toFixed(12)),
|
|
465
|
+
anchor: row.anchor,
|
|
466
|
+
heading: row.heading,
|
|
467
|
+
canonicalUrl: row.canonical_url,
|
|
468
|
+
lastUpdated: row.last_updated,
|
|
469
|
+
product: row.product,
|
|
470
|
+
contentType: row.content_type,
|
|
471
|
+
taskType: row.task_type,
|
|
472
|
+
language: row.language,
|
|
473
|
+
lifecycle: row.lifecycle,
|
|
474
|
+
replacementUrl: row.replacement_url,
|
|
475
|
+
aliases: JSON.parse(row.aliases_json),
|
|
476
|
+
relatedDocuments: JSON.parse(row.related_documents_json),
|
|
477
|
+
}));
|
|
359
478
|
},
|
|
360
|
-
readDocument: async (source, stablePath): Promise<DocumentationDocument | null> => {
|
|
479
|
+
readDocument: async (source, stablePath, anchor): Promise<DocumentationDocument | null> => {
|
|
361
480
|
const current = await state();
|
|
362
481
|
const row = current.database
|
|
363
482
|
.query(
|
|
364
|
-
"SELECT title, original_url, markdown FROM documentation_documents WHERE source = ? AND stable_path = ?",
|
|
483
|
+
"SELECT title, original_url, markdown, lifecycle, replacement_url FROM documentation_documents WHERE source = ? AND stable_path = ?",
|
|
365
484
|
)
|
|
366
|
-
.get(source, stablePath) as Pick<
|
|
367
|
-
|
|
485
|
+
.get(source, stablePath) as Pick<
|
|
486
|
+
DocumentRow,
|
|
487
|
+
"title" | "original_url" | "markdown" | "lifecycle" | "replacement_url"
|
|
488
|
+
> | null;
|
|
489
|
+
if (!row) return null;
|
|
490
|
+
let markdown = row.markdown;
|
|
491
|
+
if (anchor) {
|
|
492
|
+
const passage = current.database
|
|
493
|
+
.query("SELECT markdown FROM documentation_passages WHERE source = ? AND stable_path = ? AND anchor = ?")
|
|
494
|
+
.get(source, stablePath, anchor) as { markdown: string } | null;
|
|
495
|
+
if (!passage) return null;
|
|
496
|
+
markdown = passage.markdown;
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
markdown,
|
|
500
|
+
title: row.title,
|
|
501
|
+
originalUrl: row.original_url,
|
|
502
|
+
lifecycle: row.lifecycle,
|
|
503
|
+
replacementUrl: row.replacement_url,
|
|
504
|
+
};
|
|
368
505
|
},
|
|
369
506
|
readAsset: async (source, stablePath, filename): Promise<DocumentationAsset | null> => {
|
|
370
507
|
const current = await state();
|