Research Agents: Lets you define research agents that chat and answer with citations from SEC filings, your documents, and the web, via Jaspers' research-store service.
| 1 | // What an answer's citations owe: every sentence that states something cites the passage it rests on, |
| 2 | // and every ref it cites is one a search gave. Pure, so the reading of an answer is tested. |
| 3 | // Headings, table rows, code, and short fragments state nothing to cite; a sentence saying the passages |
| 4 | // do not hold something is what the prompt asks for when they do not, and cites nothing. |
| 5 | |
| 6 | const CITE = /\[(p\d+)\]/g |
| 7 | const SAYS_NOT_FOUND = |
| 8 | /\b(no passage|not (?:find|found)|couldn['’]?t find|could not find|did not find|didn['’]?t find|do(?:es)? not (?:say|hold|mention|contain)|not in the (?:passages|filings|store))\b/i |
| 9 | const CLAIM_WORDS = 6 |
| 10 | |
| 11 | /** The sentences of an answer that state something and cite nothing. */ |
| 12 | export function uncited(answer: string): string[] { |
| 13 | const out: string[] = [] |
| 14 | let fenced = false |
| 15 | for (const raw of answer.split('\n')) { |
| 16 | const line = raw.trim() |
| 17 | if (line.startsWith('```')) { |
| 18 | fenced = !fenced |
| 19 | continue |
| 20 | } |
| 21 | if (fenced || !line || line.startsWith('#') || line.startsWith('|') || line.startsWith('>')) continue |
| 22 | const body = line.replace(/^(?:[-*+]|\d+[.)])\s+/, '') |
| 23 | for (const sentence of body.split(/(?<=[.!?])\s+(?=["“(*\p{Lu}\d])/u)) { |
| 24 | const plain = sentence.replace(/\*\*?|__?|`/g, '').trim() |
| 25 | if (CITE.test(sentence) || SAYS_NOT_FOUND.test(plain)) { |
| 26 | CITE.lastIndex = 0 |
| 27 | continue |
| 28 | } |
| 29 | CITE.lastIndex = 0 |
| 30 | const words = plain.split(/\s+/).filter(Boolean).length |
| 31 | if (words >= CLAIM_WORDS || (/\d/.test(plain) && words >= 3)) out.push(plain) |
| 32 | } |
| 33 | } |
| 34 | return out |
| 35 | } |
| 36 | |
| 37 | /** The refs an answer cites that no search gave. */ |
| 38 | export function unknownRefs(answer: string, known: Set<string>): string[] { |
| 39 | const out = new Set<string>() |
| 40 | for (const match of answer.matchAll(CITE)) if (!known.has(match[1]!)) out.add(match[1]!) |
| 41 | return [...out] |
| 42 | } |
| 43 | |
| 44 | /** What the model is told when its answer owes citations. */ |
| 45 | export function repairText(missing: string[], unknown: string[], refs: string[]): string { |
| 46 | const lines = [ |
| 47 | 'Every sentence that states a fact must cite the passage it rests on, like [p2]. This answer does not yet:', |
| 48 | ...missing.map((s) => `- Not cited: "${s}"`), |
| 49 | ...unknown.map((r) => `- [${r}] is not a passage you were given.`), |
| 50 | `Write the whole answer again. Cite each of these from the passages you have (${refs.join(', ') || 'none yet'}), search for what they rest on, or leave them out.`, |
| 51 | 'Reply with the answer alone, as the user will read it: say nothing about this rewrite, what you changed, or what you left out.', |
| 52 | ] |
| 53 | return lines.join('\n') |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * An answer as it is copied: its words, then the passages it cites in the order it first cites them, |
| 58 | * each with where it is from and its address, so a ref still says something once pasted elsewhere. |
| 59 | */ |
| 60 | export function withSources(answer: string, passages: { ref: string; label: string; url: string | null }[]): string { |
| 61 | const byRef = new Map(passages.map((p) => [p.ref, p])) |
| 62 | const cited = [...new Set([...answer.matchAll(CITE)].map((m) => m[1]!))].flatMap((ref) => byRef.get(ref) ?? []) |
| 63 | if (cited.length === 0) return answer.trim() |
| 64 | const lines = cited.map((p) => `[${p.ref}] ${p.label}${p.url ? ` ${p.url}` : ''}`) |
| 65 | return `${answer.trim()}\n\nSources\n${lines.join('\n')}` |
| 66 | } |