@konneal/engine 0.2.132 → 0.2.134
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/dist/{ask-XAJDCNFU.js → ask-PFU74MIP.js} +2 -2
- package/dist/{chunk-DNDQQYDT.js → chunk-JYJJ4SYF.js} +7 -6
- package/dist/{chunk-26VW4ZXU.js → chunk-WPOKSNPE.js} +18 -11
- package/dist/refusal.d.ts +17 -1
- package/dist/{search-544LVPGG.js → search-NIRCPRMW.js} +1 -1
- package/dist/worker_public/src/index.js +4 -4
- package/dist/worker_public/src/refusal.js +1 -1
- package/package.json +1 -1
- package/workers/worker_public/src/ask.ts +9 -5
- package/workers/worker_public/src/refusal.ts +42 -10
- package/workers/worker_public/src/research.ts +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
handleAsk
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-JYJJ4SYF.js";
|
|
4
4
|
import "./chunk-J6LBR6GI.js";
|
|
5
5
|
import "./chunk-7AXXR7GD.js";
|
|
6
6
|
import "./chunk-TH5XGPD5.js";
|
|
7
7
|
import "./chunk-W7J5SFXJ.js";
|
|
8
|
-
import "./chunk-
|
|
8
|
+
import "./chunk-WPOKSNPE.js";
|
|
9
9
|
import "./chunk-4NXH5ZI6.js";
|
|
10
10
|
import "./chunk-VT7DR6NQ.js";
|
|
11
11
|
import "./chunk-GBGRHXRU.js";
|
|
@@ -83,7 +83,7 @@ import {
|
|
|
83
83
|
canonicalRefusal,
|
|
84
84
|
exciseMisplacedPin,
|
|
85
85
|
refusalAnswer
|
|
86
|
-
} from "./chunk-
|
|
86
|
+
} from "./chunk-WPOKSNPE.js";
|
|
87
87
|
import {
|
|
88
88
|
entitlementScope,
|
|
89
89
|
requestSalt,
|
|
@@ -1980,6 +1980,7 @@ async function handleAsk(env, ctx, req, tier, key) {
|
|
|
1980
1980
|
}
|
|
1981
1981
|
}
|
|
1982
1982
|
let understanding = null;
|
|
1983
|
+
const refusalScope = (cited) => ({ cited, conversational: understanding?.intent === "conversational" });
|
|
1983
1984
|
let optimisticVec = null;
|
|
1984
1985
|
let optimisticHits = [];
|
|
1985
1986
|
const t0 = Date.now();
|
|
@@ -2620,7 +2621,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
|
|
|
2620
2621
|
}
|
|
2621
2622
|
}
|
|
2622
2623
|
}
|
|
2623
|
-
const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full), true) : canonicalRefusal(full);
|
|
2624
|
+
const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full, refusalScope(usedHits.length > 0)), true) : canonicalRefusal(full, refusalScope(false));
|
|
2624
2625
|
const c2 = canonical0.includes(refusalAnswer()) ? { text: canonical0, blocks: [], dropped: [] } : await contractV2(env.DB, canonical0, usedHits);
|
|
2625
2626
|
send({
|
|
2626
2627
|
type: "done",
|
|
@@ -2704,7 +2705,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
|
|
|
2704
2705
|
);
|
|
2705
2706
|
answer = await generateOnce(env, MODELS.fallback, flat, routeEffort(routed.route));
|
|
2706
2707
|
}
|
|
2707
|
-
if (answer) answer = canonicalRefusal(answer);
|
|
2708
|
+
if (answer) answer = canonicalRefusal(answer, refusalScope(usedHits.length > 0));
|
|
2708
2709
|
if (answer && usedHits.length > 0) answer = exciseMisplacedPin(answer, true);
|
|
2709
2710
|
stageTiming.generate = Date.now() - tGen;
|
|
2710
2711
|
let used = usedHits;
|
|
@@ -2729,7 +2730,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
|
|
|
2729
2730
|
generateRetries += 1;
|
|
2730
2731
|
const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], routeEffort(routed.route));
|
|
2731
2732
|
if (corrected) {
|
|
2732
|
-
const correctedAnswer = canonicalRefusal(corrected);
|
|
2733
|
+
const correctedAnswer = canonicalRefusal(corrected, refusalScope(usedHits.length > 0));
|
|
2733
2734
|
const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h) => h.text));
|
|
2734
2735
|
const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
|
|
2735
2736
|
if (retryAnchors.violations.length < anchors.violations.length || !retryRetyped && retyped || unreferenced && correctedAnswer.includes("[[u:")) {
|
|
@@ -2754,7 +2755,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
|
|
|
2754
2755
|
const { messages: retryMessages, usedHits: retryUsed } = buildMessages(q.query, retryRetrieve.hits, q.lang, keptHistory, void 0, summary, budget);
|
|
2755
2756
|
const retryAnswer = await generateOnce(env, model, retryMessages, routeEffort(routed.route));
|
|
2756
2757
|
if (retryAnswer) {
|
|
2757
|
-
answer = canonicalRefusal(retryAnswer);
|
|
2758
|
+
answer = canonicalRefusal(retryAnswer, refusalScope(retryRetrieve.hits.length > 0));
|
|
2758
2759
|
used = retryUsed;
|
|
2759
2760
|
}
|
|
2760
2761
|
}
|
|
@@ -2786,7 +2787,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
|
|
|
2786
2787
|
messages[messages.length - 1]
|
|
2787
2788
|
], effort);
|
|
2788
2789
|
if (regen && !regen.includes(refusalAnswer())) {
|
|
2789
|
-
answer = canonicalRefusal(regen);
|
|
2790
|
+
answer = canonicalRefusal(regen, refusalScope(true));
|
|
2790
2791
|
console.log("blind-answer: regeneration grounded");
|
|
2791
2792
|
}
|
|
2792
2793
|
}
|
|
@@ -24,16 +24,7 @@ function refusalPatterns(publisher) {
|
|
|
24
24
|
// verb is the guard, a scope DISCUSSION inside a real answer
|
|
25
25
|
// ("this exemption is outside the scope of R 60") never matches
|
|
26
26
|
/\bI don[’']?t have (?:any )?information on [^.]{0,80}(?:that[’']?s|—)?\s*(?:general|common) knowledge\b/i,
|
|
27
|
-
/\b(?:that[’']?s|this is) (?:just )?(?:general|common) knowledge[^.]{0,80}(?:outside|beyond|not covered)\b/i
|
|
28
|
-
// the redirect-only openers (2026-10-11 smoke): the model swaps the
|
|
29
|
-
// pinned sentence for a scope OFFER — "I'm happy to help with
|
|
30
|
-
// questions about OIML … instead." / "I can answer questions about
|
|
31
|
-
// OIML … — such as OIML B 20:2024 …". Anchored at the answer's
|
|
32
|
-
// start (a real answer never OPENS with the redirect — the
|
|
33
|
-
// redirect TAIL after content survives untouched), the publisher
|
|
34
|
-
// token profile-driven.
|
|
35
|
-
new RegExp(`^\\s*I(?:['\u2019]m| am) happy to help with questions about ${publisher}\\b[^\\n]{0,220}?\\binstead\\b`, "i"),
|
|
36
|
-
new RegExp(`^\\s*I can (?:answer|help with) questions about ${publisher}\\b[^\\n]{0,220}?\\bpublications\\b`, "i")
|
|
27
|
+
/\b(?:that[’']?s|this is) (?:just )?(?:general|common) knowledge[^.]{0,80}(?:outside|beyond|not covered)\b/i
|
|
37
28
|
],
|
|
38
29
|
// the misplaced-pin wall's subset: every pattern here carries a
|
|
39
30
|
// first-person refusal verb ("I don't have information", "I can't
|
|
@@ -61,7 +52,19 @@ function sentenceEnd(answer, i) {
|
|
|
61
52
|
const m = /[.!?\n]/.exec(answer.slice(i));
|
|
62
53
|
return m ? i + m.index + 1 : answer.length;
|
|
63
54
|
}
|
|
64
|
-
function
|
|
55
|
+
function redirectOfferSentences(answer) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < answer.length) {
|
|
59
|
+
const end = sentenceEnd(answer, i);
|
|
60
|
+
const s = answer.slice(i, end);
|
|
61
|
+
if (/\bI\b[^.!?]{0,160}?\bquestions? about\b/i.test(s) || /\bI\b[^.!?]{0,160}?\binstead\b/i.test(s) && !/[§\[\d]/.test(s)) out.push([i, end]);
|
|
62
|
+
i = end;
|
|
63
|
+
while (i < answer.length && /\s/.test(answer[i] ?? "")) i++;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function canonicalRefusal(answer, scope) {
|
|
65
68
|
const CANON = refusalAnswer();
|
|
66
69
|
if (answer.includes(CANON)) return answer;
|
|
67
70
|
const { variant: REFUSAL_VARIANT, drift: REFUSAL_DRIFT } = refusalPatterns(P().publisher.name);
|
|
@@ -72,6 +75,10 @@ function canonicalRefusal(answer) {
|
|
|
72
75
|
if (!m) continue;
|
|
73
76
|
return answer.slice(0, sentenceStart(answer, m.index)) + CANON + answer.slice(sentenceEnd(answer, m.index));
|
|
74
77
|
}
|
|
78
|
+
if (scope && !scope.cited && !scope.conversational) {
|
|
79
|
+
const offer = redirectOfferSentences(answer)[0];
|
|
80
|
+
if (offer) return answer.slice(0, offer[0]) + CANON + answer.slice(offer[1]);
|
|
81
|
+
}
|
|
75
82
|
return answer;
|
|
76
83
|
}
|
|
77
84
|
function exciseMisplacedPin(answer, cites) {
|
package/dist/refusal.d.ts
CHANGED
|
@@ -2,11 +2,27 @@
|
|
|
2
2
|
* Refusals are never cached: a refusal says "retrieval found nothing",
|
|
3
3
|
* which is a property of the moment, not of the question. */
|
|
4
4
|
export declare function refusalAnswer(): string;
|
|
5
|
+
/** The refusal scope (2026-10-11, the third smoke shape): the loose
|
|
6
|
+
* redirect-offer family is gated by what the PIPELINE knows, not by
|
|
7
|
+
* ever-more phrase patterns. The model's scope offers keep drifting
|
|
8
|
+
* ("I'm happy to help … instead", "I can answer questions about …",
|
|
9
|
+
* "I'm a metrology assistant — I can help with … instead") — the same
|
|
10
|
+
* surface form that legitimately ANSWERS a conversational question
|
|
11
|
+
* ("what can you help me with?"). The discriminators: a knowledge-seek
|
|
12
|
+
* question (the understanding's own class) and an UNCITED answer — a
|
|
13
|
+
* cited answer's scope talk is content, a conversational turn's scope
|
|
14
|
+
* talk is the answer itself. */
|
|
15
|
+
export interface RefusalScope {
|
|
16
|
+
/** the understanding read the question as a conversational turn */
|
|
17
|
+
conversational?: boolean;
|
|
18
|
+
/** the answer carries citations */
|
|
19
|
+
cited?: boolean;
|
|
20
|
+
}
|
|
5
21
|
/** Normalize a refusal-shaped answer to the pinned sentence. A drift
|
|
6
22
|
* refusal occupies its own sentence: swap that sentence (preface
|
|
7
23
|
* included) for the pinned one — the redirect tail survives. An answer
|
|
8
24
|
* outside the refusal family is returned byte-identical. */
|
|
9
|
-
export declare function canonicalRefusal(answer: string): string;
|
|
25
|
+
export declare function canonicalRefusal(answer: string, scope?: RefusalScope): string;
|
|
10
26
|
/** The misplaced-pin wall (2026-10-10): an answer that actually cites
|
|
11
27
|
* passages is NOT a refusal — but the model sometimes drops the
|
|
12
28
|
* pinned sentence into an otherwise-correct, well-cited answer as a
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
handleMemories,
|
|
5
5
|
scoreFaithfulness,
|
|
6
6
|
scoreJudge
|
|
7
|
-
} from "../../chunk-
|
|
7
|
+
} from "../../chunk-JYJJ4SYF.js";
|
|
8
8
|
import {
|
|
9
9
|
TOOLS_REGISTRY,
|
|
10
10
|
bindModelNode,
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
} from "../../chunk-W7J5SFXJ.js";
|
|
55
55
|
import {
|
|
56
56
|
canonicalRefusal
|
|
57
|
-
} from "../../chunk-
|
|
57
|
+
} from "../../chunk-WPOKSNPE.js";
|
|
58
58
|
import {
|
|
59
59
|
entitlementScope,
|
|
60
60
|
standardKeysFrom
|
|
@@ -877,7 +877,7 @@ ${recent.map((h, n) => `[${n + 1}] ${h.metadata.docidentifier ?? ""} \xA7${h.met
|
|
|
877
877
|
telemetry(env, ctx, "member", "research", MODELS.research, false, 0, queryHash, q.lang);
|
|
878
878
|
throw new Error("The generation model is unavailable; please retry.");
|
|
879
879
|
}
|
|
880
|
-
answer = canonicalRefusal(answer);
|
|
880
|
+
answer = canonicalRefusal(answer, { cited: usedHits.length > 0, conversational: false });
|
|
881
881
|
const anchors = checkQuoteAnchors(answer, used.map((h) => h.text));
|
|
882
882
|
if (anchors.violations.length) console.log("research anchors:", anchors.violations.length, "unverified");
|
|
883
883
|
const out = {
|
|
@@ -1052,7 +1052,7 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
|
|
|
1052
1052
|
const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
|
|
1053
1053
|
payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
|
|
1054
1054
|
} else {
|
|
1055
|
-
const res = name === "ask" ? await (await import("../../ask-
|
|
1055
|
+
const res = name === "ask" ? await (await import("../../ask-PFU74MIP.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-NIRCPRMW.js")).handleSearch(env, ctx, inner, tier, key);
|
|
1056
1056
|
payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
|
|
1057
1057
|
}
|
|
1058
1058
|
telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"playwright": "^1.62.1",
|
|
28
28
|
"yaml": "^2.9.1"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.2.
|
|
30
|
+
"version": "0.2.134",
|
|
31
31
|
"description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
|
|
32
32
|
"license": "BSD-3-Clause",
|
|
33
33
|
"type": "module",
|
|
@@ -625,6 +625,10 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
625
625
|
// take this path (they always run understanding + live retrieval);
|
|
626
626
|
// fresh already bypassed the exact cache above and bypasses this one.
|
|
627
627
|
let understanding: any = null;
|
|
628
|
+
// the loose redirect-offer family's gate (refusal.ts): knowledge-seek
|
|
629
|
+
// questions only, uncited answers only — a conversational turn's
|
|
630
|
+
// scope vocabulary is the answer itself
|
|
631
|
+
const refusalScope = (cited: boolean) => ({ cited, conversational: understanding?.intent === "conversational" });
|
|
628
632
|
// a query naming a model node (/req/…, /term/…) is node-SCOPED: its
|
|
629
633
|
// ── Option C: optimistic parallel retrieval ──
|
|
630
634
|
// The dense lane (folded-query embed + unfiltered Vectorize query) runs
|
|
@@ -1537,7 +1541,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1537
1541
|
}
|
|
1538
1542
|
// the misplaced-pin wall rides the streamed path too: a
|
|
1539
1543
|
// CITED stream is not a refusal
|
|
1540
|
-
const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full), true) : canonicalRefusal(full);
|
|
1544
|
+
const canonical0 = usedHits.length > 0 ? exciseMisplacedPin(canonicalRefusal(full, refusalScope(usedHits.length > 0)), true) : canonicalRefusal(full, refusalScope(false));
|
|
1541
1545
|
// answer contract v2: validate [[u:]] refs, resolve typed blocks
|
|
1542
1546
|
const c2 = canonical0.includes(refusalAnswer())
|
|
1543
1547
|
? { text: canonical0, blocks: [], dropped: [] as string[] }
|
|
@@ -1641,7 +1645,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1641
1645
|
);
|
|
1642
1646
|
answer = await generateOnce(env, MODELS.fallback, flat, routeEffort(routed.route));
|
|
1643
1647
|
}
|
|
1644
|
-
if (answer) answer = canonicalRefusal(answer);
|
|
1648
|
+
if (answer) answer = canonicalRefusal(answer, refusalScope(usedHits.length > 0));
|
|
1645
1649
|
// the misplaced-pin wall: a CITED answer is not a refusal — excise
|
|
1646
1650
|
// the pinned sentence if the model dropped it in as a disclaimer
|
|
1647
1651
|
// (measured on chip-scoped asks: correct cited answers carrying the
|
|
@@ -1684,7 +1688,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1684
1688
|
generateRetries += 1;
|
|
1685
1689
|
const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], routeEffort(routed.route));
|
|
1686
1690
|
if (corrected) {
|
|
1687
|
-
const correctedAnswer = canonicalRefusal(corrected);
|
|
1691
|
+
const correctedAnswer = canonicalRefusal(corrected, refusalScope(usedHits.length > 0));
|
|
1688
1692
|
const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h: Hit) => h.text));
|
|
1689
1693
|
const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
|
|
1690
1694
|
if (retryAnchors.violations.length < anchors.violations.length || (!retryRetyped && retyped) || (unreferenced && correctedAnswer.includes("[[u:"))) {
|
|
@@ -1729,7 +1733,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1729
1733
|
const retryAnswer = await generateOnce(env, model, retryMessages, routeEffort(routed.route));
|
|
1730
1734
|
// the answer now comes from the retry passages — citations must follow
|
|
1731
1735
|
if (retryAnswer) {
|
|
1732
|
-
answer = canonicalRefusal(retryAnswer);
|
|
1736
|
+
answer = canonicalRefusal(retryAnswer, refusalScope(retryRetrieve.hits.length > 0));
|
|
1733
1737
|
used = retryUsed;
|
|
1734
1738
|
}
|
|
1735
1739
|
}
|
|
@@ -1781,7 +1785,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1781
1785
|
messages[messages.length - 1],
|
|
1782
1786
|
], effort);
|
|
1783
1787
|
if (regen && !regen.includes(refusalAnswer())) {
|
|
1784
|
-
answer = canonicalRefusal(regen);
|
|
1788
|
+
answer = canonicalRefusal(regen, refusalScope(true));
|
|
1785
1789
|
console.log("blind-answer: regeneration grounded");
|
|
1786
1790
|
}
|
|
1787
1791
|
}
|
|
@@ -29,15 +29,6 @@ function refusalPatterns(publisher: string): { variant: RegExp; drift: RegExp[];
|
|
|
29
29
|
// ("this exemption is outside the scope of R 60") never matches
|
|
30
30
|
/\bI don[’']?t have (?:any )?information on [^.]{0,80}(?:that[’']?s|—)?\s*(?:general|common) knowledge\b/i,
|
|
31
31
|
/\b(?:that[’']?s|this is) (?:just )?(?:general|common) knowledge[^.]{0,80}(?:outside|beyond|not covered)\b/i,
|
|
32
|
-
// the redirect-only openers (2026-10-11 smoke): the model swaps the
|
|
33
|
-
// pinned sentence for a scope OFFER — "I'm happy to help with
|
|
34
|
-
// questions about OIML … instead." / "I can answer questions about
|
|
35
|
-
// OIML … — such as OIML B 20:2024 …". Anchored at the answer's
|
|
36
|
-
// start (a real answer never OPENS with the redirect — the
|
|
37
|
-
// redirect TAIL after content survives untouched), the publisher
|
|
38
|
-
// token profile-driven.
|
|
39
|
-
new RegExp(`^\\s*I(?:['’]m| am) happy to help with questions about ${publisher}\\b[^\\n]{0,220}?\\binstead\\b`, "i"),
|
|
40
|
-
new RegExp(`^\\s*I can (?:answer|help with) questions about ${publisher}\\b[^\\n]{0,220}?\\bpublications\\b`, "i"),
|
|
41
32
|
],
|
|
42
33
|
// the misplaced-pin wall's subset: every pattern here carries a
|
|
43
34
|
// first-person refusal verb ("I don't have information", "I can't
|
|
@@ -71,11 +62,48 @@ function sentenceEnd(answer: string, i: number): number {
|
|
|
71
62
|
return m ? i + m.index + 1 : answer.length;
|
|
72
63
|
}
|
|
73
64
|
|
|
65
|
+
/** The refusal scope (2026-10-11, the third smoke shape): the loose
|
|
66
|
+
* redirect-offer family is gated by what the PIPELINE knows, not by
|
|
67
|
+
* ever-more phrase patterns. The model's scope offers keep drifting
|
|
68
|
+
* ("I'm happy to help … instead", "I can answer questions about …",
|
|
69
|
+
* "I'm a metrology assistant — I can help with … instead") — the same
|
|
70
|
+
* surface form that legitimately ANSWERS a conversational question
|
|
71
|
+
* ("what can you help me with?"). The discriminators: a knowledge-seek
|
|
72
|
+
* question (the understanding's own class) and an UNCITED answer — a
|
|
73
|
+
* cited answer's scope talk is content, a conversational turn's scope
|
|
74
|
+
* talk is the answer itself. */
|
|
75
|
+
export interface RefusalScope {
|
|
76
|
+
/** the understanding read the question as a conversational turn */
|
|
77
|
+
conversational?: boolean;
|
|
78
|
+
/** the answer carries citations */
|
|
79
|
+
cited?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A whole-sentence redirect offer in ONE sentence, first person:
|
|
83
|
+
* the "questions about" marker (four observed shapes carry it —
|
|
84
|
+
* "I'm happy to help with questions about…", "I can answer questions
|
|
85
|
+
* about…", "I'm a metrology assistant — I can answer questions
|
|
86
|
+
* about…", "I'm here for questions about…"), or the "instead" marker
|
|
87
|
+
* in a sentence with NO citation apparatus (no clause anchors, no
|
|
88
|
+
* digits — an offer names no provisions). */
|
|
89
|
+
function redirectOfferSentences(answer: string): Array<[number, number]> {
|
|
90
|
+
const out: Array<[number, number]> = [];
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < answer.length) {
|
|
93
|
+
const end = sentenceEnd(answer, i);
|
|
94
|
+
const s = answer.slice(i, end);
|
|
95
|
+
if (/\bI\b[^.!?]{0,160}?\bquestions? about\b/i.test(s) || (/\bI\b[^.!?]{0,160}?\binstead\b/i.test(s) && !/[§\[\d]/.test(s))) out.push([i, end]);
|
|
96
|
+
i = end;
|
|
97
|
+
while (i < answer.length && /\s/.test(answer[i] ?? "")) i++;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
/** Normalize a refusal-shaped answer to the pinned sentence. A drift
|
|
75
103
|
* refusal occupies its own sentence: swap that sentence (preface
|
|
76
104
|
* included) for the pinned one — the redirect tail survives. An answer
|
|
77
105
|
* outside the refusal family is returned byte-identical. */
|
|
78
|
-
export function canonicalRefusal(answer: string): string {
|
|
106
|
+
export function canonicalRefusal(answer: string, scope?: RefusalScope): string {
|
|
79
107
|
const CANON = refusalAnswer();
|
|
80
108
|
if (answer.includes(CANON)) return answer;
|
|
81
109
|
const { variant: REFUSAL_VARIANT, drift: REFUSAL_DRIFT } = refusalPatterns(P().publisher.name);
|
|
@@ -86,6 +114,10 @@ export function canonicalRefusal(answer: string): string {
|
|
|
86
114
|
if (!m) continue;
|
|
87
115
|
return answer.slice(0, sentenceStart(answer, m.index)) + CANON + answer.slice(sentenceEnd(answer, m.index));
|
|
88
116
|
}
|
|
117
|
+
if (scope && !scope.cited && !scope.conversational) {
|
|
118
|
+
const offer = redirectOfferSentences(answer)[0];
|
|
119
|
+
if (offer) return answer.slice(0, offer[0]) + CANON + answer.slice(offer[1]);
|
|
120
|
+
}
|
|
89
121
|
return answer;
|
|
90
122
|
}
|
|
91
123
|
|
|
@@ -164,7 +164,7 @@ async function run(
|
|
|
164
164
|
telemetry(env, ctx, "member", "research", MODELS.research, false, 0, queryHash, q.lang);
|
|
165
165
|
throw new Error("The generation model is unavailable; please retry.");
|
|
166
166
|
}
|
|
167
|
-
answer = canonicalRefusal(answer);
|
|
167
|
+
answer = canonicalRefusal(answer, { cited: usedHits.length > 0, conversational: false });
|
|
168
168
|
const anchors = checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
|
|
169
169
|
if (anchors.violations.length) console.log("research anchors:", anchors.violations.length, "unverified");
|
|
170
170
|
const out = {
|