feat(llm): timeline table, element diffs and guidance in the chat UI; mode selector (refs #691)

This commit is contained in:
kert
2026-09-10 11:18:50 -04:00
parent a2490b4a06
commit 6fd2c43df9
2 changed files with 144 additions and 3 deletions

View File

@@ -82,6 +82,22 @@
color: var(--muted-fg); padding: 4px 2px 0;
}
.valuation-wrap { overflow-x: auto; }
table.lineage {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
}
table.lineage th, table.lineage td {
border: 1px solid var(--border); padding: 4px 6px; text-align: left; white-space: nowrap;
}
table.lineage th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); }
tr.unanchored td { color: var(--muted, #888); font-style: italic; }
.lineage-wrap { overflow-x: auto; margin-bottom: 6px; }
.lineage-wrap .caption { font-size: 12px; color: var(--muted-fg); margin-bottom: 2px; }
ul.elements, ul.guidance {
font-size: 12px; color: var(--muted-fg); margin: 6px 0 0; padding-left: 18px;
}
ul.elements li, ul.guidance li { margin: 2px 0; }
.lineage-wrap .elements-note { font-size: 11px; color: var(--muted-fg); font-style: italic; margin-top: 4px; }
.provenance { font-size: 12px; color: var(--muted-fg); margin-top: 4px; }
.provenance .cite { margin-right: 4px; }
details.sources { margin-top: 2px; font-size: 14px; }
@@ -122,10 +138,15 @@
}
button:hover:not(:disabled) { background: #253748; }
button:disabled { opacity: .45; cursor: default; }
form label.recent {
form label.recent, form label.mode {
display: flex; align-items: center; gap: 6px; font-size: 13px;
color: var(--muted-fg); white-space: nowrap;
}
form label.mode select {
font-family: var(--font-body); font-size: 13px;
background: var(--card); color: var(--foreground);
border: 1px solid var(--border); border-radius: 3px; padding: 3px 4px;
}
.src a { color: var(--primary); text-decoration: underline dotted; }
.src .kind {
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
@@ -153,6 +174,13 @@
<form id="form">
<textarea id="q" placeholder="e.g. What do commenters say about telehealth originating sites?" autofocus></textarea>
<label class="recent"><input type="checkbox" id="recent"> last 12 months</label>
<label class="mode">mode
<select id="mode">
<option value="auto" selected>auto</option>
<option value="timeline">timeline</option>
<option value="recent">recent</option>
</select>
</label>
<button id="send" type="submit">Send</button>
</form>
@@ -195,6 +223,102 @@
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
}
function renderLineage(wrap, ev) {
const events = ev.events || [];
const box = document.createElement('div');
box.className = 'lineage-wrap';
const cap = document.createElement('div');
cap.className = 'caption';
cap.textContent = 'Timeline for ' + (ev.codes || []).join(', ') +
' (' + (ev.families || []).join(', ') + ')';
box.appendChild(cap);
if (events.length) {
const t = document.createElement('table');
t.className = 'lineage';
const thead = t.createTHead().insertRow();
for (const name of ['Year', 'Event', 'Codes', 'Source']) {
const th = document.createElement('th'); th.textContent = name; thead.appendChild(th);
}
const tb = t.createTBody();
for (const e of events) {
const tr = tb.insertRow();
if (e.anchored === false) {
tr.className = 'unanchored';
tr.title = 'no FR/CPT paragraph within a year of this RVU-file event';
}
tr.insertCell().textContent = e.year;
const evCell = tr.insertCell();
let kindText = e.kind;
if ((e.from_codes || []).length || (e.to_codes || []).length) {
kindText += ' (' + (e.from_codes || []).join(', ') + ' → ' +
(e.to_codes || []).join(', ') + ')';
}
evCell.textContent = kindText;
if (e.note) evCell.title = e.note;
tr.insertCell().textContent = e.code;
const srcCell = tr.insertCell();
let cite;
if (e.url) {
cite = document.createElement('a');
cite.href = e.url; cite.target = '_blank'; cite.rel = 'noopener';
} else {
cite = document.createElement('span'); // an empty href reloads the chat
}
cite.textContent = '[' + e.label + ']';
srcCell.appendChild(cite);
}
box.appendChild(t);
}
const diffs = ev.element_diffs || [];
if (diffs.length) {
const ul = document.createElement('ul');
ul.className = 'elements';
for (const d of diffs) {
const li = document.createElement('li');
li.appendChild(document.createTextNode(
d.type + '=' + d.value + ' — in ' + (d.in_codes || []).join(', ') +
'; not in ' + (d.not_in_codes || []).join(', ') + ' '
));
const c = document.createElement('span');
c.className = 'cite'; c.textContent = '[' + d.label + ']';
li.appendChild(c);
ul.appendChild(li);
}
box.appendChild(ul);
}
const guidance = ev.guidance || [];
if (guidance.length) {
const ul = document.createElement('ul');
ul.className = 'guidance';
for (const g of guidance) {
const li = document.createElement('li');
let link;
if (g.url) {
link = document.createElement('a');
link.href = g.url; link.target = '_blank'; link.rel = 'noopener';
} else {
link = document.createElement('span');
}
link.textContent = g.locator + ' (' + g.kind.toUpperCase() + ')';
li.appendChild(link);
li.appendChild(document.createTextNode(' '));
const c = document.createElement('span');
c.className = 'cite'; c.textContent = '[' + g.label + ']';
li.appendChild(c);
ul.appendChild(li);
}
box.appendChild(ul);
}
if (ev.elements_note) {
const note = document.createElement('div');
note.className = 'elements-note';
note.textContent = ev.elements_note;
box.appendChild(note);
}
wrap.appendChild(box);
log.scrollTop = log.scrollHeight;
}
const VAL_COLS = [
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
@@ -273,7 +397,8 @@
if (ev.model) {
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = ev.model + ' @ ' + (ev.host || '').replace(/^https?:\/\//, '');
meta.textContent = ev.model + ' @ ' + (ev.host || '').replace(/^https?:\/\//, '') +
(ev.mode === 'timeline' ? ' · timeline' : '');
wrap.appendChild(meta);
}
if (!sources.length) return;
@@ -322,11 +447,12 @@
send.disabled = true;
const recent = document.getElementById('recent').checked;
const since = recent ? new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10) : null;
const mode = document.getElementById('mode').value;
try {
const resp = await fetch('chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, since })
body: JSON.stringify({ question, since, mode })
});
if (!resp.ok) throw new Error('request failed (' + resp.status + ')');
const reader = resp.body.getReader();
@@ -343,6 +469,7 @@
if (!line) continue;
const ev = JSON.parse(line);
if (ev.type === 'token') { answer += ev.text; paint(b, answer); log.scrollTop = log.scrollHeight; }
else if (ev.type === 'lineage') renderLineage(wrap, ev);
else if (ev.type === 'valuation') renderValuation(wrap, ev);
else if (ev.type === 'sources') renderSources(wrap, ev);
else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; }

View File

@@ -40,6 +40,20 @@ class TestIndex:
# a citation without a URL is not an empty <a href="">
assert "if (p.url)" in html
def test_serves_chat_page_with_lineage_renderer(self):
r = client.get("/")
assert r.status_code == 200
html = r.text
assert "function renderLineage" in html
assert "ev.type === 'lineage'" in html
assert "table.lineage" in html
assert 'id="mode"' in html
assert "tr.unanchored" in html
assert "ul.className = 'elements'" in html
assert "ul.className = 'guidance'" in html
# mode is sent to the API and echoed back on the sources meta line
assert "mode" in html and "ev.mode === 'timeline'" in html
class TestWhoami:
def test_reads_forwarded_header(self):