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
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:
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user