feat(llm): timeline table, element diffs and guidance in the chat UI; mode selector (refs #691)
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user