Skip to content

Commit a8de3ca

Browse files
feat(review): polish review handoff surface
Make Run Story entries status and timestamp aware, add clearer Review empty states, and provide a Markdown download path for handoff previews before external routing. Verification: make lint; venv/bin/python -m pytest tests/test_review_ui.py -q; make test (281 passed); venv/bin/python tests/ui_smoke.py; make browser-e2e-fresh; python3 -m json.tool ops/roadmap/features.json; git diff --check.
1 parent ce8f93b commit a8de3ca

9 files changed

Lines changed: 130 additions & 36 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ WhisperForge is now a single-user voice-to-knowledge workbench:
3535
- Open GitHub issues: none (`#41`, `#42`, `#43`, `#45`, `#46`, `#47`, and
3636
`#48` are shipped).
3737
- Open GitHub PRs: none.
38-
- Current unit baseline: `279 passed`.
38+
- Current unit baseline: `281 passed`.
3939
- Audio repo consolidation audit:
4040
[`docs/AUDIO-REPO-CONSOLIDATION-AUDIT-2026-05-18.md`](docs/AUDIO-REPO-CONSOLIDATION-AUDIT-2026-05-18.md).
4141
Next round plan:
@@ -123,7 +123,7 @@ useful in real sessions.
123123
| `wf-export-readiness-refresh` | P0 | Refresh scorecard/handoff readiness signals after export events | Shipped in `#47` |
124124
| `wf-transcription-router` | P1 | Implement provider router from transcription matrix | Shipped in `#48` |
125125
| `wf-demo-fixture-pack` | P0 | Expand the presentation demo fixture pack | Shipped; browser smokes stay credential-free |
126-
| `wf-review-polish` | P0 | Polish the Review tab for presentation and daily use | Rendered UI smoke covers labels/behavior |
126+
| `wf-review-polish` | P0 | Polish the Review tab for presentation and daily use | Shipped; rendered UI smoke covers labels/behavior |
127127
| `wf-kb-governance` | P1 | Add KB governance and profile-pack review workflow | Human confirms private/stale policy |
128128
| `wf-router-media-normalization` | P1 | Add fixture-backed media normalization for the transcription router | Runtime defaults preserved |
129129
| `wf-digest-approved-routing` | P1 | Add human-approved routing for resurfacing digests | Explicit approval remains required |

STATUS.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Last updated: 2026-05-21
4646
-> `0`.
4747
- `python3 -m json.tool ops/roadmap/features.json` passes.
4848
- `make lint` passes dependency-light Python syntax checks.
49-
- `make test` -> `279 passed`.
49+
- `make test` -> `281 passed`.
5050
- `make browser-e2e` -> `browser-e2e: OK`.
5151
- `make browser-e2e-fresh` -> `browser-e2e-fresh: OK`.
5252
- `make eval-fixture` passes editorial and SongForge fixtures.
@@ -107,15 +107,13 @@ Last updated: 2026-05-21
107107

108108
## Next Round
109109

110-
Dogfood closeout, first router slice, Run Story/Review extraction, and the demo
111-
fixture pack are complete. The next roadmap slice should stay focused on
112-
presentation polish and local-first reliability:
110+
Dogfood closeout, first router slice, Run Story/Review extraction, the demo
111+
fixture pack, and Review polish are complete. The next roadmap slice should
112+
stay focused on local-first reliability:
113113

114-
1. `wf-review-polish` - make the Review tab more legible, status-aware, and
115-
copy-friendly.
116-
2. `wf-kb-governance` - define canonical, stale, private, and ignored KB
114+
1. `wf-kb-governance` - define canonical, stale, private, and ignored KB
117115
context so agents do not over-trust messy source material.
118-
3. `wf-router-media-normalization` - add fixture-backed FFmpeg/media
116+
2. `wf-router-media-normalization` - add fixture-backed FFmpeg/media
119117
normalization before changing transcription defaults.
120-
4. `wf-digest-approved-routing` - route resurfacing digests only after explicit
118+
3. `wf-digest-approved-routing` - route resurfacing digests only after explicit
121119
approval.

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
a completed article/handoff run, a SongForge creative-pack run, and a
2828
partial/failed run so cold-start demos have useful Runs-dialog material
2929
without live credentials.
30+
- **Review tab polish** — Run Story steps now show readable statuses and UTC
31+
timestamps, Review sections surface clearer empty states, and handoff previews
32+
can be downloaded as Markdown before any tracker routing.
3033
- **Import-folder capture helpers**`whisperforge_core.captures` can now
3134
import text/markdown notes and audio pointers from files or folders, dedupe
3235
obvious repeats, and ignore generated chunk/temp files.

