feat(llm): chat UI renders the valuation table with FR provenance links (refs P48)

This commit is contained in:
kert
2026-09-08 22:53:57 -04:00
parent 82541cf24c
commit 47717ae5de
2 changed files with 70 additions and 0 deletions

View File

@@ -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; }
} }

View File

@@ -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):