From 61bceb329f1a4f15fc8e0d4c33df29d998bbf6c7 Mon Sep 17 00:00:00 2001 From: Morten Punnerud-Engelstad Date: Mon, 10 Aug 2026 15:31:10 +0200 Subject: [PATCH] Progress, a folded transcript, and links between the graph and the log Four things, all of them about being able to see what the run did. PROGRESS. A thin bar above the transcript, replacing the bare status line. It is deliberately not determinate: the number of steps is not known in advance, so a fraction would be inventing a denominator. It approaches a ceiling instead -- visible movement per step, never a false 'almost done' -- and turns green on success or red on failure, so a finished run cannot be mistaken for a stalled one. A FOLDED TRANSCRIPT. When the run ends the steps collapse behind one line, '6 thinking steps - click to show', between the question and the answer. That is the reading order you want: what you asked, how much thinking it took, what it concluded. The answer is never hidden. HOVER SHOWS WHERE IT WALKED. Hovering a node lights up the path that reached it, following the strongest incoming edge back toward the start -- the same notion of 'strongest' the server already draws. Leaving the node clears it. CLICK OPENS THE LOG. Clicking a node unfolds the transcript, scrolls to that step and marks it, with a close button on the step that folds everything back up. Step panels carry id='step-N' so the lookup is by id rather than by counting, and the final-answer node falls back to the answer panel since it has no step of its own. Also: the server now takes the next free port instead of dying on EADDRINUSE, which happens the moment you already have it running -- the most common case there is. It binds to test rather than asking whether the port is free, because a check followed by a separate bind has a race between the two. The port it actually chose is printed, loudly, even when you named one: silently landing somewhere else is worse than an error, because you go looking at the wrong URL. One existing test changed meaning rather than breaking: it waited for a step to be *visible*, and steps are no longer visible after a run finishes. It now waits for them to be attached. That is the test correctly catching a real behaviour change. 9 new browser tests covering all four, plus the port search. --- src/mpe_lkg/app.py | 39 ++++++- src/mpe_lkg/templates/index.html | 184 ++++++++++++++++++++++++++++++- tests/test_render.py | 90 ++++++++++++++- 3 files changed, 306 insertions(+), 7 deletions(-) diff --git a/src/mpe_lkg/app.py b/src/mpe_lkg/app.py index 7dc99e0..cfa0c13 100644 --- a/src/mpe_lkg/app.py +++ b/src/mpe_lkg/app.py @@ -10,6 +10,7 @@ import json import os import queue +import socket import threading from flask import Flask, Response, jsonify, render_template, request @@ -240,6 +241,31 @@ def worker(): ) +PORT_SEARCH_WINDOW = 20 + + +def find_free_port(host: str, first: int, window: int = PORT_SEARCH_WINDOW) -> int: + """The first free port at or above ``first``. + + Binds to test rather than asking whether the port is free: a "is it available" + check followed by a separate bind has a race between the two, and the common + case here -- you already have the app running -- is exactly when that race + matters. Binding and catching OSError leaves no gap. + """ + for candidate in range(first, first + window): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, candidate)) + except OSError: + continue + return candidate + raise OSError( + f"No free port between {first} and {first + window - 1} on {host}. " + f"Pass --port to choose another range." + ) + + def run(host: str | None = None, port: int | None = None, debug: bool | None = None) -> None: """Start the server, after saying whether the model backend is actually there.""" status = backends.health(backends.DEFAULT_BASE_URL) @@ -248,11 +274,20 @@ def run(host: str | None = None, port: int | None = None, debug: bool | None = N else: print(f"\n Ollama at {status['base_url']} — models: {', '.join(status['models'])}\n") + host = host or os.environ.get("LKG_HOST", "127.0.0.1") + wanted = port or int(os.environ.get("LKG_PORT", "5100")) + chosen = find_free_port(host, wanted) + if chosen != wanted: + # Said out loud even though it is a convenience: silently landing on a + # different port than the one you asked for is worse than an error, because + # you go looking at the wrong URL. + print(f" {wanted} is in use — serving on http://{host}:{chosen} instead\n") + # Bound to localhost with the debugger off by default. The previous default of # debug=True on 0.0.0.0 exposed the Werkzeug console to the whole network. app.run( - host=host or os.environ.get("LKG_HOST", "127.0.0.1"), - port=port or int(os.environ.get("LKG_PORT", "5100")), + host=host, + port=chosen, debug=os.environ.get("LKG_DEBUG", "") == "1" if debug is None else debug, threaded=True, ) diff --git a/src/mpe_lkg/templates/index.html b/src/mpe_lkg/templates/index.html index 81b9024..34c0e76 100644 --- a/src/mpe_lkg/templates/index.html +++ b/src/mpe_lkg/templates/index.html @@ -28,6 +28,29 @@ .notice { color: #7a5c00; background: #fff6d8; border: 1px solid #e0c356; padding: 6px 10px; margin-top: 8px; font-size: 13px; } #status { color: #666; font-size: 13px; min-height: 1.4em; margin-bottom: 10px; } + + /* Progress. A determinate bar would be a lie -- the number of steps is not + known in advance -- so it fills toward a soft ceiling and stops claiming + anything the moment the run ends. */ + #progress { margin: 8px 0 4px; } + #progress-bar { height: 4px; background: #eceae4; border-radius: 2px; overflow: hidden; } + #progress-bar span { display: block; height: 100%; width: 0; background: #2a78d6; + transition: width .45s ease-out; } + #progress[data-state="done"] #progress-bar span { background: #008300; } + #progress[data-state="error"] #progress-bar span { background: #c0392b; } + #progress[data-state="idle"] #progress-bar { visibility: hidden; } + + /* Collapsed summary: question, then how much thinking, then the answer. */ + .thinking-toggle { display: block; width: 100%; text-align: left; margin: 0 0 20px; + padding: 9px 12px; border: 1px solid #ddd; background: #f7f6f3; border-radius: 4px; + cursor: pointer; font: inherit; color: #52514e; } + .thinking-toggle:hover { background: #efeee9; } + .thinking-toggle b { color: #0b0b0b; } + #steps[hidden] { display: none; } + .step[data-focus="true"] { border-color: #2a78d6; box-shadow: 0 0 0 3px rgba(42,120,214,.14); } + .step-close { float: right; border: none; background: none; cursor: pointer; + font-size: 18px; line-height: 1; color: #7a7975; padding: 0 2px; margin: 0; } + .step-close:hover { color: #0b0b0b; } #models { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 13px; color: #52514e; margin: 8px 0 4px; } #models label { display: flex; align-items: center; gap: 5px; } @@ -53,13 +76,18 @@

Local Llama Knowledge Graph

+
-
+
+ +
+
+
@@ -70,6 +98,11 @@

Local Llama Knowledge Graph

const submit = document.getElementById('submit'); const downloadBtn = document.getElementById('download-img'); const response = document.getElementById('response'); + const thinking = document.getElementById('thinking'); + const steps = document.getElementById('steps'); + const answer = document.getElementById('answer'); + const progress = document.getElementById('progress'); + const progressBar = progress.querySelector('span'); const similar = document.getElementById('similar'); const errors = document.getElementById('errors'); const status = document.getElementById('status'); @@ -82,6 +115,7 @@

Local Llama Knowledge Graph

// EventSource running when you submitted again, so two reasoning runs raced // into the same panel. let eventSource = null; + let stepCount = 0; function initGraph() { network = new vis.Network(graphContainer, { nodes, edges }, { @@ -107,6 +141,14 @@

Local Llama Knowledge Graph

// magnify past 100% by default and a handful of nodes then sits in a // corner of a mostly empty canvas. network.on('stabilized', () => network.fit({ animation: false, maxZoomLevel: 3 })); + + // Hover: show where the reasoning walked to reach this step. + network.on('hoverNode', (params) => highlightPath(params.node)); + network.on('blurNode', () => highlightPath(null)); + // Click: open the log at that step. + network.on('click', (params) => { + if (params.nodes.length) focusStep(params.nodes[0]); + }); } initGraph(); @@ -153,6 +195,10 @@

Local Llama Knowledge Graph

function appendPanel(className, heading, data) { const div = document.createElement('div'); div.className = className; + // The node id the backend uses, so a click on the graph can find this + // panel by lookup instead of by counting. + if (data.step) div.id = `step-${data.step}`; + const h = document.createElement('h3'); h.textContent = heading; div.appendChild(h); @@ -174,7 +220,122 @@

Local Llama Knowledge Graph

data.path_data.strongest_path, data.path_data.path_weights, data.path_data.avg_similarity); div.appendChild(pathDiv); } - response.appendChild(div); + (className === 'step' ? steps : answer).appendChild(div); + } + + /* ---- progress ---------------------------------------------------------- */ + + function setProgress(state, fraction) { + progress.dataset.state = state; + if (fraction !== undefined) { + progressBar.style.width = `${Math.min(100, fraction * 100).toFixed(1)}%`; + } + } + + function stepProgress(n) { + // How many steps a run takes is not known in advance, so a determinate + // bar would be inventing a denominator. This approaches a ceiling + // instead: visible movement per step, never a false "almost done". + setProgress('running', 1 - Math.pow(0.78, n)); + } + + /* ---- collapse the thinking once the run is over ------------------------ */ + + function collapseThinking(count) { + if (!count) return; + thinking.hidden = false; + thinking.innerHTML = ''; + const button = document.createElement('button'); + button.className = 'thinking-toggle'; + button.type = 'button'; + const label = (expanded) => + `${expanded ? '▾' : '▸'} ${count} thinking step${count === 1 ? '' : 's'}` + + ` — click to ${expanded ? 'hide' : 'show'}`; + button.innerHTML = label(false); + button.setAttribute('aria-expanded', 'false'); + steps.hidden = true; + + button.addEventListener('click', () => { + const expanded = steps.hidden; + steps.hidden = !expanded; + button.innerHTML = label(expanded); + button.setAttribute('aria-expanded', String(expanded)); + }); + thinking.appendChild(button); + } + + function expandThinking() { + const button = thinking.querySelector('.thinking-toggle'); + if (button && steps.hidden) button.click(); + } + + /* ---- graph <-> log ----------------------------------------------------- */ + + function focusStep(nodeId) { + // Node ids are "Step3"; the panels are "step-3". + const n = String(nodeId).replace(/^Step/, ''); + expandThinking(); + const panel = document.getElementById(`step-${n}`) + || answer.querySelector('.final-answer'); + if (!panel) return; + + steps.querySelectorAll('[data-focus]').forEach(el => el.removeAttribute('data-focus')); + panel.dataset.focus = 'true'; + panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + if (!panel.querySelector('.step-close')) { + const close = document.createElement('button'); + close.className = 'step-close'; + close.type = 'button'; + close.title = 'Collapse the thinking again'; + close.textContent = '×'; + close.addEventListener('click', (e) => { + e.stopPropagation(); + panel.removeAttribute('data-focus'); + close.remove(); + const button = thinking.querySelector('.thinking-toggle'); + if (button && !steps.hidden) button.click(); + }); + panel.querySelector('h3').appendChild(close); + } + } + + function pathTo(nodeId) { + // Where the reasoning walked to get here: follow the strongest incoming + // edge back to the start. Cheap, and it is the same notion of "strongest" + // the server already draws. + const seen = new Set([nodeId]); + const walk = [nodeId]; + let current = nodeId; + for (let guard = 0; guard < 64; guard++) { + const incoming = edges.get({ + filter: e => (e.to === current || e.from === current) + }); + let best = null; + for (const edge of incoming) { + const other = edge.to === current ? edge.from : edge.to; + if (seen.has(other)) continue; + // Only walk backwards, toward earlier steps. + if (Number(String(other).replace(/^Step/, '')) >= Number(String(current).replace(/^Step/, ''))) continue; + if (!best || edge.value > best.value) best = { other, value: edge.value }; + } + if (!best) break; + seen.add(best.other); + walk.push(best.other); + current = best.other; + } + return walk; + } + + function highlightPath(nodeId) { + const onPath = new Set(nodeId ? pathTo(nodeId) : []); + nodes.update(nodes.get().map(n => ({ + id: n.id, + borderWidth: onPath.has(n.id) ? 4 : 2, + color: onPath.has(n.id) + ? { border: '#eb6834', background: '#f7b799' } + : null // null restores the default palette + }))); } function run() { @@ -182,12 +343,18 @@

Local Llama Knowledge Graph

if (!userQuery) return; if (eventSource) { eventSource.close(); eventSource = null; } - response.innerHTML = ''; + thinking.innerHTML = ''; + thinking.hidden = true; + steps.innerHTML = ''; + steps.hidden = false; + answer.innerHTML = ''; similar.innerHTML = ''; errors.innerHTML = ''; nodes.clear(); edges.clear(); setBusy(true); + stepCount = 0; + setProgress('running', 0.06); status.textContent = 'Connecting to the model…'; eventSource = new EventSource(`/query?query=${encodeURIComponent(userQuery)}`); @@ -196,11 +363,15 @@

Local Llama Knowledge Graph

const data = JSON.parse(event.data); if (data.type === 'step') { - status.textContent = `Step ${data.step}…`; + stepCount = data.step; + stepProgress(data.step); + status.textContent = `Step ${data.step}: ${data.title}`; appendPanel('step', `Step ${data.step}: ${data.title}`, data); if (data.graph) updateGraph(data.graph); } else if (data.type === 'final') { + setProgress('running', 0.97); + status.textContent = 'Writing the final answer…'; appendPanel('final-answer', 'Final Answer', data); if (data.graph) updateGraph(data.graph); @@ -225,15 +396,20 @@

Local Llama Knowledge Graph

} else if (data.type === 'error') { showError(data.message, data.hint); + setProgress('error', 1); status.textContent = ''; } else if (data.type === 'done') { const model = data.embedding ? `${data.embedding.model} (${data.embedding.dim}d)` : 'unknown'; status.textContent = `Done in ${data.total_time.toFixed(1)}s · ` + `${data.steps} steps · embeddings: ${model}`; + // Question and answer stay in view; the reasoning folds away into + // a single line you can open when you want it. + collapseThinking(data.steps || stepCount); } else if (data.type === 'done_stream') { setBusy(false); + if (progress.dataset.state !== 'error') setProgress('done', 1); eventSource.close(); eventSource = null; } diff --git a/tests/test_render.py b/tests/test_render.py index 1453c60..2869f9b 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -157,7 +157,9 @@ def test_enter_key_submits(self, page, tmp_path): page.goto(server.url) page.fill("#query", "capital of France") page.press("#query", "Enter") - page.wait_for_selector(".step", timeout=30_000) + # state="attached", not visible: once the run finishes the steps fold + # away behind the summary, so they exist without being on screen. + page.wait_for_selector(".step", state="attached", timeout=30_000) page.wait_for_function("() => !document.querySelector('#submit').disabled", timeout=30_000) assert page.locator(".step").count() >= 1 @@ -197,3 +199,89 @@ def test_shortened_step_is_marked_in_the_ui(self, page, tmp_path): with LiveServer([step("Long", "y" * 900)], tmp_path, repeat_last=True) as server: run_query(page, server) assert page.locator(".notice").count() >= 1 + + +class TestProgressAndCollapse: + """Fase D: progress while it runs, and a folded summary once it is done.""" + + def test_progress_bar_advances_and_finishes(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path, delay=0.3) as server: + page.goto(server.url) + page.fill("#query", "q") + page.click("#submit") + page.wait_for_function( + "() => document.querySelector('#progress').dataset.state === 'running'", timeout=10_000 + ) + page.wait_for_function( + "() => document.querySelector('#progress').dataset.state === 'done'", timeout=30_000 + ) + width = page.evaluate("() => document.querySelector('#progress-bar span').style.width") + assert width == "100%" + + def test_a_failed_run_does_not_report_success(self, page, tmp_path): + with LiveServer([], tmp_path) as server: # backend with nothing to say + run_query(page, server) + assert page.evaluate("() => document.querySelector('#progress').dataset.state") == "error" + + def test_thinking_folds_into_one_line(self, page, tmp_path): + """Question and answer stay in view; the reasoning collapses behind a count.""" + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + + toggle = page.locator(".thinking-toggle") + assert toggle.count() == 1 + assert "5 thinking steps" in toggle.inner_text() + assert page.locator("#steps").is_hidden() + # The answer is never hidden -- that is the point of folding. + assert page.locator(".final-answer").is_visible() + + def test_the_summary_expands_and_folds_again(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + page.click(".thinking-toggle") + assert page.locator("#steps").is_visible() + page.click(".thinking-toggle") + assert page.locator("#steps").is_hidden() + + +class TestGraphToLog: + def test_clicking_a_node_opens_that_step(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + page.wait_for_timeout(800) + # Click through vis.js's own event, which is what a real click triggers. + page.evaluate("() => network.emit('click', {nodes: ['Step3'], edges: []})") + + assert page.locator("#steps").is_visible(), "the log must open to show the step" + assert page.locator("#step-3[data-focus='true']").count() == 1 + + def test_the_close_button_folds_the_log_again(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + page.wait_for_timeout(800) + page.evaluate("() => network.emit('click', {nodes: ['Step2'], edges: []})") + page.click("#step-2 .step-close") + + assert page.locator("#steps").is_hidden() + assert page.locator("#step-2[data-focus='true']").count() == 0 + + def test_hovering_a_node_highlights_the_path_that_reached_it(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + page.wait_for_timeout(800) + page.evaluate("() => network.emit('hoverNode', {node: 'Step4'})") + highlighted = page.evaluate( + "() => nodes.get().filter(n => n.borderWidth === 4).map(n => n.id)" + ) + page.evaluate("() => network.emit('blurNode', {node: 'Step4'})") + cleared = page.evaluate("() => nodes.get().filter(n => n.borderWidth === 4).length") + + assert "Step4" in highlighted, "the hovered node is on its own path" + assert len(highlighted) > 1, "the path back to the start should light up too" + assert cleared == 0, "leaving the node must clear the highlight" + + def test_steps_carry_the_id_the_graph_looks_them_up_by(self, page, tmp_path): + with LiveServer(normal_script(), tmp_path) as server: + run_query(page, server) + ids = page.evaluate("() => [...document.querySelectorAll('#steps .step')].map(e => e.id)") + assert ids == [f"step-{i}" for i in range(1, len(ids) + 1)]