AI progress: honest milestone bar from closed JSON contract units

Replace the char-count estimate (EST_OUTPUT_CHARS) with ContractProgress:
points are earned only when real output units complete — first token (5),
each closed contract key / array item (5-90), root closed (95), stored (100).
Reasoning/thinking streams no longer fake progress: the first thinking
delta fires the first-token milestone ('the model is alive') and holds.
Surface the job's tone through ProcessingJobOut/JobInfo so the bar can
label itself 'Summarizing dry wit…'. Test rewritten to pin the new
honest-thinking semantics.
This commit is contained in:
avi 2026-09-18 15:00:04 -05:00
commit 61f2124b1c
6 changed files with 197 additions and 42 deletions

View file

@ -93,7 +93,8 @@ async def _collect_reply(resp: httpx.Response, ticker) -> str:
Streaming answers are NDJSON (one JSON object per line, ``done`` on the
last); a server honoring stream=false returns one JSON body — both work.
The running character count feeds [ticker] for UI progress."""
The accumulated content *prefix* feeds [ticker], which derives progress
from completed JSON contract units only (see ContractProgress)."""
import json as _json
ctype = resp.headers.get("content-type", "")
@ -103,11 +104,10 @@ async def _collect_reply(resp: httpx.Response, ticker) -> str:
content = _json.loads(body)["message"]["content"]
except (ValueError, KeyError, TypeError) as e:
raise ProviderTransientError("Ollama sent an unreadable reply.") from e
ticker(len(content or ""))
ticker(content or "")
return content or ""
parts: list[str] = []
total = 0
async for line in resp.aiter_lines():
line = line.strip()
if not line:
@ -117,14 +117,15 @@ async def _collect_reply(resp: httpx.Response, ticker) -> str:
except ValueError:
continue
piece = (obj.get("message") or {}).get("content") or ""
# Like the openai_compat adapter: a thinking channel is real
# work and must move the progress ticker even when the server
# ignored think:false.
# Like the openai_compat adapter: a thinking channel means the
# model started (the ticker's first-token milestone) but is not
# contract output, so it moves nothing beyond that.
thinking = (obj.get("message") or {}).get("thinking") or ""
if piece or thinking:
if piece:
parts.append(piece)
total += len(piece) + len(thinking)
ticker(total)
ticker("".join(parts))
elif thinking:
ticker("")
if obj.get("done"):
break
content = "".join(parts)