@bootstrapware/answers 0.2.11 → 0.2.13
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/AGENTS.md +3 -3
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/server.d.ts +32 -2
- package/dist/server.js +203 -27
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -227,18 +227,18 @@ Questions are limited to 1,000 Unicode code points. A longer draft stays in the
|
|
|
227
227
|
Hosted indexes the main body of each page, split by headings; navigation, headers, footers, and cookie banners are dropped. The model writes a short answer from the best-matching passages and must cite them. It does not use outside knowledge. `list_answers_capabilities` returns `sourceAdvice` with the same rules.
|
|
228
228
|
|
|
229
229
|
1. Make sure the fact is stated in a page body, not only implied by a homepage card. A pricing page, a support page, and one page per product work well.
|
|
230
|
-
2. Keep each figure next to what it prices (`Hosted is $19.99/month`). An answer whose price or number is not in a cited passage is rejected unless it follows from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent).
|
|
230
|
+
2. Keep each figure next to what it prices (`Hosted is $19.99/month`). An answer whose price or number is not in a cited passage is rejected unless it follows from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent). The same number written with a comma, dot, space, or apostrophe as the thousands separator is that figure (`2,500`, `2.500`, and `2 500`). A comma with one or two decimal digits is that decimal (`19,99` and `$19.99`).
|
|
231
231
|
3. After `add_answers_url_source`, wait for the index job, then use the dashboard retrieval check to see which passages a question finds. Ask through the widget with a test key to see the written answer.
|
|
232
232
|
4. For facts you want stated exactly (prices, refund policy, support contact), add a custom Q&A in the dashboard. Custom Q&A is always considered, and a matching entry is answered first. Hosted entries are indexed on save, usually within about a minute; the dashboard knowledge list shows when each is indexed. With BYO, an entry changed through `upsertQa`, `removeQa`, or `setQa` applies from the next question.
|
|
233
233
|
5. When the pages are silent on a yes/no question (certification, compliance, encryption), the answer says the site does not state it and gives the support contact if a source has one. Add the support contact as a custom Q&A so that works. Unanswered with the fallback is the correct outcome when nothing covers the question. Add a URL or a custom Q&A. Do not send the question through MCP.
|
|
234
234
|
|
|
235
235
|
## Knowledge, grounding, and provider
|
|
236
236
|
|
|
237
|
-
Hosted knowledge kinds are website root, sitemap, single public URL, PDF, DOCX, TXT, Markdown, and custom Q&A (a question and its answer). MCP can add a public URL and reindex it. Files, custom Q&A, conversations, and unanswered question text stay on the dashboard.
|
|
237
|
+
Hosted knowledge kinds are website root, sitemap, single public URL, PDF, DOCX, TXT, Markdown, and custom Q&A (a question and its answer). A website or sitemap crawl also fetches `/llms.txt` and `/llms-full.txt` on that host when `robots.txt` allows them. Text and Markdown are indexed. A missing file is ignored and is not an index error. BYO `refresh()` does the same for each origin in `urls` or the sitemap. These agent indexes stay below product pages when both match a question. MCP can add a public URL and reindex it. Files, custom Q&A, conversations, and unanswered question text stay on the dashboard.
|
|
238
238
|
|
|
239
239
|
Every Hosted source is visitor-readable. Do not upload private runbooks or secrets. Crawl is public `http` and `https` only. Private hosts are rejected. `robots.txt` is honored. A failed crawl leaves the last good index in place. A page that is only navigation chrome is rejected and does not become a citation.
|
|
240
240
|
|
|
241
|
-
Retrieval combines keyword and embedding search over the active index. When the question names a product whose pages live under a URL path, those pages are preferred. Custom Q&A is always considered, and a matching entry is answered first. The model answers in 1 to 4 sentences in the visitor's language, citing passages it was given; citations are ids from the retrieved index, and the model does not invent URLs. A draft that declines, cites an unretrieved passage, states a figure that is not in a cited passage and does not follow from cited figures by the rules above, or writes code not quoted from a passage is retried once and then returns `unanswered` with the configured fallback. A derived total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes list and discounted line items that sum to the total. Off-topic questions and prompt-injection attempts are declined before retrieval. How-to questions get the documented steps and a citation to the docs page; the model does not write new code. There is no model-memory fallback. Earlier turns are used only for clear follow-ups ("what about Chat?"); a standalone question is answered fresh.
|
|
241
|
+
Retrieval combines keyword and embedding search over the active index. When the question names a product whose pages live under a URL path, those pages are preferred, and passages on that path that mention the distinctive terms in the question are reserved for the model. Custom Q&A is always considered, and a matching entry is answered first. The model answers in 1 to 4 sentences in the visitor's language, citing passages it was given; citations are ids from the retrieved index, and the model does not invent URLs. A draft that declines, cites an unretrieved passage, states a figure that is not in a cited passage and does not follow from cited figures by the rules above, or writes code not quoted from a passage is retried once and then returns `unanswered` with the configured fallback. A derived total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes list and discounted line items that sum to the total. Off-topic questions and prompt-injection attempts are declined before retrieval. How-to questions get the documented steps and a citation to the docs page; the model does not write new code. There is no model-memory fallback. Earlier turns are used only for clear follow-ups ("what about Chat?"); a standalone question is answered fresh.
|
|
242
242
|
|
|
243
243
|
Unanswered observations store normalized question text even when conversation history is off, so the dashboard can group them. MCP does not return that text.
|
|
244
244
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.13
|
|
4
|
+
|
|
5
|
+
- Grounding treats a thousands group as one figure. `2,500`, `2.500`, `2 500` (including a narrow no-break space), and `2'500` are the same number, and `19,99` is the same decimal as `19.99`. A one-digit group no longer matches a different grouped number that happens to end in the same digits (`1,500` is not `2,500`).
|
|
6
|
+
- Citation markers in an answer are removed when the comma inside the brackets is an Arabic comma, an ideographic comma, or a fullwidth comma (`[2، 5]`), including Arabic-Indic digits.
|
|
7
|
+
- A website or sitemap crawl, and BYO `refresh()` for each origin already being fetched, also indexes `/llms.txt` and `/llms-full.txt` when the host publishes them as text or Markdown. A missing file is ignored and is not listed as a skipped page.
|
|
8
|
+
|
|
9
|
+
## 0.2.12
|
|
10
|
+
|
|
11
|
+
- Hosted and BYO retrieval now reserve named-path passages that mention the distinctive terms in the question (`stream` on an Answers page), so a generic product-page crowd cannot hide a documented yes/no.
|
|
12
|
+
- The custom Q&A lane no longer treats “does X support…” as a match for a contact-support entry, and embedding-only Q&A needs a closer match than before.
|
|
13
|
+
|
|
3
14
|
## 0.2.11
|
|
4
15
|
|
|
5
16
|
- Retrieval prefers pages under a product path the question named, so another product's similar vocabulary cannot crowd out a documented yes/no (Chat SSE vs an Answers capability page).
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ export const POST = (request: Request) => answers.handler(request);
|
|
|
59
59
|
|
|
60
60
|
The index is built on the first question and kept in memory. Call `answers.refresh()` after your content changes (it returns `{ pages, chunks, skipped }`; `skipped` lists every page left out and why, including a URL that redirects to a different page, and `onRefresh` receives the same report after the automatic first build), or save `answers.index.toJSON()` and pass it back as `snapshot` to skip re-embedding on restart. Pass `retrieve` to use your own vector store instead.
|
|
61
61
|
|
|
62
|
-
Answers come only from the indexed passages. A draft that cites something it did not retrieve, or states a price or number that is not in a cited passage and does not follow from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent), is rejected and the visitor gets the fallback. A stack total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes lines that sum to that total. Custom Q&A is always considered, and a matching entry is answered first. When the sources are silent on a yes/no question, the answer says the site does not state it (with the support contact if a source has one). How-to questions cite the docs page; no new code is written.
|
|
62
|
+
Answers come only from the indexed passages. `refresh()` also fetches `/llms.txt` and `/llms-full.txt` for each origin in `urls` or the sitemap when the host publishes them as text or Markdown, and ignores a missing file. A draft that cites something it did not retrieve, or states a price or number that is not in a cited passage and does not follow from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent), is rejected and the visitor gets the fallback. The same number with a comma, dot, space, or apostrophe as the thousands separator counts as the figure in the source, and a comma decimal such as `19,99` counts as `$19.99`. A stack total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes lines that sum to that total. Custom Q&A is always considered, and a matching entry is answered first. When the sources are silent on a yes/no question, the answer says the site does not state it (with the support contact if a source has one). How-to questions cite the docs page; no new code is written.
|
|
63
63
|
|
|
64
64
|
Custom Q&A can change at runtime without a refresh: call `await answers.upsertQa({ id, question, answer })`, `answers.removeQa(id)`, or `answers.setQa([...])` from your save handler. Only the changed entry is embedded and it answers on the next question. Relevant custom Q&A are always offered to the model, even when many page passages outrank them, and that includes setups that pass their own `retrieve`.
|
|
65
65
|
|
package/dist/server.d.ts
CHANGED
|
@@ -330,6 +330,9 @@ type RefreshReport = {
|
|
|
330
330
|
};
|
|
331
331
|
/** Same page: host (ignoring www and scheme) and path, ignoring a trailing slash, query, and fragment. */
|
|
332
332
|
declare function samePage(left: string, right: string): boolean;
|
|
333
|
+
/** `/llms.txt` and `/llms-full.txt` on an origin already being indexed. */
|
|
334
|
+
declare function agentIndexUrls(origin: string): string[];
|
|
335
|
+
declare function isAgentIndexUrl(raw: string): boolean;
|
|
333
336
|
/**
|
|
334
337
|
* BYO Answers backend. Runs on your server with your OpenAI key; questions and knowledge stay on
|
|
335
338
|
* your infrastructure. Pair with createByoAdapter in the browser.
|
|
@@ -395,6 +398,33 @@ declare function tokenize(text: string): string[];
|
|
|
395
398
|
declare function firstPathSegment(url?: string | null): string | null;
|
|
396
399
|
/** Path segments the visitor named that also appear as product roots in the candidate URLs. */
|
|
397
400
|
declare function namedPathQuery(query: string, urls: Array<string | null | undefined>): Set<string>;
|
|
401
|
+
/**
|
|
402
|
+
* Product or subject the visitor named ("Does Answers support…", capitalized "Answers").
|
|
403
|
+
* Used so the product name is not treated as the distinctive evidence term.
|
|
404
|
+
*/
|
|
405
|
+
declare function subjectTokens(query: string): Set<string>;
|
|
406
|
+
/** Auxiliaries in "does X support/offer Y?" — they match contact-support Q&A, not the capability. */
|
|
407
|
+
declare function capabilityVerbs(query: string): Set<string>;
|
|
408
|
+
/** Query tokens that should pull the passage that actually states the fact (not the product name). */
|
|
409
|
+
declare function distinctiveTokens(query: string, named?: ReadonlySet<string>): string[];
|
|
410
|
+
declare function contentTokenOverlap(text: string, tokens: readonly string[]): number;
|
|
411
|
+
type EvidenceRow = {
|
|
412
|
+
id: string;
|
|
413
|
+
url?: string | null;
|
|
414
|
+
canonical_url?: string | null;
|
|
415
|
+
title?: string | null;
|
|
416
|
+
text?: string;
|
|
417
|
+
excerpt?: string;
|
|
418
|
+
kind?: string;
|
|
419
|
+
};
|
|
420
|
+
/** How many named-path passages that mention the distinctive terms are reserved for the model. */
|
|
421
|
+
declare const EVIDENCE_LANE = 3;
|
|
422
|
+
/**
|
|
423
|
+
* Pages under a product path the question named that also mention the distinctive terms
|
|
424
|
+
* (`stream` on an Answers grounding page). Generic product pages and another product's
|
|
425
|
+
* similar vocabulary do not qualify.
|
|
426
|
+
*/
|
|
427
|
+
declare function selectNamedEvidence<T extends EvidenceRow>(query: string, rows: readonly T[], lane?: number): T[];
|
|
398
428
|
/**
|
|
399
429
|
* Sort bucket: 0 custom Q&A, 1 pages under a product path the question named, 2 the rest.
|
|
400
430
|
* Empty `named` keeps page order (everyone 1 except Q&A).
|
|
@@ -403,7 +433,7 @@ declare function namedPathBucket(url: string | null | undefined, named: Readonly
|
|
|
403
433
|
/** How many custom Q&A entries are always offered to the model when they look relevant. */
|
|
404
434
|
declare const QA_LANE = 2;
|
|
405
435
|
/** Embedding similarity at which a custom Q&A counts as relevant even with no shared words. */
|
|
406
|
-
declare const QA_LANE_MIN_SIMILARITY = 0.
|
|
436
|
+
declare const QA_LANE_MIN_SIMILARITY = 0.55;
|
|
407
437
|
type QaLaneCandidate = {
|
|
408
438
|
id: string;
|
|
409
439
|
title: string;
|
|
@@ -421,4 +451,4 @@ declare function withQaLane<T extends {
|
|
|
421
451
|
id: string;
|
|
422
452
|
}>(ranked: readonly T[], lane: readonly T[], limit: number): T[];
|
|
423
453
|
|
|
424
|
-
export { type AnswerCitation, type AnswerDeps, type AnswerInput, type AnswerOutput, type AnswerReason, type AnswersBackend, type AnswersBackendOptions, CHUNK_MAX_CHARS, CHUNK_TARGET_CHARS, type ChatMessage, type Chunk, DEFAULT_ANSWER_MODEL, DEFAULT_EMBEDDING_MODEL, type DocumentInput, EMBEDDING_DIMENSIONS, type ExtractedPage, FALLBACK_ANSWER_MODEL, type IndexedEntry, type LlmClient, LlmError, MemoryIndex, type MemoryIndexSnapshot, type OpenAiOptions, type PageSection, type Passage, QA_LANE, QA_LANE_MIN_SIMILARITY, type QaInput, type QaLaneCandidate, type ReasoningEffort, type RefreshReport, type RetrieveQuery, type Retriever, type SkippedPage, type Turn, answerMessages, answerQuestion, authFailure, chunkPage, citationsFor, cosine, createAnswersBackend, createOpenAiClient, decodeCfEmail, decodeEntities, decodeProtectedEmails, extractPage, extractText, figuresIn, firstPathSegment, inferSources, isReasoningModel, matchingQa, namedPathBucket, namedPathQuery, prepareMessages, reciprocalRankFusion, samePage, selectQaLane, sourceBlock, stem, tokenize, verifyAnswer, withQaLane };
|
|
454
|
+
export { type AnswerCitation, type AnswerDeps, type AnswerInput, type AnswerOutput, type AnswerReason, type AnswersBackend, type AnswersBackendOptions, CHUNK_MAX_CHARS, CHUNK_TARGET_CHARS, type ChatMessage, type Chunk, DEFAULT_ANSWER_MODEL, DEFAULT_EMBEDDING_MODEL, type DocumentInput, EMBEDDING_DIMENSIONS, EVIDENCE_LANE, type EvidenceRow, type ExtractedPage, FALLBACK_ANSWER_MODEL, type IndexedEntry, type LlmClient, LlmError, MemoryIndex, type MemoryIndexSnapshot, type OpenAiOptions, type PageSection, type Passage, QA_LANE, QA_LANE_MIN_SIMILARITY, type QaInput, type QaLaneCandidate, type ReasoningEffort, type RefreshReport, type RetrieveQuery, type Retriever, type SkippedPage, type Turn, agentIndexUrls, answerMessages, answerQuestion, authFailure, capabilityVerbs, chunkPage, citationsFor, contentTokenOverlap, cosine, createAnswersBackend, createOpenAiClient, decodeCfEmail, decodeEntities, decodeProtectedEmails, distinctiveTokens, extractPage, extractText, figuresIn, firstPathSegment, inferSources, isAgentIndexUrl, isReasoningModel, matchingQa, namedPathBucket, namedPathQuery, prepareMessages, reciprocalRankFusion, samePage, selectNamedEvidence, selectQaLane, sourceBlock, stem, subjectTokens, tokenize, verifyAnswer, withQaLane };
|
package/dist/server.js
CHANGED
|
@@ -504,11 +504,59 @@ function namedPathQuery(query, urls) {
|
|
|
504
504
|
}
|
|
505
505
|
const named = /* @__PURE__ */ new Set();
|
|
506
506
|
if (!present.size) return named;
|
|
507
|
-
for (const token of new Set(tokenize(query))) {
|
|
507
|
+
for (const token of /* @__PURE__ */ new Set([...tokenize(query), ...subjectTokens(query)])) {
|
|
508
508
|
if (present.has(token)) named.add(token);
|
|
509
509
|
}
|
|
510
510
|
return named;
|
|
511
511
|
}
|
|
512
|
+
function subjectTokens(query) {
|
|
513
|
+
const out = /* @__PURE__ */ new Set();
|
|
514
|
+
const subject = query.match(/\b(?:does|do|can|is|are)\s+([A-Za-z][\w-]*)\b/i);
|
|
515
|
+
if (subject) {
|
|
516
|
+
const [token] = tokenize(subject[1]);
|
|
517
|
+
if (token) out.add(token);
|
|
518
|
+
}
|
|
519
|
+
for (const word of query.match(/\b[A-Z][A-Za-z]{2,}\b/g) ?? []) {
|
|
520
|
+
const [token] = tokenize(word);
|
|
521
|
+
if (token) out.add(token);
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
function capabilityVerbs(query) {
|
|
526
|
+
const out = /* @__PURE__ */ new Set();
|
|
527
|
+
if (/\b(?:does|do|can)\s+[A-Za-z][\w-]*\s+(?:support|offer|have|include|allow)\b/i.test(query)) {
|
|
528
|
+
for (const verb of ["support", "offer", "have", "include", "allow"]) out.add(stem(verb));
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
function distinctiveTokens(query, named = /* @__PURE__ */ new Set()) {
|
|
533
|
+
const skip = /* @__PURE__ */ new Set([...named, ...subjectTokens(query), ...capabilityVerbs(query)]);
|
|
534
|
+
return [...new Set(tokenize(query))].filter((token) => token.length >= 4 && !skip.has(token));
|
|
535
|
+
}
|
|
536
|
+
function contentTokenOverlap(text, tokens) {
|
|
537
|
+
if (!tokens.length) return 0;
|
|
538
|
+
const own = new Set(tokenize(text));
|
|
539
|
+
let n = 0;
|
|
540
|
+
for (const token of tokens) if (own.has(token)) n += 1;
|
|
541
|
+
return n;
|
|
542
|
+
}
|
|
543
|
+
var EVIDENCE_LANE = 3;
|
|
544
|
+
function selectNamedEvidence(query, rows, lane = EVIDENCE_LANE) {
|
|
545
|
+
if (!rows.length || lane <= 0) return [];
|
|
546
|
+
const pages = rows.filter((row) => row.kind !== "qa" && row.kind !== "manual");
|
|
547
|
+
const named = namedPathQuery(
|
|
548
|
+
query,
|
|
549
|
+
pages.map((row) => row.url ?? row.canonical_url)
|
|
550
|
+
);
|
|
551
|
+
const terms = distinctiveTokens(query, named);
|
|
552
|
+
if (!terms.length) return [];
|
|
553
|
+
return pages.map((row) => {
|
|
554
|
+
const url = row.url ?? row.canonical_url;
|
|
555
|
+
const overlap = contentTokenOverlap(`${row.title ?? ""} ${row.excerpt ?? row.text ?? ""}`, terms);
|
|
556
|
+
const bucket = namedPathBucket(url, named);
|
|
557
|
+
return { row, overlap, bucket };
|
|
558
|
+
}).filter((item) => item.overlap >= 1 && (!named.size || item.bucket === 1)).sort((a, b) => b.overlap - a.overlap).slice(0, lane).map((item) => item.row);
|
|
559
|
+
}
|
|
512
560
|
function namedPathBucket(url, named, qa = false) {
|
|
513
561
|
if (qa) return 0;
|
|
514
562
|
if (!named.size) return 1;
|
|
@@ -516,7 +564,7 @@ function namedPathBucket(url, named, qa = false) {
|
|
|
516
564
|
return seg && named.has(seg) ? 1 : 2;
|
|
517
565
|
}
|
|
518
566
|
var QA_LANE = 2;
|
|
519
|
-
var QA_LANE_MIN_SIMILARITY = 0.
|
|
567
|
+
var QA_LANE_MIN_SIMILARITY = 0.55;
|
|
520
568
|
function selectQaLane(questions, candidates, lane = QA_LANE) {
|
|
521
569
|
if (!candidates.length || lane <= 0) return [];
|
|
522
570
|
const words = (text) => new Set(tokenize(text));
|
|
@@ -525,12 +573,18 @@ function selectQaLane(questions, candidates, lane = QA_LANE) {
|
|
|
525
573
|
for (const t of titles) for (const w of t) df.set(w, (df.get(w) ?? 0) + 1);
|
|
526
574
|
const common = Math.max(2, Math.ceil(candidates.length / 2));
|
|
527
575
|
const asked = new Set(questions.flatMap((q) => [...words(q)]).filter((w) => (df.get(w) ?? 0) < common || candidates.length < 3));
|
|
576
|
+
const weak = new Set(questions.flatMap((q) => [...capabilityVerbs(q), ...subjectTokens(q)]));
|
|
528
577
|
return candidates.map((c, i) => {
|
|
529
578
|
let shared = 0;
|
|
530
|
-
|
|
579
|
+
let strong = 0;
|
|
580
|
+
for (const w of asked) {
|
|
581
|
+
if (!titles[i].has(w)) continue;
|
|
582
|
+
shared += 1;
|
|
583
|
+
if (!weak.has(w)) strong += 1;
|
|
584
|
+
}
|
|
531
585
|
const sim = typeof c.similarity === "number" && Number.isFinite(c.similarity) ? c.similarity : 0;
|
|
532
|
-
return { id: c.id, shared, sim, score: shared + sim * 2 };
|
|
533
|
-
}).filter((x) => x.
|
|
586
|
+
return { id: c.id, shared, strong, sim, score: strong + shared * 0.25 + sim * 2 };
|
|
587
|
+
}).filter((x) => x.strong >= 1 || x.sim >= QA_LANE_MIN_SIMILARITY).sort((a, b) => b.score - a.score).slice(0, lane).map((x) => x.id);
|
|
534
588
|
}
|
|
535
589
|
function withQaLane(ranked, lane, limit) {
|
|
536
590
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -679,14 +733,21 @@ var MemoryIndex = class _MemoryIndex {
|
|
|
679
733
|
const lexical = this.lexical(`${query.text} ${query.original}`, depth).map(String);
|
|
680
734
|
const vector = query.embedding ? this.vector(query.embedding, depth).map(String) : [];
|
|
681
735
|
const fused = reciprocalRankFusion([lexical, vector], 60, [1, 1.3]);
|
|
682
|
-
const
|
|
736
|
+
const asked = `${query.text} ${query.original}`;
|
|
737
|
+
const named = namedPathQuery(asked, this.entries.map((e) => e.url));
|
|
738
|
+
const terms = distinctiveTokens(asked, named);
|
|
683
739
|
const boosted = fused.map(({ id, score }) => {
|
|
684
740
|
const entry = this.entries[Number(id)];
|
|
685
741
|
const weight = entry.kind === "qa" ? 1.25 : 1;
|
|
686
|
-
|
|
687
|
-
|
|
742
|
+
const overlap = entry.kind === "qa" ? 0 : contentTokenOverlap(`${entry.title} ${entry.content}`, terms);
|
|
743
|
+
return { id: Number(id), score: score * weight, overlap, bucket: namedPathBucket(entry.url, named, entry.kind === "qa") };
|
|
744
|
+
}).sort((a, b) => a.bucket - b.bucket || b.overlap - a.overlap || b.score - a.score);
|
|
688
745
|
const ranked = boosted.slice(0, query.limit).map(({ id }) => this.passage(id));
|
|
689
|
-
|
|
746
|
+
const evidence = selectNamedEvidence(
|
|
747
|
+
asked,
|
|
748
|
+
this.entries.map((e, i) => ({ id: String(i), url: e.url, title: e.title, text: e.content, kind: e.kind }))
|
|
749
|
+
).map((row) => this.passage(Number(row.id)));
|
|
750
|
+
return withQaLane(withQaLane(ranked, evidence, query.limit), this.qaLane(query), query.limit);
|
|
690
751
|
}
|
|
691
752
|
};
|
|
692
753
|
function qaEntry(item, fallbackId) {
|
|
@@ -916,10 +977,96 @@ ${sourceBlock(passages)}`,
|
|
|
916
977
|
{ role: "user", content: user }
|
|
917
978
|
];
|
|
918
979
|
}
|
|
919
|
-
|
|
920
|
-
|
|
980
|
+
function isGroupSpace(ch) {
|
|
981
|
+
return ch === "\xA0" || ch === "\u202F" || ch === "\u2007" || ch === "\u2009";
|
|
982
|
+
}
|
|
983
|
+
function stripInt(digits) {
|
|
984
|
+
return digits.replace(/^0+(?=\d)/, "") || "0";
|
|
985
|
+
}
|
|
986
|
+
function decimalKey(intDigits, frac) {
|
|
987
|
+
return { key: `${stripInt(intDigits)}.${frac}`.replace(/\.0+$/, ""), twoPlace: frac.length === 2 };
|
|
988
|
+
}
|
|
989
|
+
function canonicalBody(body) {
|
|
990
|
+
const noSpace = [...body].map((ch) => isGroupSpace(ch) ? " " : ch).join("").replace(/[’']/g, "").replace(/ /g, "");
|
|
991
|
+
if (!/^\d+(?:[.,]\d+)*$/.test(noSpace)) return null;
|
|
992
|
+
if (!noSpace.includes(".") && !noSpace.includes(",")) return { key: stripInt(noSpace), twoPlace: false };
|
|
993
|
+
if (noSpace.includes(".") && noSpace.includes(",")) {
|
|
994
|
+
const dec = noSpace.lastIndexOf(".") > noSpace.lastIndexOf(",") ? "." : ",";
|
|
995
|
+
const at = noSpace.lastIndexOf(dec);
|
|
996
|
+
const intDigits2 = noSpace.slice(0, at).replace(/[.,]/g, "");
|
|
997
|
+
const frac2 = noSpace.slice(at + 1);
|
|
998
|
+
if (!/^\d+$/.test(intDigits2) || !/^\d+$/.test(frac2)) return null;
|
|
999
|
+
return decimalKey(intDigits2, frac2);
|
|
1000
|
+
}
|
|
1001
|
+
const parts = noSpace.split(noSpace.includes(".") ? "." : ",");
|
|
1002
|
+
if (parts.every((part, index) => /^\d+$/.test(part) && (index === 0 || part.length === 3))) {
|
|
1003
|
+
return { key: stripInt(parts.join("")), twoPlace: false };
|
|
1004
|
+
}
|
|
1005
|
+
const frac = parts[parts.length - 1] ?? "";
|
|
1006
|
+
const intDigits = parts.slice(0, -1).join("");
|
|
1007
|
+
if (!/^\d+$/.test(intDigits) || !/^\d+$/.test(frac)) return null;
|
|
1008
|
+
return decimalKey(intDigits, frac);
|
|
1009
|
+
}
|
|
1010
|
+
function parseFigure(raw) {
|
|
1011
|
+
let body = raw.trim();
|
|
1012
|
+
let kind = "other";
|
|
1013
|
+
if (/^[$€£]/.test(body)) {
|
|
1014
|
+
kind = "money";
|
|
1015
|
+
body = body.slice(1).trimStart();
|
|
1016
|
+
}
|
|
1017
|
+
if (body.endsWith("%")) {
|
|
1018
|
+
kind = "percent";
|
|
1019
|
+
body = body.slice(0, -1).trimEnd();
|
|
1020
|
+
}
|
|
1021
|
+
const parsed = canonicalBody(body);
|
|
1022
|
+
if (!parsed) return null;
|
|
1023
|
+
if (kind === "other" && parsed.twoPlace) kind = "money";
|
|
1024
|
+
if (kind === "other" && /^\d$/.test(parsed.key)) return null;
|
|
1025
|
+
return { key: parsed.key, kind };
|
|
1026
|
+
}
|
|
1027
|
+
function scanFigures(text) {
|
|
1028
|
+
const out = [];
|
|
1029
|
+
const groupSep = /[ \u00A0\u202F\u2007\u2009'’.,]/;
|
|
1030
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1031
|
+
const ch = text[i] ?? "";
|
|
1032
|
+
const prev = text[i - 1] ?? "";
|
|
1033
|
+
if (/\d/.test(ch) && /[\dA-Za-z]/.test(prev)) continue;
|
|
1034
|
+
if (!/\d/.test(ch) && !/[$€£]/.test(ch)) continue;
|
|
1035
|
+
let cursor = i;
|
|
1036
|
+
if (/[$€£]/.test(text[cursor] ?? "")) {
|
|
1037
|
+
cursor += 1;
|
|
1038
|
+
if (text[cursor] === " ") cursor += 1;
|
|
1039
|
+
}
|
|
1040
|
+
if (!/\d/.test(text[cursor] ?? "")) continue;
|
|
1041
|
+
while (cursor < text.length && /\d/.test(text[cursor] ?? "")) cursor += 1;
|
|
1042
|
+
while (cursor < text.length && groupSep.test(text[cursor] ?? "")) {
|
|
1043
|
+
const sep = text[cursor] ?? "";
|
|
1044
|
+
let next = cursor + 1;
|
|
1045
|
+
let digits = 0;
|
|
1046
|
+
while (next < text.length && /\d/.test(text[next] ?? "")) {
|
|
1047
|
+
digits += 1;
|
|
1048
|
+
next += 1;
|
|
1049
|
+
}
|
|
1050
|
+
if (!digits) break;
|
|
1051
|
+
const spaceLike = sep === " " || isGroupSpace(sep) || sep === "'" || sep === "\u2019";
|
|
1052
|
+
if (spaceLike && digits !== 3) break;
|
|
1053
|
+
cursor = next;
|
|
1054
|
+
}
|
|
1055
|
+
let end = cursor;
|
|
1056
|
+
if (text[end] === "%") end += 1;
|
|
1057
|
+
else if (text[end] === " " && text[end + 1] === "%") end += 2;
|
|
1058
|
+
const parsed = parseFigure(text.slice(i, end));
|
|
1059
|
+
if (parsed) out.push(parsed);
|
|
1060
|
+
if (end > i) i = end - 1;
|
|
1061
|
+
}
|
|
1062
|
+
return out;
|
|
1063
|
+
}
|
|
921
1064
|
function figuresIn(text) {
|
|
922
|
-
|
|
1065
|
+
const out = [];
|
|
1066
|
+
for (const figure of scanFigures(text)) {
|
|
1067
|
+
if (!out.includes(figure.key)) out.push(figure.key);
|
|
1068
|
+
}
|
|
1069
|
+
return out;
|
|
923
1070
|
}
|
|
924
1071
|
function figureSupported(figure, haystack) {
|
|
925
1072
|
const figures = new Set(figuresIn(haystack));
|
|
@@ -930,19 +1077,15 @@ function roundCents(n) {
|
|
|
930
1077
|
return Math.round(n * 100) / 100;
|
|
931
1078
|
}
|
|
932
1079
|
function figureKey(n) {
|
|
933
|
-
return
|
|
1080
|
+
return String(roundCents(n)).replace(/\.0+$/, "");
|
|
934
1081
|
}
|
|
935
1082
|
function sourceFigures(text) {
|
|
936
1083
|
const out = [];
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if (m[1]) out.push({ num: Number(m[1].replace(/,/g, "")), kind: "money" });
|
|
941
|
-
else if (m[2]) out.push({ num: Number(m[2].replace(/,/g, "")), kind: "percent" });
|
|
942
|
-
else if (m[3]) out.push({ num: Number(m[3]), kind: /\.\d{2}$/.test(m[3]) ? "money" : "other" });
|
|
943
|
-
else if (m[4]) out.push({ num: Number(m[4].replace(/,/g, "")), kind: "other" });
|
|
1084
|
+
for (const figure of scanFigures(text)) {
|
|
1085
|
+
const num = Number(figure.key);
|
|
1086
|
+
if (Number.isFinite(num)) out.push({ num, kind: figure.kind });
|
|
944
1087
|
}
|
|
945
|
-
return out
|
|
1088
|
+
return out;
|
|
946
1089
|
}
|
|
947
1090
|
function uniqueNums(values) {
|
|
948
1091
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -1224,8 +1367,9 @@ function citationsFor(cited, passages, answer, max) {
|
|
|
1224
1367
|
}
|
|
1225
1368
|
return out;
|
|
1226
1369
|
}
|
|
1370
|
+
var CITATION_MARK = /\s*\[[\d\u0660-\u0669\u06F0-\u06F9]+(?:\s*[,،、,]\s*[\d\u0660-\u0669\u06F0-\u06F9]+)*\]/g;
|
|
1227
1371
|
function tidy(answer) {
|
|
1228
|
-
return answer.replace(
|
|
1372
|
+
return answer.replace(CITATION_MARK, "").replace(/^#+\s+/gm, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
1229
1373
|
}
|
|
1230
1374
|
function authFailure(error) {
|
|
1231
1375
|
return error instanceof LlmError && (error.status === 401 || error.status === 403);
|
|
@@ -1344,6 +1488,18 @@ function samePage(left, right) {
|
|
|
1344
1488
|
}
|
|
1345
1489
|
var HISTORY_TURNS = 3;
|
|
1346
1490
|
var SESSIONS_MAX = 5e3;
|
|
1491
|
+
var AGENT_INDEX_PATHS = ["/llms.txt", "/llms-full.txt"];
|
|
1492
|
+
function agentIndexUrls(origin) {
|
|
1493
|
+
const base = origin.endsWith("/") ? origin : `${origin}/`;
|
|
1494
|
+
return AGENT_INDEX_PATHS.map((path) => new URL(path, base).href);
|
|
1495
|
+
}
|
|
1496
|
+
function isAgentIndexUrl(raw) {
|
|
1497
|
+
try {
|
|
1498
|
+
return /^\/llms(?:-full)?\.txt$/i.test(new URL(raw).pathname);
|
|
1499
|
+
} catch {
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1347
1503
|
function randomHex(bytes) {
|
|
1348
1504
|
const values = new Uint8Array(bytes);
|
|
1349
1505
|
globalThis.crypto.getRandomValues(values);
|
|
@@ -1395,20 +1551,32 @@ function createAnswersBackend(options) {
|
|
|
1395
1551
|
async function refresh() {
|
|
1396
1552
|
const urls = [...options.urls ?? []];
|
|
1397
1553
|
if (options.sitemap) urls.push(...await sitemapUrls(fetchImpl, options.sitemap, options.maxPages ?? 500));
|
|
1554
|
+
const listed = [...new Set(urls)].slice(0, options.maxPages ?? 500);
|
|
1555
|
+
const listedSet = new Set(listed);
|
|
1556
|
+
const optional = /* @__PURE__ */ new Set();
|
|
1557
|
+
for (const url of listed) {
|
|
1558
|
+
try {
|
|
1559
|
+
for (const extra of agentIndexUrls(new URL(url).origin)) {
|
|
1560
|
+
if (!listedSet.has(extra)) optional.add(extra);
|
|
1561
|
+
}
|
|
1562
|
+
} catch {
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1398
1565
|
const docs = [...options.documents ?? []];
|
|
1399
1566
|
const skipped = [];
|
|
1400
|
-
for (const url of [...
|
|
1401
|
-
const
|
|
1567
|
+
for (const url of [...listed, ...optional]) {
|
|
1568
|
+
const quiet = optional.has(url);
|
|
1569
|
+
const response = await fetchImpl(url, { headers: { accept: "text/html, text/markdown, text/plain" } }).catch(() => null);
|
|
1402
1570
|
if (!response) {
|
|
1403
|
-
skipped.push({ url, reason: "The page could not be fetched." });
|
|
1571
|
+
if (!quiet) skipped.push({ url, reason: "The page could not be fetched." });
|
|
1404
1572
|
continue;
|
|
1405
1573
|
}
|
|
1406
1574
|
if (!response.ok) {
|
|
1407
|
-
skipped.push({ url, reason: `HTTP ${response.status}.` });
|
|
1575
|
+
if (!quiet) skipped.push({ url, reason: `HTTP ${response.status}.` });
|
|
1408
1576
|
continue;
|
|
1409
1577
|
}
|
|
1410
1578
|
if (response.redirected && response.url && !samePage(response.url, url)) {
|
|
1411
|
-
skipped.push({ url, reason: `Redirects to ${response.url}. List that URL instead if it should be indexed.` });
|
|
1579
|
+
if (!quiet) skipped.push({ url, reason: `Redirects to ${response.url}. List that URL instead if it should be indexed.` });
|
|
1412
1580
|
continue;
|
|
1413
1581
|
}
|
|
1414
1582
|
const type = response.headers.get("content-type") ?? "";
|
|
@@ -1499,27 +1667,33 @@ export {
|
|
|
1499
1667
|
DEFAULT_ANSWER_MODEL,
|
|
1500
1668
|
DEFAULT_EMBEDDING_MODEL,
|
|
1501
1669
|
EMBEDDING_DIMENSIONS,
|
|
1670
|
+
EVIDENCE_LANE,
|
|
1502
1671
|
FALLBACK_ANSWER_MODEL,
|
|
1503
1672
|
LlmError,
|
|
1504
1673
|
MemoryIndex,
|
|
1505
1674
|
QA_LANE,
|
|
1506
1675
|
QA_LANE_MIN_SIMILARITY,
|
|
1676
|
+
agentIndexUrls,
|
|
1507
1677
|
answerMessages,
|
|
1508
1678
|
answerQuestion,
|
|
1509
1679
|
authFailure,
|
|
1680
|
+
capabilityVerbs,
|
|
1510
1681
|
chunkPage,
|
|
1511
1682
|
citationsFor,
|
|
1683
|
+
contentTokenOverlap,
|
|
1512
1684
|
cosine,
|
|
1513
1685
|
createAnswersBackend,
|
|
1514
1686
|
createOpenAiClient,
|
|
1515
1687
|
decodeCfEmail,
|
|
1516
1688
|
decodeEntities,
|
|
1517
1689
|
decodeProtectedEmails,
|
|
1690
|
+
distinctiveTokens,
|
|
1518
1691
|
extractPage,
|
|
1519
1692
|
extractText,
|
|
1520
1693
|
figuresIn,
|
|
1521
1694
|
firstPathSegment,
|
|
1522
1695
|
inferSources,
|
|
1696
|
+
isAgentIndexUrl,
|
|
1523
1697
|
isReasoningModel,
|
|
1524
1698
|
matchingQa,
|
|
1525
1699
|
namedPathBucket,
|
|
@@ -1527,9 +1701,11 @@ export {
|
|
|
1527
1701
|
prepareMessages,
|
|
1528
1702
|
reciprocalRankFusion,
|
|
1529
1703
|
samePage,
|
|
1704
|
+
selectNamedEvidence,
|
|
1530
1705
|
selectQaLane,
|
|
1531
1706
|
sourceBlock,
|
|
1532
1707
|
stem,
|
|
1708
|
+
subjectTokens,
|
|
1533
1709
|
tokenize,
|
|
1534
1710
|
verifyAnswer,
|
|
1535
1711
|
withQaLane
|