docs/NEXT-ROUND-PLAN-2026-05-19.md

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ name remains WhisperForge.
1616
- Remote branches: only `origin/main`.
1717
- Legacy audio repositories are archived as historical pointers.
1818
- Release target: local-first personal workbench.
19-
- Current verification baseline: `make test` -> `279 passed`, plus lint, JSON,
19+
- Current verification baseline: `make test` -> `281 passed`, plus lint, JSON,
2020
rendered UI, browser, fixture, and Streamlit health smokes in normal closeout.
21-
- Latest polish slice: credential-free demo fixture pack with article/handoff,
22-
SongForge, and partial/failed runs.
21+
- Latest polish slice: Review tab status/empty-state polish plus handoff draft
22+
download.
2323

2424
## Product Direction
2525

@@ -37,7 +37,6 @@ the local loop feels boringly reliable.
3737

3838
| ID | Priority | Title | Why Now |
3939
| --- | --- | --- | --- |
40-
| `wf-review-polish` | P0 | Polish the Review tab for presentation and daily use | Review is now the trust surface; make it legible, calm, and copy-friendly. |
4140
| `wf-kb-governance` | P1 | Add KB governance and profile-pack review workflow | The KB is powerful enough to need explicit stale/private/canonical controls. |
4241
| `wf-router-media-normalization` | P1 | Add fixture-backed media normalization for the transcription router | Large audio/video intake is the next practical extraction bottleneck. |
4342
| `wf-digest-approved-routing` | P1 | Add human-approved routing for resurfacing digests | Resurfacing should become actionable without becoming autonomous spam. |
@@ -49,25 +48,18 @@ criteria and verification gates.
4948

5049
## Today/Tomorrow Execution Order
5150

52-
1. **Review tab polish**
53-
- Add status-aware Run Story rendering, timestamps where available, and
54-
compact empty/error states.
55-
- Add a copy-friendly handoff preview export/download path that works
56-
without GitHub or Linear credentials.
57-
- Keep UI tests focused on labels and behavior, not brittle styling.
58-
59-
2. **KB governance**
51+
1. **KB governance**
6052
- Turn audit warnings into reviewer actions: canonical, ignore, quarantine,
6153
needs-update.
6254
- Document the privacy/stale policy for humans and future agents.
6355
- Surface unresolved governance warnings before generation.
6456

65-
3. **Router media normalization**
57+
2. **Router media normalization**
6658
- Convert the provider matrix into fixture-backed FFmpeg/media inspection
6759
and chunk normalization behavior.
6860
- Preserve current runtime defaults until privacy/cost receipts are visible.
6961

70-
4. **Digest approved routing**
62+
3. **Digest approved routing**
7163
- Keep digest generation report-only by default.
7264
- Add explicit approval for local follow-up queue and Notion draft paths.
7365

docs/PRESENTATION-RUNBOOK-2026-05-19.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ make smoke
2626

2727
Expected now:
2828

29-
- `make test` -> `279 passed`
29+
- `make test` -> `281 passed`
3030
- fixture and UI/smoke checks pass
3131

3232
If Docker is running and you want services proof:
@@ -115,9 +115,9 @@ Use these to get actionable feedback:
115115

116116
## 5) Next Dev Slice (After Review)
117117

118-
1. Polish the Review tab: status-aware Run Story rendering, clearer empty/error
119-
states, and a copy-friendly handoff preview path.
120-
2. Add KB governance so stale/private/canonical profile context is visible
118+
1. Add KB governance so stale/private/canonical profile context is visible
121119
before generation.
122-
3. Add fixture-backed media normalization for large audio/video routing before
120+
2. Add fixture-backed media normalization for large audio/video routing before
123121
changing transcription defaults.
122+
3. Add approved digest routing only after the local follow-up/Notion destination
123+
rules are clear.

