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;
|
color: var(--muted-fg); padding: 4px 2px 0;
|
||||||
}
|
}
|
||||||
.valuation-wrap { overflow-x: auto; }
|
.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 { font-size: 12px; color: var(--muted-fg); margin-top: 4px; }
|
||||||
.provenance .cite { margin-right: 4px; }
|
.provenance .cite { margin-right: 4px; }
|
||||||
details.sources { margin-top: 2px; font-size: 14px; }
|
details.sources { margin-top: 2px; font-size: 14px; }
|
||||||
@@ -122,10 +138,15 @@
|
|||||||
}
|
}
|
||||||
button:hover:not(:disabled) { background: #253748; }
|
button:hover:not(:disabled) { background: #253748; }
|
||||||
button:disabled { opacity: .45; cursor: default; }
|
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;
|
display: flex; align-items: center; gap: 6px; font-size: 13px;
|
||||||
color: var(--muted-fg); white-space: nowrap;
|
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 a { color: var(--primary); text-decoration: underline dotted; }
|
||||||
.src .kind {
|
.src .kind {
|
||||||
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
|
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
|
||||||
@@ -153,6 +174,13 @@
|
|||||||
<form id="form">
|
<form id="form">
|
||||||
<textarea id="q" placeholder="e.g. What do commenters say about telehealth originating sites?" autofocus></textarea>
|
<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="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>
|
<button id="send" type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -195,6 +223,102 @@
|
|||||||
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
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 = [
|
const VAL_COLS = [
|
||||||
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
|
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
|
||||||
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
|
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
|
||||||
@@ -273,7 +397,8 @@
|
|||||||
if (ev.model) {
|
if (ev.model) {
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'meta';
|
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);
|
wrap.appendChild(meta);
|
||||||
}
|
}
|
||||||
if (!sources.length) return;
|
if (!sources.length) return;
|
||||||
@@ -322,11 +447,12 @@
|
|||||||
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;
|
||||||
|
const mode = document.getElementById('mode').value;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('chat', {
|
const resp = await fetch('chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 + ')');
|
if (!resp.ok) throw new Error('request failed (' + resp.status + ')');
|
||||||
const reader = resp.body.getReader();
|
const reader = resp.body.getReader();
|
||||||
@@ -343,6 +469,7 @@
|
|||||||
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); log.scrollTop = log.scrollHeight; }
|
||||||
|
else if (ev.type === 'lineage') renderLineage(wrap, ev);
|
||||||
else if (ev.type === 'valuation') renderValuation(wrap, ev);
|
else if (ev.type === 'valuation') renderValuation(wrap, ev);
|
||||||
else if (ev.type === 'sources') renderSources(wrap, ev);
|
else if (ev.type === 'sources') renderSources(wrap, ev);
|
||||||
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; }
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ class TestIndex:
|
|||||||
# a citation without a URL is not an empty <a href="">
|
# a citation without a URL is not an empty <a href="">
|
||||||
assert "if (p.url)" in html
|
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:
|
class TestWhoami:
|
||||||
def test_reads_forwarded_header(self):
|
def test_reads_forwarded_header(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user