feat(llm): chat UI renders the valuation table with FR provenance links (refs P48)
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;
|
||||||
}
|
}
|
||||||
|
table.valuation {
|
||||||
|
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.valuation th, table.valuation td {
|
||||||
|
border: 1px solid var(--border); padding: 4px 6px; text-align: right; white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.valuation th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||||
|
text-transform: uppercase; color: var(--muted-fg); }
|
||||||
|
table.valuation td:nth-child(-n+3), table.valuation th:nth-child(-n+3) { text-align: left; }
|
||||||
|
table.valuation tr.proposed td { font-style: italic; opacity: .85; }
|
||||||
|
.valuation-wrap { overflow-x: auto; }
|
||||||
|
.provenance { font-size: 12px; color: var(--muted-fg); margin-top: 4px; }
|
||||||
|
.provenance .cite { margin-right: 4px; }
|
||||||
details.sources { margin-top: 2px; font-size: 14px; }
|
details.sources { margin-top: 2px; font-size: 14px; }
|
||||||
details.sources > summary {
|
details.sources > summary {
|
||||||
cursor: pointer; color: var(--muted-fg); font-family: var(--font-mono);
|
cursor: pointer; color: var(--muted-fg); font-family: var(--font-mono);
|
||||||
@@ -177,6 +191,53 @@
|
|||||||
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VAL_COLS = [
|
||||||
|
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
|
||||||
|
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
|
||||||
|
['total_nf', 'Total NF'], ['total_f', 'Total F'], ['cf', 'CF'], ['pay_nf', 'Pay NF'], ['pay_f', 'Pay F'],
|
||||||
|
];
|
||||||
|
function fmt(key, v) {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
if (key === 'cf') return '$' + Number(v).toFixed(4);
|
||||||
|
if (key === 'pay_nf' || key === 'pay_f') return '$' + Number(v).toFixed(2);
|
||||||
|
if (typeof v === 'number') return v.toFixed(2);
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
function renderValuation(wrap, ev) {
|
||||||
|
const rows = ev.rows || [];
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'valuation-wrap';
|
||||||
|
if (rows.length) {
|
||||||
|
const t = document.createElement('table');
|
||||||
|
t.className = 'valuation';
|
||||||
|
const thead = t.createTHead().insertRow();
|
||||||
|
for (const [, name] of VAL_COLS) { const th = document.createElement('th'); th.textContent = name; thead.appendChild(th); }
|
||||||
|
const tb = t.createTBody();
|
||||||
|
for (const r of rows) {
|
||||||
|
const tr = tb.insertRow();
|
||||||
|
if (r.proposed) tr.className = 'proposed';
|
||||||
|
for (const [key] of VAL_COLS) tr.insertCell().textContent = fmt(key, r[key]);
|
||||||
|
}
|
||||||
|
box.appendChild(t);
|
||||||
|
}
|
||||||
|
const prov = document.createElement('div');
|
||||||
|
prov.className = 'provenance';
|
||||||
|
for (const p of ev.provenance || []) {
|
||||||
|
const c = document.createElement('span'); c.className = 'cite'; c.textContent = p.label;
|
||||||
|
prov.appendChild(c);
|
||||||
|
const a = document.createElement('a'); a.href = p.url; a.target = '_blank'; a.rel = 'noopener';
|
||||||
|
a.textContent = p.citation || p.vintage;
|
||||||
|
prov.appendChild(a);
|
||||||
|
prov.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
if ((ev.unpriced || []).length) {
|
||||||
|
prov.appendChild(document.createTextNode('not priced: ' + ev.unpriced.join(', ')));
|
||||||
|
}
|
||||||
|
box.appendChild(prov);
|
||||||
|
wrap.appendChild(box);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSources(wrap, ev) {
|
function renderSources(wrap, ev) {
|
||||||
const sources = ev.sources || [];
|
const sources = ev.sources || [];
|
||||||
if (ev.model) {
|
if (ev.model) {
|
||||||
@@ -252,6 +313,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 === '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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ class TestIndex:
|
|||||||
assert "Library Chat" in r.text
|
assert "Library Chat" in r.text
|
||||||
assert 'id="form"' in r.text
|
assert 'id="form"' in r.text
|
||||||
|
|
||||||
|
def test_serves_chat_page_with_valuation_renderer(self):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
html = r.text
|
||||||
|
assert "function renderValuation" in html
|
||||||
|
assert "ev.type === 'valuation'" in html
|
||||||
|
assert "table.valuation" 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