fix(llm): chat answers render markdown and cited [labels] become FR/eCFR jump links once the events carry their URLs (refs #691)
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Successful in 1m28s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 2m35s
Infra CI / zotero (push) Successful in 23s
Infra CI / docs (push) Successful in 1m24s
Infra CI / api (push) Successful in 1m5s
Infra CI / llm (push) Successful in 47s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 14s
CI / test (push) Failing after 14m11s

This commit is contained in:
kert
2026-09-10 15:30:53 -04:00
parent 6ed7ae5e4d
commit d8f667898d
2 changed files with 116 additions and 10 deletions

View File

@@ -66,6 +66,20 @@
background: var(--secondary); padding: 1px 5px; border-radius: 2px; background: var(--secondary); padding: 1px 5px; border-radius: 2px;
white-space: nowrap; white-space: nowrap;
} }
/* A cite with a known paragraph URL is a jump link (FR ¶, eCFR §). */
.bot .bubble a.cite { text-decoration: underline dotted; cursor: pointer; }
.bot .bubble a.cite:hover { text-decoration: underline; }
/* Markdown blocks inside the answer bubble. */
.bot .bubble p { margin: 0 0 .6em; }
.bot .bubble p:last-child { margin-bottom: 0; }
.bot .bubble h3, .bot .bubble h4, .bot .bubble h5, .bot .bubble h6 {
font-family: var(--font-display, inherit); margin: .8em 0 .3em; line-height: 1.25;
}
.bot .bubble h3 { font-size: 1.1em; } .bot .bubble h4 { font-size: 1em; }
.bot .bubble h5, .bot .bubble h6 { font-size: .95em; }
.bot .bubble ul, .bot .bubble ol { margin: 0 0 .6em 1.2em; padding: 0; }
.bot .bubble li { margin: .15em 0; }
.bot .bubble code { font-family: var(--font-mono); font-size: .88em; }
table.valuation { table.valuation {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px; border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -209,20 +223,92 @@
} }
// Render answer text, wrapping [CMS-...] citations in styled spans. // Render answer text, wrapping [CMS-...] citations in styled spans.
function paint(el, text) { // Inline markdown + citations. Model text is never assigned to
el.innerHTML = ''; // innerHTML: every run becomes a text node or a created element.
const re = /\[([^\[\]\n]{2,90})\]/g; // `links` maps a bracketed label (as the model cites it) to the URL the
// lineage/valuation/sources events carried for it — an FR paragraph
// (federalregister.gov/…#p-NNNN), an eCFR section, a comment — so a
// cited [CY2017 PFS final 81 FR 80255 ¶1196] is a jump link, not a badge.
function paintInline(el, text, links) {
const re = /\[([^\[\]\n]{2,90})\]|\*\*([^*\n]+)\*\*|`([^`\n]+)`/g;
let last = 0, m; let last = 0, m;
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index))); if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
const c = document.createElement('span'); if (m[1] !== undefined) {
const url = links && links[m[1]];
let c;
if (url) {
c = document.createElement('a');
c.href = url; c.target = '_blank'; c.rel = 'noopener';
} else {
c = document.createElement('span');
}
c.className = 'cite'; c.textContent = m[0]; c.className = 'cite'; c.textContent = m[0];
el.appendChild(c); el.appendChild(c);
} else if (m[2] !== undefined) {
const s = document.createElement('strong'); s.textContent = m[2]; el.appendChild(s);
} else {
const code = document.createElement('code'); code.textContent = m[3]; el.appendChild(code);
}
last = m.index + m[0].length; last = m.index + m[0].length;
} }
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last))); if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
} }
// Block markdown: headings (# … ######), bullet and numbered lists,
// paragraphs. The model writes markdown; showing raw "## History" was
// the bug this replaces.
function paint(el, text, links) {
el.innerHTML = '';
const lines = text.split('\n');
let para = [], list = null;
const flushPara = () => {
if (!para.length) return;
const p = document.createElement('p');
paintInline(p, para.join(' '), links);
el.appendChild(p); para = [];
};
const flushList = () => { list = null; };
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
const h = /^(#{1,6})\s+(.*)$/.exec(line);
const li = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
if (h) {
flushPara(); flushList();
const level = Math.min(h[1].length + 2, 6); // # → h3 inside a bubble
const hd = document.createElement('h' + level);
paintInline(hd, h[2], links); el.appendChild(hd);
} else if (li) {
flushPara();
const ordered = /^\s*\d/.test(line);
if (!list || list.tagName !== (ordered ? 'OL' : 'UL')) {
list = document.createElement(ordered ? 'ol' : 'ul'); el.appendChild(list);
}
const item = document.createElement('li');
paintInline(item, li[1], links); list.appendChild(item);
} else if (!line.trim()) {
flushPara(); flushList();
} else {
flushList(); para.push(line);
}
}
flushPara();
}
// label → url for this turn, filled by the lineage/valuation/sources
// events; the answer is repainted whenever the map grows.
function collectLinks(links, ev) {
const add = (label, url) => { if (label && url && !links[label]) links[label] = url; };
if (ev.type === 'lineage') {
for (const e of ev.events || []) add(e.label, e.url);
for (const g of ev.guidance || []) { add(g.label, g.url); add(g.locator, g.url); }
} else if (ev.type === 'valuation') {
for (const p of ev.provenance || []) add(p.label, p.url);
} else if (ev.type === 'sources') {
for (const s of ev.sources || []) add(s.label, s.url);
}
}
function renderLineage(wrap, ev) { function renderLineage(wrap, ev) {
const events = ev.events || []; const events = ev.events || [];
const box = document.createElement('div'); const box = document.createElement('div');
@@ -444,6 +530,7 @@
bubble('user').b.textContent = question; bubble('user').b.textContent = question;
const { wrap, b } = bubble('bot'); const { wrap, b } = bubble('bot');
let answer = ''; let answer = '';
const links = {}; // label → url, see collectLinks
send.disabled = true; send.disabled = true;
const recent = document.getElementById('recent').checked; const recent = document.getElementById('recent').checked;
const since = recent ? new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10) : null; const since = recent ? new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10) : null;
@@ -468,10 +555,10 @@
const line = part.replace(/^data: /, '').trim(); const line = part.replace(/^data: /, '').trim();
if (!line) continue; if (!line) continue;
const ev = JSON.parse(line); const ev = JSON.parse(line);
if (ev.type === 'token') { answer += ev.text; paint(b, answer); log.scrollTop = log.scrollHeight; } if (ev.type === 'token') { answer += ev.text; paint(b, answer, links); log.scrollTop = log.scrollHeight; }
else if (ev.type === 'lineage') renderLineage(wrap, ev); else if (ev.type === 'lineage') { collectLinks(links, ev); renderLineage(wrap, ev); }
else if (ev.type === 'valuation') renderValuation(wrap, ev); else if (ev.type === 'valuation') { collectLinks(links, ev); renderValuation(wrap, ev); }
else if (ev.type === 'sources') renderSources(wrap, ev); else if (ev.type === 'sources') { collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links); }
else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; } else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; }
} }
} }

View File

@@ -54,6 +54,25 @@ class TestIndex:
# mode is sent to the API and echoed back on the sources meta line # mode is sent to the API and echoed back on the sources meta line
assert "mode" in html and "ev.mode === 'timeline'" in html assert "mode" in html and "ev.mode === 'timeline'" in html
def test_serves_chat_page_with_markdown_and_cite_links(self):
"""The answer is painted as markdown (headings/lists/bold, never
raw "## …"), and a cited [label] whose URL arrived on the
lineage/valuation/sources events is an <a class="cite"> jump
link to the FR paragraph / eCFR section; the answer is repainted
when the sources event completes the label→url map."""
html = client.get("/").text
assert "function paintInline" in html
assert "function paint(el, text, links)" in html
assert "function collectLinks" in html
assert "a.cite" in html
assert (
"collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links)"
in html
)
assert "document.createElement('h' + level)" in html
# model text never reaches innerHTML
assert "innerHTML = ''" in html and "innerHTML = text" not in html
class TestWhoami: class TestWhoami:
def test_reads_forwarded_header(self): def test_reads_forwarded_header(self):