alexa-ai 2.1.1
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/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WebAnswer
|
|
5
|
+
* ---------
|
|
6
|
+
* Prompt and post-processing for `AlexaAI.searchWeb()`.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS
|
|
9
|
+
* ---------------
|
|
10
|
+
* DeepAI's chat endpoint reports web-search results in two different ways,
|
|
11
|
+
* depending on the model that answered:
|
|
12
|
+
*
|
|
13
|
+
* 1. Structured — a trailing JSON packet after the prose
|
|
14
|
+
* …answer…\u001C[{"title":…,"url":…,"description":…}]
|
|
15
|
+
* which StreamParser exposes as `webResults`.
|
|
16
|
+
*
|
|
17
|
+
* 2. Unstructured — nothing extra on the wire. The model simply writes a
|
|
18
|
+
* "Sources:" list of `title (url)` lines at the end of its answer
|
|
19
|
+
* (observed live with `gpt-4o-mini`: `sources: []`, links inside `text`).
|
|
20
|
+
*
|
|
21
|
+
* On top of that, a plain "search the web and answer" request yields one or
|
|
22
|
+
* two sentences, and some models prepend "I'm a large language model, I don't
|
|
23
|
+
* have the ability to browse the web…" even though the search tool ran.
|
|
24
|
+
*
|
|
25
|
+
* So this module owns three things:
|
|
26
|
+
*
|
|
27
|
+
* • `prompt()` — asks for a long, sectioned, WhatsApp-formatted answer with
|
|
28
|
+
* numbered points and an explicit trailing "Sources:" list.
|
|
29
|
+
* • `parse()` — lifts that list (and inline "(Source: url)" citations) out
|
|
30
|
+
* of the prose into `{title, url}` objects, and removes
|
|
31
|
+
* first-person "I cannot browse the web" boilerplate.
|
|
32
|
+
* • `render()` — appends one clean, de-duplicated *Sources:* block, so the
|
|
33
|
+
* WhatsApp message still carries the links exactly once.
|
|
34
|
+
*
|
|
35
|
+
* `parse()` runs on text that ResponseFormatter has already normalised, so it
|
|
36
|
+
* only has to understand WhatsApp-style output: `*Sources:*`, `• title (url)`,
|
|
37
|
+
* `1. title — url`, bare URLs, and a title line followed by a URL line.
|
|
38
|
+
*
|
|
39
|
+
* Third-party names are deliberately left alone: a research answer about
|
|
40
|
+
* Google, Microsoft or OpenAI must not be rewritten the way IdentityGuard
|
|
41
|
+
* rewrites the assistant's own identity. Only sentences in which the model
|
|
42
|
+
* talks about *itself* are removed.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** A URL, allowing one level of balanced parentheses (Wikipedia_(disambiguation)). */
|
|
46
|
+
const URL = String.raw`https?:\/\/(?:[^\s<>()\[\]"'“”]|\([^\s()]*\))+`;
|
|
47
|
+
/** Optional list marker: "•", "-", "*", "1.", "1)", "(1)". */
|
|
48
|
+
const MARK = String.raw`(?:(?:[-•*·]|\d{1,2}[.)]|[(\[]\d{1,2}[)\]])\s*)?`;
|
|
49
|
+
const MARK_ONLY = /^(?:[-•*·]|\d{1,2}[.)]|[(\[]\d{1,2}[)\]])\s+/;
|
|
50
|
+
const MARK_ONLY_LINE = /^\s*(?:[-•*·]|\d{1,2}[.)]|[(\[]\d{1,2}[)\]])\s+/gm;
|
|
51
|
+
|
|
52
|
+
const LINE = {
|
|
53
|
+
/** `Title (https://…)`, `Title [https://…]`, `Title <https://…>` */
|
|
54
|
+
titleParen: new RegExp(`^\\s*${MARK}(.{1,160}?)\\s*[\\(\\[<]\\s*(${URL})\\s*[\\)\\]>]\\s*[.,;]?\\s*$`, 'i'),
|
|
55
|
+
/** `Title — https://…`, `Title: https://…`, `Title | https://…` */
|
|
56
|
+
titleSep: new RegExp(`^\\s*${MARK}(.{1,160}?)\\s*(?:[-–—|]|→|=>|:)\\s*(${URL})\\s*[.,;]?\\s*$`, 'i'),
|
|
57
|
+
/** `Title https://…` (whitespace only — accepted under a heading, not in prose) */
|
|
58
|
+
titleSpace: new RegExp(`^\\s*${MARK}(.{1,160}?)\\s+(${URL})\\s*[.,;]?\\s*$`, 'i'),
|
|
59
|
+
/** `https://… — Title` */
|
|
60
|
+
urlSep: new RegExp(`^\\s*${MARK}(${URL})\\s*(?:[-–—|:]|→)\\s*(.{1,160}?)\\s*$`, 'i'),
|
|
61
|
+
/** `https://…` on its own */
|
|
62
|
+
bareUrl: new RegExp(`^\\s*${MARK}(${URL})\\s*[.,;]?\\s*$`, 'i'),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A whole line that only introduces the list: "Sources:", "*References*", "Here are the sources I used:" … */
|
|
66
|
+
const HEADING = new RegExp(
|
|
67
|
+
'^\\s*[*_~#>•\\-\\s]*' +
|
|
68
|
+
'(?:(?:here|these|below)\\s+are\\s+)?' +
|
|
69
|
+
'(?:(?:some|the|my|a\\s+few|a\\s+couple\\s+of|all|useful|helpful|relevant|main|key|top|additional|related|recommended|official|primary|selected|trusted|reliable|web|online)\\s+)*' +
|
|
70
|
+
'(?:sources?|references?|citations?|links?|reading|further\\s+reading|read\\s+more|learn\\s+more|for\\s+more\\s+(?:information|details|info)|where\\s+to\\s+read\\s+more|(?:sources?|links?)\\s+(?:and|&)\\s+(?:references?|links?))' +
|
|
71
|
+
'(?:\\s+(?:(?:that\\s+)?i\\s+)?(?:used|consulted|found|referenced|cited))?' +
|
|
72
|
+
'(?:\\s+for\\s+this\\s+(?:answer|response))?' +
|
|
73
|
+
'\\s*[*_~]*\\s*:?\\s*[*_~]*\\s*$',
|
|
74
|
+
'i'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** "Sources: https://a, https://b" — heading and items on one line. */
|
|
78
|
+
const HEADING_INLINE = /^\s*[*_~#>•\-\s]*(?:sources?|references?|citations?|links?)\s*[*_~]*\s*:\s*[*_~]*\s*(\S.*)$/i;
|
|
79
|
+
|
|
80
|
+
/** "(Source: https://…)" / "[via https://…]" inside a sentence. */
|
|
81
|
+
const INLINE_CITATION = new RegExp(
|
|
82
|
+
`\\s*[(\\[]\\s*(?:sources?|src|via|ref(?:erence)?s?|read\\s+more|more\\s+(?:info|information))\\s*:?\\s*(${URL}(?:\\s*[,;]\\s*${URL})*)\\s*[)\\]]`,
|
|
83
|
+
'gi'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** Titles that are really just labels ("Link", "See also"). */
|
|
87
|
+
const LABEL_TITLE = /^(?:sources?|links?|references?|citations?|read\s+more|see|see\s+also|via|from|url|link|website|site|more|here)$/i;
|
|
88
|
+
/** Placeholder "sources" that name nothing: "None", "General knowledge, no specific pages". */
|
|
89
|
+
const PLACEHOLDER_TITLE =
|
|
90
|
+
/^(?:none|n\/a|not\s+applicable|no\s+(?:specific\s+|particular\s+|external\s+)?(?:sources?|links?|urls?|pages?|websites?)|general\s+knowledge|(?:my|internal|prior|existing)\s+(?:knowledge|training)|based\s+on\s+(?:my|general)\b|various(?:\s+(?:online\s+)?(?:sources?|websites?))?|multiple(?:\s+(?:online\s+)?(?:sources?|websites?))?)\b/i;
|
|
91
|
+
/** Trailing prepositions left on a title: "Read the full report at". */
|
|
92
|
+
const TITLE_TAIL = /\s+(?:at|from|on|via|in|here|see|visit|to|by)$/i;
|
|
93
|
+
|
|
94
|
+
/** Vendor / product names the assistant must never attribute itself to. */
|
|
95
|
+
const VENDORS =
|
|
96
|
+
'deep\\s*ai|open\\s*ai|chat\\s*gpt|gpt-?\\d[\\w.-]*|claude|gemini|bard|llama[\\w.-]*|mistral|grok|deepseek|qwen|anthropic|google ai|meta ai|standard ai chat|copilot';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sentences in which the model talks about itself instead of the topic.
|
|
100
|
+
* Every alternative is first-person or self-attributing, so an article about
|
|
101
|
+
* "the training data" or "an AI assistant from Google" is untouched. The
|
|
102
|
+
* whole sentence is removed.
|
|
103
|
+
*/
|
|
104
|
+
const DISCLAIMER = new RegExp(
|
|
105
|
+
'[^.!?\\n]*(?:' +
|
|
106
|
+
// "I'm a large language model", "I am an AI"
|
|
107
|
+
"\\bi(?:'m| am)\\s+(?:just\\s+|only\\s+|merely\\s+)?(?:an?\\s+)?(?:large\\s+)?(?:ai\\s+)?(?:language\\s+)?(?:model|ai)\\b|" +
|
|
108
|
+
// "As an AI (language model), I …"
|
|
109
|
+
'\\bas an? (?:ai|artificial intelligence|(?:large\\s+)?language model)(?:\\s+(?:language\\s+)?(?:model|assistant|system))?\\s*,?\\s+i\\b|' +
|
|
110
|
+
// "I'm ChatGPT", "I was trained by OpenAI", "my developers at OpenAI"
|
|
111
|
+
`\\bi(?:'m| am)\\s+(?:${VENDORS})\\b|` +
|
|
112
|
+
'\\bi\\s+(?:was|am|have\\s+been)\\s+(?:created|made|developed|built|trained|designed)\\s+by\\b|' +
|
|
113
|
+
`\\b(?:i(?:'m| am| use| run on| was| have been)|my (?:developers?|creators?|makers?|team|model|training|underlying model|architecture))\\b[^.!?\\n]{0,60}?\\b(?:${VENDORS})\\b|` +
|
|
114
|
+
// "This response was generated by ChatGPT", "Powered by GPT-4"
|
|
115
|
+
`\\b(?:this|the)\\s+(?:answer|response|report|summary|information|content|text)\\s+(?:was|is|has been)\\s+(?:generated|produced|written|compiled|created|provided)\\s+by\\b[^.!?\\n]{0,40}?\\b(?:${VENDORS})\\b|` +
|
|
116
|
+
`\\bpowered\\s+by\\s+(?:an?\\s+)?(?:${VENDORS})\\b|` +
|
|
117
|
+
// "I can't / don't have the ability to browse the web / access real-time data"
|
|
118
|
+
"\\bi\\s+(?:can(?:'t|not)|cannot|am\\s+(?:not\\s+able|unable)\\s+to|do\\s+not\\s+have|don't\\s+have|lack|have\\s+no)\\b[^.!?\\n,]{0,60}?" +
|
|
119
|
+
'\\b(?:brows\\w*|surf\\w*|(?:the\\s+)?internet|(?:the\\s+)?web\\b|real[- ]time(?:\\s+\\w+)?|live\\s+(?:data|information|updates|access)|up-to-date\\s+information|current\\s+(?:information|data|events)|internet\\s+access|web\\s+access|external\\s+(?:websites?|links?|sources?))|' +
|
|
120
|
+
// knowledge cut-off / training-data caveats
|
|
121
|
+
'\\bmy\\s+(?:knowledge|training)\\s+(?:cut-?off|data)\\b|\\bas of my (?:last|latest|most recent) (?:update|training)\\b|' +
|
|
122
|
+
'\\bbased on (?:my|the) (?:training|available) (?:data|information)\\b' +
|
|
123
|
+
')[^.!?\\n]*[.!?]*',
|
|
124
|
+
'gi'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** `<Headline>` / `<…>` tokens copied from the layout template. */
|
|
128
|
+
const PLACEHOLDER = /<(?!https?:\/\/)(?=[^\s<>])[^<>\n]{0,80}(?<=[^\s<>])>/g;
|
|
129
|
+
/** A line with nothing left but list/heading scaffolding once placeholders are gone: "1. **: ", "*:*". */
|
|
130
|
+
const SCAFFOLD_LINE = /^[\s\d.)(\-•*_~:…]*$/;
|
|
131
|
+
|
|
132
|
+
/** "However, " left dangling at the start of the reply once a disclaimer went. */
|
|
133
|
+
const DANGLING_CONJUNCTION = /^(?:but|however|that said|still|anyway|nevertheless|nonetheless|instead)\s*,?\s+/i;
|
|
134
|
+
|
|
135
|
+
class WebAnswer {
|
|
136
|
+
/** Normalise the `detail` option. */
|
|
137
|
+
static detailOf(value) {
|
|
138
|
+
return /^(?:short|brief|concise|quick)$/i.test(String(value ?? '')) ? 'short' : 'long';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The user turn sent to DeepAI.
|
|
143
|
+
*
|
|
144
|
+
* The long form hands the model a fill-in-the-blanks template rather than
|
|
145
|
+
* a description of one: small models (`gpt-4o-mini`, `standard`) follow a
|
|
146
|
+
* visible layout far more reliably than "write several sections".
|
|
147
|
+
*
|
|
148
|
+
* @param {string} question
|
|
149
|
+
* @param {object} [opts]
|
|
150
|
+
* @param {'short'|'long'} [opts.detail='long']
|
|
151
|
+
* @param {string} [opts.language] "Sinhala", "Tamil", … (default: the language of the query)
|
|
152
|
+
* @param {string} [opts.instructions] extra guidance ("focus on Sri Lanka")
|
|
153
|
+
* @param {Date} [opts.now]
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
static prompt(question, { detail = 'long', language = '', instructions = '', now = new Date() } = {}) {
|
|
157
|
+
const topic = String(question ?? '').trim();
|
|
158
|
+
const today = WebAnswer._isoDate(now);
|
|
159
|
+
const extras = [];
|
|
160
|
+
if (String(language ?? '').trim()) extras.push(`Write the entire answer in ${String(language).trim()}.`);
|
|
161
|
+
if (String(instructions ?? '').trim()) extras.push(String(instructions).trim());
|
|
162
|
+
const extra = extras.length ? `\n\nAdditional instructions: ${extras.join(' ')}` : '';
|
|
163
|
+
|
|
164
|
+
const formatting =
|
|
165
|
+
'WhatsApp formatting only: *bold* with single asterisks, _italic_ with underscores, "1." numbered lists. ' +
|
|
166
|
+
'No markdown headers (#), no double asterisks, no tables.';
|
|
167
|
+
const conduct =
|
|
168
|
+
'Do not add notes about training data, knowledge cut-offs or being unable to browse the web, and do not ' +
|
|
169
|
+
'describe yourself or your origins — just present the findings. Never reply with a one-word command.';
|
|
170
|
+
|
|
171
|
+
if (WebAnswer.detailOf(detail) === 'short') {
|
|
172
|
+
return (
|
|
173
|
+
'Use your web search tool to look up the topic below, then answer directly in 2 to 4 sentences ' +
|
|
174
|
+
'with the most important current facts (figures, names, dates).\n\n' +
|
|
175
|
+
`Topic: ${topic}\nToday's date: ${today}\n\n` +
|
|
176
|
+
`Rules:\n1. ${formatting}\n` +
|
|
177
|
+
'2. End with a line that says exactly "Sources:" followed by the pages you used, one per line, ' +
|
|
178
|
+
'written as "title (url)".\n' +
|
|
179
|
+
`3. ${conduct}` +
|
|
180
|
+
extra
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
'Use your web search tool to research the topic below, then write a detailed, well-organised report ' +
|
|
186
|
+
'on it for a WhatsApp reader.\n\n' +
|
|
187
|
+
`Topic: ${topic}\nToday's date: ${today}\n\n` +
|
|
188
|
+
'Required layout — follow it exactly, replacing every <placeholder> (do not print the angle brackets):\n\n' +
|
|
189
|
+
'<One or two sentences that directly answer or introduce the topic.>\n\n' +
|
|
190
|
+
'*<Section heading 1>:*\n' +
|
|
191
|
+
'1. *<Headline>*: <Two sentences with specific facts, names, figures and dates from your search.>\n' +
|
|
192
|
+
'2. *<Headline>*: <…>\n' +
|
|
193
|
+
'3. *<Headline>*: <…>\n' +
|
|
194
|
+
'4. *<Headline>*: <…>\n\n' +
|
|
195
|
+
'*<Section heading 2>:*\n' +
|
|
196
|
+
'1. *<Headline>*: <…>\n' +
|
|
197
|
+
'2. *<Headline>*: <…>\n' +
|
|
198
|
+
'3. *<Headline>*: <…>\n' +
|
|
199
|
+
'4. *<Headline>*: <…>\n\n' +
|
|
200
|
+
'*<Section heading 3>:*\n' +
|
|
201
|
+
'1. *<Headline>*: <…>\n' +
|
|
202
|
+
'2. *<Headline>*: <…>\n' +
|
|
203
|
+
'3. *<Headline>*: <…>\n\n' +
|
|
204
|
+
'Sources:\n' +
|
|
205
|
+
'<Page title> (<url>)\n' +
|
|
206
|
+
'<Page title> (<url>)\n' +
|
|
207
|
+
'<Page title> (<url>)\n\n' +
|
|
208
|
+
'Rules:\n' +
|
|
209
|
+
'1. Write 3 to 5 sections with 3 to 4 numbered points each — about 300 to 450 words in total. ' +
|
|
210
|
+
'Never stop after one paragraph.\n' +
|
|
211
|
+
'2. Choose headings that fit the topic. For the topic "coffee" they could be *Recent Coffee News:*, ' +
|
|
212
|
+
'*Coffee Trends:* and *Other Coffee News:*; for a price or exchange-rate question, *Current Rate:*, ' +
|
|
213
|
+
'*Recent Movement:* and *What Is Driving It:*; for a person or company, *Latest News:*, *Background:* ' +
|
|
214
|
+
'and *Key Facts:*.\n' +
|
|
215
|
+
'3. Every point must carry concrete, current information (numbers, names, places, dates) — no filler ' +
|
|
216
|
+
'and no repetition.\n' +
|
|
217
|
+
`4. ${formatting}\n` +
|
|
218
|
+
'5. The "Sources:" list comes last, one page per line, at least 3 pages if you can.\n' +
|
|
219
|
+
`6. ${conduct}` +
|
|
220
|
+
extra
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Split a formatted reply into the prose answer and the sources it listed.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} text output of ResponseFormatter.format()
|
|
228
|
+
* @returns {{ text: string, sources: Array<{title:string|null,url:string|null,description:string|null}> }}
|
|
229
|
+
*/
|
|
230
|
+
static parse(text) {
|
|
231
|
+
let body = WebAnswer.stripDisclaimers(WebAnswer.dropPlaceholders(text));
|
|
232
|
+
const sources = [];
|
|
233
|
+
|
|
234
|
+
// 1) Inline "(Source: url)" citations.
|
|
235
|
+
body = body.replace(INLINE_CITATION, (_m, urls) => {
|
|
236
|
+
for (const url of String(urls).split(/\s*[,;]\s*/)) {
|
|
237
|
+
if (url) sources.push(WebAnswer._source(null, url));
|
|
238
|
+
}
|
|
239
|
+
return '';
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// 2) A trailing sources block.
|
|
243
|
+
const lines = body.split('\n');
|
|
244
|
+
let cut = -1;
|
|
245
|
+
let listed = null;
|
|
246
|
+
|
|
247
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
248
|
+
const line = lines[i];
|
|
249
|
+
if (HEADING.test(line)) {
|
|
250
|
+
const block = WebAnswer._parseBlock(lines.slice(i + 1));
|
|
251
|
+
if (block) {
|
|
252
|
+
cut = i;
|
|
253
|
+
listed = block;
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
const inline = line.match(HEADING_INLINE);
|
|
258
|
+
if (inline) {
|
|
259
|
+
const items = WebAnswer._parseInline(inline[1]);
|
|
260
|
+
if (!items.length) {
|
|
261
|
+
// "Sources: general knowledge" / "Sources: none" — a
|
|
262
|
+
// placeholder, not a list. Drop the line, keep the answer.
|
|
263
|
+
if (!/https?:\/\//i.test(inline[1]) && WebAnswer._cleanTitle(inline[1].replace(/[.,;]+$/, '')) == null) {
|
|
264
|
+
cut = i;
|
|
265
|
+
listed = [];
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
const block = WebAnswer._parseBlock(lines.slice(i + 1));
|
|
270
|
+
if (block) {
|
|
271
|
+
cut = i;
|
|
272
|
+
listed = [...items, ...block];
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (cut === -1) {
|
|
279
|
+
// No heading: accept a run of unmistakable link lines at the very end.
|
|
280
|
+
let i = lines.length - 1;
|
|
281
|
+
const tail = [];
|
|
282
|
+
for (; i >= 0; i--) {
|
|
283
|
+
const line = lines[i].trim();
|
|
284
|
+
if (!line) continue;
|
|
285
|
+
const hit = WebAnswer._parseLine(line, { strict: true });
|
|
286
|
+
if (!hit) break;
|
|
287
|
+
tail.unshift(hit);
|
|
288
|
+
}
|
|
289
|
+
if (tail.length) {
|
|
290
|
+
cut = i + 1;
|
|
291
|
+
listed = tail;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (cut !== -1) {
|
|
296
|
+
body = lines.slice(0, cut).join('\n');
|
|
297
|
+
sources.push(...listed);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
body = body
|
|
301
|
+
.replace(/[ \t]+$/gm, '')
|
|
302
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
303
|
+
.trim();
|
|
304
|
+
|
|
305
|
+
return { text: body, sources: WebAnswer.mergeSources(sources) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Remove `<placeholder>` tokens a model copied from the layout template,
|
|
310
|
+
* and any list line that is left with nothing but its marker.
|
|
311
|
+
*/
|
|
312
|
+
static dropPlaceholders(text) {
|
|
313
|
+
const input = String(text ?? '');
|
|
314
|
+
if (!input.includes('<')) return input;
|
|
315
|
+
return input
|
|
316
|
+
.split('\n')
|
|
317
|
+
.map((line) => {
|
|
318
|
+
PLACEHOLDER.lastIndex = 0;
|
|
319
|
+
if (!PLACEHOLDER.test(line)) return line;
|
|
320
|
+
const stripped = line.replace(PLACEHOLDER, '').replace(/[ \t]+$/, '');
|
|
321
|
+
return SCAFFOLD_LINE.test(stripped) ? null : stripped;
|
|
322
|
+
})
|
|
323
|
+
.filter((line) => line !== null)
|
|
324
|
+
.join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Words in a reply (markup and list markers excluded). */
|
|
328
|
+
static wordCount(text) {
|
|
329
|
+
const words = String(text ?? '')
|
|
330
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
331
|
+
.replace(MARK_ONLY_LINE, '')
|
|
332
|
+
.replace(/[*_~`]/g, ' ')
|
|
333
|
+
.match(/[\p{L}\p{N}]+(?:['’][\p{L}]+)?/gu);
|
|
334
|
+
return words ? words.length : 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Follow-up turn sent when the long-form answer came back too short.
|
|
339
|
+
* @param {string} question
|
|
340
|
+
* @param {number} words how long the first attempt was
|
|
341
|
+
*/
|
|
342
|
+
static expandPrompt(question, words) {
|
|
343
|
+
return (
|
|
344
|
+
`Your reply was only ${words} words and did not follow the required layout. Rewrite it in full now for ` +
|
|
345
|
+
`the topic "${String(question ?? '').trim()}": one or two intro sentences, then 3 to 5 sections — each a ` +
|
|
346
|
+
'bold *Heading:* line followed by 3 to 4 numbered points written as *Headline*: two sentences of specific, ' +
|
|
347
|
+
'current facts — at least 300 words in total, then the "Sources:" list with one "title (url)" per line. ' +
|
|
348
|
+
'Use your web search tool for current details. Output only the report: no apology, no preamble, no notes ' +
|
|
349
|
+
'about your abilities or training data.'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Remove "I'm a language model / I can't browse the web" sentences. */
|
|
354
|
+
static stripDisclaimers(text) {
|
|
355
|
+
const input = String(text ?? '');
|
|
356
|
+
if (!input.trim()) return '';
|
|
357
|
+
let out = input.replace(DISCLAIMER, '');
|
|
358
|
+
if (out !== input) {
|
|
359
|
+
// "…can't browse the web. However, here is…" -> "Here is…"
|
|
360
|
+
out = out.replace(/^\s+/, '');
|
|
361
|
+
const dangling = out.match(DANGLING_CONJUNCTION);
|
|
362
|
+
if (dangling) {
|
|
363
|
+
const rest = out.slice(dangling[0].length);
|
|
364
|
+
out = rest.charAt(0).toUpperCase() + rest.slice(1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out
|
|
368
|
+
.replace(/[ \t]+$/gm, '')
|
|
369
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
370
|
+
.trim();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* De-duplicate by URL (ignoring scheme, `www.`, trailing slash and hash),
|
|
375
|
+
* keeping the first title/description seen for each. Title-only entries
|
|
376
|
+
* merge into a linked entry with the same title.
|
|
377
|
+
*
|
|
378
|
+
* @param {...Array<{title?:string,url?:string,description?:string}>} lists
|
|
379
|
+
*/
|
|
380
|
+
static mergeSources(...lists) {
|
|
381
|
+
const byKey = new Map();
|
|
382
|
+
const byTitle = new Map();
|
|
383
|
+
const out = [];
|
|
384
|
+
|
|
385
|
+
for (const list of lists) {
|
|
386
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
387
|
+
if (!item || typeof item !== 'object') continue;
|
|
388
|
+
const url = item.url ? WebAnswer.cleanUrl(item.url) : null;
|
|
389
|
+
const title = WebAnswer._cleanTitle(item.title);
|
|
390
|
+
if (!url && !title) continue;
|
|
391
|
+
|
|
392
|
+
const key = url ? WebAnswer.urlKey(url) : null;
|
|
393
|
+
const titleKey = title ? title.toLowerCase() : null;
|
|
394
|
+
const existing = (key && byKey.get(key)) || (titleKey && byTitle.get(titleKey)) || null;
|
|
395
|
+
|
|
396
|
+
if (existing) {
|
|
397
|
+
if (!existing.title && title) existing.title = title;
|
|
398
|
+
if (!existing.url && url) {
|
|
399
|
+
existing.url = url;
|
|
400
|
+
byKey.set(key, existing);
|
|
401
|
+
}
|
|
402
|
+
if (!existing.description && item.description) existing.description = String(item.description);
|
|
403
|
+
if (titleKey && !byTitle.has(titleKey)) byTitle.set(titleKey, existing);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const entry = { title, url, description: item.description ? String(item.description) : null };
|
|
408
|
+
if (key) byKey.set(key, entry);
|
|
409
|
+
if (titleKey) byTitle.set(titleKey, entry);
|
|
410
|
+
out.push(entry);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Final WhatsApp text: the answer plus one *Sources:* block.
|
|
418
|
+
*
|
|
419
|
+
* @param {string} body
|
|
420
|
+
* @param {Array<{title?:string,url?:string}>} sources
|
|
421
|
+
* @param {object} [opts]
|
|
422
|
+
* @param {boolean} [opts.includeSources=true]
|
|
423
|
+
* @param {number} [opts.maxSources=5]
|
|
424
|
+
* @param {string} [opts.heading='Sources']
|
|
425
|
+
*/
|
|
426
|
+
static render(body, sources, { includeSources = true, maxSources = 5, heading = 'Sources' } = {}) {
|
|
427
|
+
const answer = String(body ?? '').trim();
|
|
428
|
+
const limit = Number.isFinite(maxSources) ? Math.max(0, Math.floor(maxSources)) : 5;
|
|
429
|
+
const lines = includeSources
|
|
430
|
+
? (Array.isArray(sources) ? sources : [])
|
|
431
|
+
.filter((s) => s && (s.url || s.title))
|
|
432
|
+
.slice(0, limit)
|
|
433
|
+
.map((s, i) => `${i + 1}. ${s.title && s.url ? `${s.title} — ${s.url}` : s.url || s.title}`)
|
|
434
|
+
: [];
|
|
435
|
+
if (!lines.length) return answer;
|
|
436
|
+
const block = `*${heading}:*\n${lines.join('\n')}`;
|
|
437
|
+
return answer ? `${answer}\n\n${block}` : block;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ------------------------------------------------------------ helpers --
|
|
441
|
+
|
|
442
|
+
/** Trim punctuation the model glues onto a URL. */
|
|
443
|
+
static cleanUrl(url) {
|
|
444
|
+
let out = String(url ?? '').trim();
|
|
445
|
+
out = out.replace(/[.,;:!?'"“”]+$/g, '');
|
|
446
|
+
// A trailing ")" with no matching "(" belongs to the sentence.
|
|
447
|
+
while (out.endsWith(')') && (out.match(/\(/g) || []).length < (out.match(/\)/g) || []).length) {
|
|
448
|
+
out = out.slice(0, -1);
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Comparison key for a URL. */
|
|
454
|
+
static urlKey(url) {
|
|
455
|
+
return String(url ?? '')
|
|
456
|
+
.trim()
|
|
457
|
+
.toLowerCase()
|
|
458
|
+
.replace(/^https?:\/\//, '')
|
|
459
|
+
.replace(/^www\./, '')
|
|
460
|
+
.replace(/#.*$/, '')
|
|
461
|
+
.replace(/\/+$/, '');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** @private */
|
|
465
|
+
static _isoDate(now) {
|
|
466
|
+
const d = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
467
|
+
return d.toISOString().slice(0, 10);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** @private */
|
|
471
|
+
static _cleanTitle(value) {
|
|
472
|
+
if (value == null) return null;
|
|
473
|
+
let title = String(value)
|
|
474
|
+
.replace(/^[\s*_~"'“”\[\]•\-–—:]+|[\s*_~"'“”\[\]:—–\-]+$/g, '')
|
|
475
|
+
.replace(/\s{2,}/g, ' ')
|
|
476
|
+
.trim();
|
|
477
|
+
title = title.replace(TITLE_TAIL, '').trim();
|
|
478
|
+
if (!title || LABEL_TITLE.test(title) || PLACEHOLDER_TITLE.test(title) || /^https?:\/\//i.test(title)) return null;
|
|
479
|
+
return title;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** @private */
|
|
483
|
+
static _source(title, url) {
|
|
484
|
+
return { title: WebAnswer._cleanTitle(title), url: url ? WebAnswer.cleanUrl(url) : null, description: null };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @private One list line → source, or null.
|
|
489
|
+
* `strict` (no heading above) refuses "Title https://…" without a real
|
|
490
|
+
* separator and prose-like titles, so a sentence that happens to end in a
|
|
491
|
+
* link is left in the answer.
|
|
492
|
+
*/
|
|
493
|
+
static _parseLine(line, { strict = false } = {}) {
|
|
494
|
+
const text = String(line ?? '').trim();
|
|
495
|
+
if (!text || !/https?:\/\//i.test(text)) return null;
|
|
496
|
+
|
|
497
|
+
let title = null;
|
|
498
|
+
let url = null;
|
|
499
|
+
let m;
|
|
500
|
+
if ((m = text.match(LINE.bareUrl))) {
|
|
501
|
+
url = m[1];
|
|
502
|
+
} else if ((m = text.match(LINE.titleParen))) {
|
|
503
|
+
[, title, url] = m;
|
|
504
|
+
} else if ((m = text.match(LINE.urlSep))) {
|
|
505
|
+
[, url, title] = m;
|
|
506
|
+
} else if ((m = text.match(LINE.titleSep))) {
|
|
507
|
+
[, title, url] = m;
|
|
508
|
+
} else if (!strict && (m = text.match(LINE.titleSpace))) {
|
|
509
|
+
[, title, url] = m;
|
|
510
|
+
} else {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (strict && title) {
|
|
515
|
+
const plain = title.replace(/[*_~]/g, '').trim();
|
|
516
|
+
const labelLike = MARK_ONLY.test(text) || /^[^.,;!?]{1,80}$/.test(plain);
|
|
517
|
+
if (!labelLike) return null;
|
|
518
|
+
}
|
|
519
|
+
return WebAnswer._source(title, url);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** @private "Title (url), Title (url)" or "url, url" after an inline heading. */
|
|
523
|
+
static _parseInline(rest) {
|
|
524
|
+
const items = [];
|
|
525
|
+
const pairRe = new RegExp(`([^,;()]{1,160}?)\\s*\\(\\s*(${URL})\\s*\\)`, 'gi');
|
|
526
|
+
let m;
|
|
527
|
+
while ((m = pairRe.exec(rest))) items.push(WebAnswer._source(m[1], m[2]));
|
|
528
|
+
if (items.length) return items;
|
|
529
|
+
const urlRe = new RegExp(URL, 'gi');
|
|
530
|
+
while ((m = urlRe.exec(rest))) items.push(WebAnswer._source(null, m[0]));
|
|
531
|
+
return items;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* @private Lines under a "Sources:" heading. Returns null when a prose
|
|
536
|
+
* line shows up (then it was not a sources block after all).
|
|
537
|
+
*/
|
|
538
|
+
static _parseBlock(lines) {
|
|
539
|
+
const sources = [];
|
|
540
|
+
let pendingTitle = null;
|
|
541
|
+
|
|
542
|
+
for (const raw of lines) {
|
|
543
|
+
const line = String(raw).trim();
|
|
544
|
+
if (!line) continue;
|
|
545
|
+
|
|
546
|
+
const hit = WebAnswer._parseLine(line);
|
|
547
|
+
if (hit) {
|
|
548
|
+
if (pendingTitle && !hit.title) {
|
|
549
|
+
hit.title = pendingTitle;
|
|
550
|
+
} else if (pendingTitle) {
|
|
551
|
+
sources.push(WebAnswer._source(pendingTitle, null));
|
|
552
|
+
}
|
|
553
|
+
pendingTitle = null;
|
|
554
|
+
sources.push(hit);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// A short, link-free line: the title for the URL on the next line,
|
|
559
|
+
// or a source the model named without linking it.
|
|
560
|
+
const bare = line.replace(MARK_ONLY, '');
|
|
561
|
+
if (!/https?:\/\//i.test(bare) && bare.length <= 120 && !/[.!?]$/.test(bare.replace(/[*_~)]+$/, ''))) {
|
|
562
|
+
if (pendingTitle) sources.push(WebAnswer._source(pendingTitle, null));
|
|
563
|
+
pendingTitle = WebAnswer._cleanTitle(bare);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
if (pendingTitle) sources.push(WebAnswer._source(pendingTitle, null));
|
|
569
|
+
return sources.filter((s) => s.url || s.title);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = WebAnswer;
|