ops/roadmap/features.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@
784784
"phase": "Presentation Polish",
785785
"priority": "P0",
786786
"kind": "ux",
787-
"status": "planned",
787+
"status": "shipped",
788788
"acceptance": [
789789
"Run Story steps render with clear status, timestamps where available, and no layout jitter.",
790790
"Review, scorecard, receipt, and handoff sections have useful empty/error states.",
@@ -793,6 +793,7 @@
793793
],
794794
"verification": [
795795
"make test",
796+
"venv/bin/python -m pytest tests/test_review_ui.py -q",
796797
"venv/bin/python tests/ui_smoke.py",
797798
"make browser-e2e-fresh",
798799
"git diff --check"

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ services/
164164
└── frontend/Dockerfile builds root app.py with DEPLOY_MODE=services
165165
166166
shared/ cross-service config + X-API-Key auth
167-
tests/ 279 tests + health/rendered UI smokes
167+
tests/ 281 tests + health/rendered UI smokes
168168
prompts/<user>/ profile.yaml, prompt .md files, knowledge_base,
169169
personas, custom_prompts
170170
```

tests/test_review_ui.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Small helper tests for Review tab presentation polish."""
2+
3+
from ui import review
4+
5+
6+
def test_run_story_step_markdown_uses_readable_status_and_timestamp():
7+
rendered = review._run_story_step_markdown({
8+
"label": "Capture",
9+
"status": "complete",
10+
"detail": "Wispr Flow input: Morning kickoff",
11+
"timestamp": "2026-05-21T17:04:15Z",
12+
})
13+
14+
assert rendered == (
15+
"- **Capture** · `Complete` · 2026-05-21 17:04 UTC \n"
16+
" Wispr Flow input: Morning kickoff"
17+
)
18+
19+
20+
def test_handoff_preview_download_helpers_are_markdown_safe():
21+
preview = {
22+
"title": "Handoff: Tighten week-two demo cadence",
23+
"body": "## Context\n\nShip the smallest useful thing.",
24+
}
25+
26+
assert review._handoff_preview_filename(preview["title"]) == (
27+
"handoff-tighten-week-two-demo-cadence.md"
28+
)
29+
assert review._handoff_preview_markdown(preview).startswith(
30+
"# Handoff: Tighten week-two demo cadence\n\n## Context"
31+
)

ui/review.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import hashlib
66
import os
7+
import re
8+
from datetime import datetime, timezone
79
from typing import Optional
810

911
import streamlit as st
@@ -16,6 +18,14 @@
1618
from whisperforge_core import run_story as run_story_mod
1719
from whisperforge_core import scorecards as scorecards_mod
1820

21+
_RUN_STORY_STATUS_LABELS = {
22+
"active": "Active",
23+
"complete": "Complete",
24+
"error": "Error",
25+
"skipped": "Skipped",
26+
"waiting": "Waiting",
27+
}
28+
1929

2030
def render() -> None:
2131
s = st.session_state
@@ -58,6 +68,8 @@ def render() -> None:
5868
st.markdown("**Receipts**")
5969
for source in summary["sources"]:
6070
st.markdown(f"- {source}")
71+
else:
72+
st.caption("Receipts appear after capture, transcript, or retrieval metadata is recorded.")
6173
if summary["quotes"]:
6274
st.markdown("**Quotes / excerpts**")
6375
for item in summary["quotes"]:
@@ -111,8 +123,12 @@ def _scorecard_panel(scorecard: dict) -> None:
111123
st.markdown("**Scorecard**")
112124
st.metric("Verdict", scorecards_mod.compact_verdict(scorecard))
113125
st.caption("Advisory only; saves are not blocked.")
126+
dimensions = scorecard.get("dimensions", [])
127+
if not dimensions:
128+
st.caption("Detailed scorecard dimensions appear after a full review run.")
129+
return
114130
with st.expander("Scorecard details", expanded=False):
115-
for dimension in scorecard.get("dimensions", []):
131+
for dimension in dimensions:
116132
score = int(dimension.get("score", 0))
117133
st.progress(
118134
score / 100,
@@ -129,27 +145,60 @@ def _scorecard_panel(scorecard: dict) -> None:
129145
def _run_story_panel(s) -> None:
130146
run_id = s.get("run_id")
131147
if not run_id:
148+
st.caption("Run story appears after this output is linked to a run artifact.")
132149
return
133150
try:
134151
manifest = run_artifacts.load_manifest(run_id)
135152
except Exception:
153+
st.caption("Run story unavailable; the run manifest could not be read.")
136154
return
137155
if not manifest:
156+
st.caption("Run story unavailable; no manifest was found for this run.")
138157
return
139158

140159
story = run_story_mod.build_run_story(
141160
manifest,
142161
capture_metadata=captures_mod.run_metadata(s.get("capture_id")),
143162
)
144163
if not story:
164+
st.caption("Run story has no recorded steps yet.")
145165
return
146166

147167
st.markdown("**Run story**")
148168
st.caption(f"Run `{manifest.get('run_id') or run_id}`")
149169
for step in story:
150-
status = str(step.get("status") or "unknown").replace("_", " ")
151-
detail = step.get("detail") or ""
152-
st.markdown(f"- **{step.get('label', 'Step')}** · `{status}` \n {detail}")
170+
st.markdown(_run_story_step_markdown(step))
171+
172+
173+
def _run_story_step_markdown(step: dict[str, str]) -> str:
174+
label = step.get("label") or "Step"
175+
status = _run_story_status_label(step.get("status") or "")
176+
timestamp = _format_run_story_timestamp(step.get("timestamp") or "")
177+
detail = step.get("detail") or "No detail recorded."
178+
meta = f"`{status}`"
179+
if timestamp:
180+
meta = f"{meta} · {timestamp}"
181+
return f"- **{label}** · {meta} \n {detail}"
182+
183+
184+
def _run_story_status_label(status: str) -> str:
185+
normalized = str(status or "").replace("_", " ").strip().lower()
186+
return _RUN_STORY_STATUS_LABELS.get(normalized, normalized.title() or "Unknown")
187+
188+
189+
def _format_run_story_timestamp(value: str) -> str:
190+
raw = str(value or "").strip()
191+
if not raw:
192+
return ""
193+
candidate = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
194+
try:
195+
parsed = datetime.fromisoformat(candidate)
196+
except ValueError:
197+
return raw
198+
if parsed.tzinfo is None:
199+
parsed = parsed.replace(tzinfo=timezone.utc)
200+
parsed = parsed.astimezone(timezone.utc)
201+
return parsed.strftime("%Y-%m-%d %H:%M UTC")
153202

154203

155204
def _review_summary_from_state(s) -> dict:
@@ -226,6 +275,7 @@ def _handoff_panel() -> None:
226275
s = st.session_state
227276
sources = _handoff_sources(s)
228277
if not sources:
278+
st.caption("Handoff draft appears after an output, transcript, or capture is available.")
229279
return
230280
with st.expander("Agent handoff draft", expanded=False):
231281
st.caption(
@@ -264,10 +314,29 @@ def _handoff_panel() -> None:
264314
st.caption(f"Saved to `{preview['path']}`")
265315
else:
266316
st.caption("Preview only; start a run to persist the draft under run artifacts.")
317+
st.download_button(
318+
"Download draft",
319+
data=_handoff_preview_markdown(preview),
320+
file_name=_handoff_preview_filename(preview.get("title") or ""),
321+
mime="text/markdown",
322+
key="download_handoff_draft",
323+
use_container_width=True,
324+
)
267325
st.text_area("Preview", preview.get("body") or "", height=420, key="handoff_preview_body")
268326
_approval_panel(preview)
269327

270328

329+
def _handoff_preview_markdown(preview: dict) -> str:
330+
title = str(preview.get("title") or "WhisperForge handoff draft").strip()
331+
body = str(preview.get("body") or "").strip()
332+
return f"# {title}\n\n{body}\n"
333+
334+
335+
def _handoff_preview_filename(title: str) -> str:
336+
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(title or "").lower()).strip(".-")
337+
return f"{(slug[:80] or 'whisperforge-handoff-draft')}.md"
338+
339+
271340
def _approval_panel(preview: dict) -> None:
272341
s = st.session_state
273342
st.markdown("---")

0 commit comments

Comments
 (0)