From 11326b8c075126c966f1012935321646e829e545 Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 30 Sep 2026 06:34:47 +0200 Subject: [PATCH 1/2] chore: ralph(447-qwen) green progress at iteration 2 model=koishi/qwen3.8-flash-next-mtp gate='python3 -m pytest tests --ignore=tests/e2e && ruff check tapscribe tests && ruff format --check tapscribe tests && node --test "tapscribe/web/js/**/*.test.js" && node tapscribe/web/tools/check-conventions.mjs && node tapscribe/web/tools/check-slots.mjs && node tapscribe/web/tools/check-css-vars.mjs && ( cd frontend && npm run typecheck )' --- CONTEXT.md | 9 +++ .../0026-transcript-clocks-name-their-zone.md | 55 +++++++++++++++ tapscribe/session_merge.py | 35 ++++++++-- tapscribe/web/js/formatters.js | 54 ++++++++++++++- tapscribe/web/js/formatters.test.js | 26 +++++++ tapscribe/web/js/next/views/transcript.js | 68 +++++++++++-------- .../web/js/next/views/transcript.test.js | 40 ++++++++++- tests/e2e/test_pipeline_hallucinations.py | 14 ++-- tests/test_batch_summarize.py | 30 ++++++++ tests/test_session_merge.py | 52 ++++++++++++++ 10 files changed, 338 insertions(+), 45 deletions(-) create mode 100644 docs/adr/0026-transcript-clocks-name-their-zone.md diff --git a/CONTEXT.md b/CONTEXT.md index 0839531b..bdf38342 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -117,6 +117,15 @@ were in effect. `source_language` records what the model was *told* to expect Post-processors (`hallucinations.apply`) consume one and return a new one via `dataclasses.replace`, never mutating in place. +## Zoned clock + +A transcript clock stamp carrying the UTC offset of the instant it shows +(`HH:MM:SS±HH:MM`), so the line reads apart from where it was rendered. The +shared rule behind the merged transcript's two renderings: text artefacts are +zoned, each naming its own zone, while an on-screen pane renders bare wall +time in the viewer's zone, which is the context there. +_Avoid_: "UTC stamp" (true only of the server's artefact). ADR-0026. + ## Candidate languages · language pin The operator's declaration of **which languages to expect** in a recording — diff --git a/docs/adr/0026-transcript-clocks-name-their-zone.md b/docs/adr/0026-transcript-clocks-name-their-zone.md new file mode 100644 index 00000000..9cf575e1 --- /dev/null +++ b/docs/adr/0026-transcript-clocks-name-their-zone.md @@ -0,0 +1,55 @@ +--- +status: accepted +date: 2026-09-30 +--- + +# Transcript clocks name their zone; on-screen panes stay bare + +One merged transcript renders two ways. The server writes `plain_text` +(`session_merge.render_transcript_text`) into `session-transcript.txt` and into +the summarizer's stdin. The browser builds the clipboard copy and the `.txt` +download (`transcript.buildCopyText`, which the download reuses byte for byte). +Both showed a bare `HH:MM:SS`, and that is ambiguous the moment the text leaves +the screen it was read on: a model reads the transcript and quotes those times +into notes nobody reads beside a clock (#438). + +The rule, stated once in each renderer's docstring and cross-named: **a +rendered transcript line's clock names the zone it is in; a line on screen +needs none.** Text artefacts carry `HH:MM:SS±HH:MM`, each naming its own zone. +The merged pane, the live feed and the recordings spans keep bare `HH:MM:SS` +in the viewer's zone, which is the context there. See +[Zoned clock](../../CONTEXT.md#zoned-clock). + +Nothing is threaded, because each renderer already holds the instant's zone. +`_clock` reads `datetime.utcoffset()` and preserves whatever offset a stored +ISO carries, since `parse_iso` never converts and merge instants are UTC. +`formatters.fmtClockZ` resolves ICU's per-instant `longOffset` under a pinned +`en-US` locale, so the artefact bytes are stable ASCII in every viewer +locale, and DST is the instant's, not today's. A naive stamp is `+00:00`, the +`parse_iso` naive-is-UTC convention. +An unreadable stamp stays `??:??:??` (JS `?`): an unknown time cannot name a +zone. + +## Consequences + +**Old stored bodies stay bare until re-merged.** `batch_summarize._summary_input` +and `buildCopyText` fall back to the stored `plain_text`, so a transcript +merged before this change renders as stored. That is honest old data, not +wrong data, and re-merging the session rewrites the artefact zoned. + +**`fmtSessionLabel` stays bare UTC.** It labels a session from the id's own +slice, not a transcript line, so the rule does not reach it. It is the +remaining bare UTC spelling in the UI. + +## Considered options + +**Local everywhere** (render each artefact in the viewer's zone). It needs a +viewer timezone threaded into the summarize request, and +`session-transcript.txt` is written by batch jobs that have no viewer. There +is no local for the server. + +**UTC everywhere** (convert every clock). The pane would then disagree with +every other clock in the UI and with the wall clock the meeting happened on. + +**A `times are UTC` header.** It evaporates the moment a line is excerpted or +quoted, which is exactly the flow that surfaced #438. diff --git a/tapscribe/session_merge.py b/tapscribe/session_merge.py index e6fc9e51..7614c25c 100644 --- a/tapscribe/session_merge.py +++ b/tapscribe/session_merge.py @@ -306,21 +306,40 @@ def to_dict(self) -> dict[str, Any]: _LOW_CONFIDENCE_LOGPROB_THRESHOLD = -0.5 +def _offset_label(utcoffset: timedelta | None) -> str: + """`±HH:MM` for an instant's UTC offset; `None` (a naive stamp) is `+00:00` + — the `parse_iso` naive-is-UTC convention.""" + if utcoffset is None: + return "+00:00" + total_minutes = int(utcoffset.total_seconds()) // 60 + hours, minutes = divmod(abs(total_minutes), 60) + return f"{'-' if total_minutes < 0 else '+'}{hours:02d}:{minutes:02d}" + + def _clock(abs_start: Any) -> str: - """`HH:MM:SS` from either a datetime (mid-merge) or its ISO string (read - back off `session-transcript.json`). + """The zoned artefact clock `HH:MM:SS±HH:MM` from either a datetime + (mid-merge) or its ISO string (read back off `session-transcript.json`). + + A rendered transcript line's clock names the zone it is in; a line on + screen needs none (its pane is the context). The stamp carries the + instant's own offset and never converts it — `parse_iso` preserves whatever + offset a stored ISO carries, and merge instants are UTC. The JS mirror for + the browser-side artefacts is `formatters.fmtClockZ`. An unreadable stamp renders `??:??:??` rather than raising: `parse_iso` raises on malformed input, and this runs over a file on disk, so one bad segment would fail a whole summarize instead of costing one line its clock. + An unknown time cannot name a zone. """ if isinstance(abs_start, datetime): - return abs_start.strftime("%H:%M:%S") + return f"{abs_start.strftime('%H:%M:%S')}{_offset_label(abs_start.utcoffset())}" try: dt = parse_iso(str(abs_start or "")) except ValueError: return "??:??:??" - return dt.strftime("%H:%M:%S") if dt else "??:??:??" + if dt is None: + return "??:??:??" + return f"{dt.strftime('%H:%M:%S')}{_offset_label(dt.utcoffset())}" def render_transcript_text( @@ -328,7 +347,13 @@ def render_transcript_text( *, names: Mapping[str, str] | None = None, ) -> str: - """The merged transcript as `[HH:MM:SS] Speaker: text` lines. + """The merged transcript as `[HH:MM:SS±HH:MM] Speaker: text` lines. + + The clock is ZONED — each line names the zone its instant is in, because + this text leaves its rendering context (stored file, summarizer input). + A line on screen needs no zone; its pane is the context. The JS mirror + for the browser-side artefacts is `formatters.fmtClockZ` — one rule, + stated at both (ADR-0026). The ONE spelling of that format on this side of the wire: `merge_session` builds `plain_text` through it, and `batch_summarize` re-renders the stored diff --git a/tapscribe/web/js/formatters.js b/tapscribe/web/js/formatters.js index 837913d9..be937f2e 100644 --- a/tapscribe/web/js/formatters.js +++ b/tapscribe/web/js/formatters.js @@ -1,7 +1,8 @@ // @ts-check // Pure formatters used across the dashboard — the APP layer next to the // vendored toolkit formatters (lib/format.js): fmtClock renders viewer-zone -// wall clock via its own module-scope Intl formatter (see its docstring); the +// wall clock via its own module-scope Intl formatter (see its docstring), and +// fmtClockZ names that zone for text artefacts (see its docstring); the // dense terminal-style number/duration forms (fmtDur, fmtMs, fmtMmSs, // fmtBytes) are a deliberate Stages aesthetic and stay hand-rolled. // No DOM dependency, no shared state — safe to unit-test in isolation. @@ -67,6 +68,57 @@ const CLOCK_FMT = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); +/** Zoned artefact clock: `HH:MM:SS±HH:MM` — the clock of a transcript line + * that LEAVES its rendering context (clipboard copy, .txt download, the + * stored file). A rendered transcript line's clock names the zone it is in; + * a line on screen needs none — the pane's zone is the context. The + * server-side twin is `session_merge._clock` (ADR-0026). The locale is + * pinned to "en-US": an artefact must carry the stable ASCII spelling, and + * the viewer locale would localise it (da-DK writes the offset `GMT+01.00`, + * fr-FR `UTC+01:00`, ar-EG localises the digits too). The READING is the + * same wall time `fmtClock` shows the pane. + * The offset is resolved PER INSTANT, so DST is the instant's, not today's. + * `timeZone` is optional: tests pass it for deterministic literals, the app + * omits it for the viewer's zone. + * @param {string | null | undefined} iso + * @param {string} [timeZone] */ +export function fmtClockZ(iso, timeZone) { + if (!iso) return "?"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "?"; + let hour = "00"; + let minute = "00"; + let second = "00"; + let offset = "+00:00"; + for (const p of clockZFormatter(timeZone).formatToParts(d)) { + if (p.type === "hour") hour = p.value; + else if (p.type === "minute") minute = p.value; + else if (p.type === "second") second = p.value; + else if (p.type === "timeZoneName") offset = p.value === "GMT" ? "+00:00" : p.value.replace(/^GMT/, ""); + } + return `${hour}:${minute}:${second}${offset}`; +} +/** One formatter per zone, built once — the same per-call cost argument + * CLOCK_FMT's docstring makes: constructed per zone once, resolved per + * instant. The viewer-zone default lives under the "" key. */ +const CLOCK_Z_FMTS = new Map(); +/** @param {string} [timeZone] */ +function clockZFormatter(timeZone) { + const key = timeZone || ""; + let fmt = CLOCK_Z_FMTS.get(key); + if (!fmt) { + // "en-US", not undefined: a viewer locale localises the longOffset part + // (da-DK `GMT+01.00`, fr-FR `UTC+01:00`), corrupting the artefact bytes. + fmt = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + timeZoneName: "longOffset", + ...(timeZone ? { timeZone } : {}), + }); + CLOCK_Z_FMTS.set(key, fmt); + } + return fmt; +} + /** * Elapsed seconds → compact "m:ss" (90 → "1:30"). Distinct from `fmtDur` * (human "1m 30.0s") and `fmtClock` (ISO wall-clock slice): this is the diff --git a/tapscribe/web/js/formatters.test.js b/tapscribe/web/js/formatters.test.js index 1071221e..66216f3b 100644 --- a/tapscribe/web/js/formatters.test.js +++ b/tapscribe/web/js/formatters.test.js @@ -24,6 +24,7 @@ import { fmtDur, fmtMs, fmtClock, + fmtClockZ, fmtMmSs, truncMid, fmtSessionLabel, @@ -122,6 +123,31 @@ test("fmtClock renders a parseable instant as hh:mm:ss", () => { assert.match(fmtClock("2026-05-12T09:19:55Z"), /^\d{2}:\d{2}:55$/); }); +// ── fmtClockZ (the zoned artefact clock) ── +// The optional zone parameter is what lets these pin LITERAL strings on any +// runner: no `process.env.TZ` games, no env pollution across `node --test` +// per-file processes. The offset is the INSTANT's — resolved per instant, so +// DST is the instant's, not today's. + +test("fmtClockZ stamps the instant's own UTC offset", () => { + assert.equal(fmtClockZ("2026-05-12T12:05:12Z", "UTC"), "12:05:12+00:00"); + // Half-hour zones keep their minutes. + assert.equal(fmtClockZ("2026-05-12T12:05:12Z", "Asia/Kolkata"), "17:35:12+05:30"); +}); + +test("fmtClockZ resolves the DST offset of the instant, not of today", () => { + assert.equal(fmtClockZ("2026-01-12T12:05:12Z", "America/New_York"), "07:05:12-05:00"); + assert.equal(fmtClockZ("2026-07-12T12:05:12Z", "America/New_York"), "08:05:12-04:00"); +}); + +test("fmtClockZ returns ? for missing or unparseable input", () => { + // Never "?+00:00" — an unknown time cannot name a zone. + assert.equal(fmtClockZ(null), "?"); + assert.equal(fmtClockZ(undefined), "?"); + assert.equal(fmtClockZ(""), "?"); + assert.equal(fmtClockZ("not-a-timestamp"), "?"); +}); + // ---- fmtMmSs ------------------------------------------------------------- test("fmtMmSs renders floored m:ss with a zero-padded seconds field", () => { diff --git a/tapscribe/web/js/next/views/transcript.js b/tapscribe/web/js/next/views/transcript.js index 7aad91fc..bd07767e 100644 --- a/tapscribe/web/js/next/views/transcript.js +++ b/tapscribe/web/js/next/views/transcript.js @@ -30,7 +30,7 @@ import { import { createFilesSource, listState } from "../session-files.js"; import { resolveSeekTarget } from "../seek-target.js"; import { wireSave, runSaveWithStatus, statusTarget } from "../../save-status.js"; -import { fmtBytes, fmtClock, fmtDur, fmtMs, truncMid } from "../../formatters.js"; +import { fmtBytes, fmtClockZ, fmtDur, fmtMs, truncMid } from "../../formatters.js"; import { aliasOf } from "../../speakers.js"; import { header, strong, inline, buildSourceToggle, renderJobBar, effectiveSource, setSourcePick, sessionLabel } from "../shell.js"; import { makeStatusFlasher, copyToClipboard, downloadFile, setText, showTextForManualCopy } from "../ui.js"; @@ -130,7 +130,7 @@ function* aliasedSegments(full, meta) { * with a NaN stamp. NaN would latch into `tZero` (NaN !== null), poisoning * every later `absStart - tZero`, so ONE corrupt value in a hand-edited or * truncated sidecar would garble the whole document — the same invariant - * fmtClock states for the copy path ("a single corrupt sidecar value must + * fmtClockZ states for the copy path ("a single corrupt sidecar value must * garble one cell, never abort the entire render"). A parseable start with a * corrupt end keeps its line, as a zero-length cue. * @@ -159,6 +159,35 @@ export function buildExportSegments(full, meta) { return segments; } +/** + * The COPY TEXT — the bytes behind the clipboard copy AND the .txt download + * (they reuse this one function, so the two exports can never disagree). + * Rebuild from segments so display-name aliases match what the user sees — + * the backend's `plain_text` uses raw speaker keys. Which segments qualify + * and what a speaker is called is the shared aliasedSegments walk (the same + * line set the .srt/.vtt exports get); this function owns only the LINE + * format: "[HH:MM:SS±HH:MM] Alias: text" — the zoned artefact clock, because + * a copy leaves the pane and must name its own zone (ADR-0026; the server's + * stored artefact spells it the same way, `session_merge._clock`) — the + * "[uncertain]" suffix on low-confidence lines, and a fallback to plain_text + * when no segments produced a line. + * + * Pure — module-scope (not a build() closure local) so the unit tests can + * reach this slice's own trap without driving a browser. + * @param {import('../../types.js').MergedTranscript} full + * @param {import('../../types.js').EffectiveMeta} meta + * @returns {string} + */ +export function buildCopyText(full, meta) { + const lines = []; + for (const { seg, text, speaker } of aliasedSegments(full, meta)) { + let line = `[${fmtClockZ(seg.abs_start)}] ${speaker}: ${text}`; + if (seg.low_confidence) line += " [uncertain]"; + lines.push(line); + } + return lines.join("\n") || full.plain_text || ""; +} + /** * @param {{ * metaFor: (s: import('../../types.js').Session) => import('../../types.js').EffectiveMeta, @@ -596,28 +625,9 @@ export function build(ctx) { }); // ---- Copy merged transcript (ported from classic main.js onCopyMerged) ---- - - // Rebuild the export text from segments so display-name aliases match what - // the user sees — the backend's `plain_text` uses raw speaker keys. Which - // segments qualify and what a speaker is called is the shared aliasedSegments - // walk (the same line set the .srt/.vtt exports get); this function owns only - // the LINE format: "[hh:mm:ss] Alias: text", "[uncertain]" suffix on - // low-confidence lines, an ABSOLUTE wall clock. Falls back to plain_text when - // no segments produced a line. - /** - * @param {import('../../types.js').MergedTranscript} full - * @param {import('../../types.js').EffectiveMeta} meta - * @returns {string} - */ - const buildCopyText = (full, meta) => { - const lines = []; - for (const { seg, text, speaker } of aliasedSegments(full, meta)) { - let line = `[${fmtClock(seg.abs_start)}] ${speaker}: ${text}`; - if (seg.low_confidence) line += " [uncertain]"; - lines.push(line); - } - return lines.join("\n") || full.plain_text || ""; - }; + // The line format lives in module-scope `buildCopyText` (shared with the + // .txt download, so the bytes stay identical); this section owns the + // controls around it. const flashCopyStatus = makeStatusFlasher(txCopyStatus); @@ -659,11 +669,11 @@ export function build(ctx) { }); }; - // .txt reuses buildCopyText, so the bytes are identical to the clipboard copy - // (not the backend's plain_text, which carries raw speaker keys). .srt/.vtt - // go through the relative-clock conversion; an empty cue list renders as "" - // rather than toVTT([])'s truthy header-only document, which is not a file - // worth handing the operator. + // .txt reuses buildCopyText, so its bytes — zone-stamped clocks included — + // are identical to the clipboard copy (not the backend's plain_text, which + // carries raw speaker keys). .srt/.vtt go through the relative-clock + // conversion; an empty cue list renders as "" rather than toVTT([])'s truthy + // header-only document, which is not a file worth handing the operator. wireExport(txDownloadTxt, ".txt", "text/plain;charset=utf-8", buildCopyText); wireExport(txDownloadSrt, ".srt", "application/x-subrip", (full, meta) => { const segments = buildExportSegments(full, meta); diff --git a/tapscribe/web/js/next/views/transcript.test.js b/tapscribe/web/js/next/views/transcript.test.js index 4259cbd8..ce492b96 100644 --- a/tapscribe/web/js/next/views/transcript.test.js +++ b/tapscribe/web/js/next/views/transcript.test.js @@ -2,14 +2,15 @@ // a selected file to its recording, and recordingVariants flattens a // recording's original + stripped-region cached transcripts into one tagged // list. Plus buildExportSegments, the subtitle-export clock: the merged -// schema's absolute ISO stamps -> the relative seconds toSRT/toVTT take. -// Node's built-in runner, no DOM — importing transcript.js is +// schema's absolute ISO stamps -> the relative seconds toSRT/toVTT take. And +// buildCopyText, the clipboard/.txt artefact: every line carries a zoned +// clock. Node's built-in runner, no DOM — importing transcript.js is // side-effect-free (it only references `document` inside functions). import { test } from "node:test"; import assert from "node:assert/strict"; -import { recordingFor, recordingVariants, buildExportSegments } from "./transcript.js"; +import { recordingFor, recordingVariants, buildExportSegments, buildCopyText } from "./transcript.js"; import { toSRT } from "../subtitles.js"; const wav = (name, transcripts = [], regions = []) => ({ @@ -168,3 +169,36 @@ test("buildExportSegments tolerates a body with no segments at all", () => { assert.deepEqual(buildExportSegments({ plain_text: "", suppressed: [] }, meta()), []); assert.deepEqual(buildExportSegments(merged([]), {}), []); }); + +// ---- buildCopyText: the clipboard/.txt artefact clock ---------------------- +// A copy leaves the pane, so its clock names its zone (#447): the line is +// `[HH:MM:SS±HH:MM] Alias: text`. The runner's zone is not a literal, so the +// SHAPE is pinned here — the offset must be there, whatever it resolves to — +// and the offset's own digits are pinned in formatters.test.js (fmtClockZ). + +test("buildCopyText stamps every line with a zone", () => { + const out = buildCopyText( + merged([ + seg("2025-02-01T09:00:00+00:00", "2025-02-01T09:00:03+00:00", "one"), + seg("2025-02-01T09:00:05+00:00", "2025-02-01T09:00:08+00:00", "two", "Spk1"), + ]), + meta({ Spk0: "Alice" }), + ); + const lines = out.split("\n"); + assert.equal(lines.length, 2); + assert.match(lines[0], /^\[\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\] Alice: one$/); + assert.match(lines[1], /^\[\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\] Spk1: two$/); +}); + +test("buildCopyText keeps the [uncertain] marker on low-confidence lines", () => { + const uncertain = { ...seg("2025-02-01T09:00:00+00:00", "2025-02-01T09:00:03+00:00", "one"), low_confidence: true }; + const out = buildCopyText(merged([uncertain]), meta({ Spk0: "Alice" })); + assert.match(out, /^\[\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\] Alice: one \[uncertain\]$/); +}); + +test("buildCopyText falls back to plain_text when no segment yields a line", () => { + // An old stored body (merged before the zoned clock) renders as stored — + // honest old data, not wrong data. + const body = { segments: [], plain_text: "[09:00:00] Alice: old body", suppressed: [] }; + assert.equal(buildCopyText(body, meta()), "[09:00:00] Alice: old body"); +}); diff --git a/tests/e2e/test_pipeline_hallucinations.py b/tests/e2e/test_pipeline_hallucinations.py index 91f7be55..db43531c 100644 --- a/tests/e2e/test_pipeline_hallucinations.py +++ b/tests/e2e/test_pipeline_hallucinations.py @@ -280,13 +280,13 @@ async def test_large_session_merge_preserves_all_speakers_in_order( assert len(segments_by_speaker[name]) == 2 # Chronological ordering: segments are sorted by abs_start (rendered - # as the HH:MM:SS prefix on each plain_text line). Per CONTEXT.md - # this is the merge's sort key, so a regression that re-orders by - # speaker or filename would surface here. We can't pin an exact - # streamed order — the filename timestamp resolution is one second - # so back-to-back WAVs share a wav_start and the same-second tie- - # break is filename-alphabetical, not stream-order. The load-bearing - # property is non-decreasing abs_start. + # as the HH:MM:SS±HH:MM prefix on each plain_text line, ADR-0026). + # Per CONTEXT.md this is the merge's sort key, so a regression that + # re-orders by speaker or filename would surface here. We can't pin + # an exact streamed order — the filename timestamp resolution is one + # second so back-to-back WAVs share a wav_start and the same-second + # tie-break is filename-alphabetical, not stream-order. The + # load-bearing property is non-decreasing abs_start. timestamps = [ line.split("]", 1)[0].lstrip("[") for line in merged["plain_text"].splitlines() diff --git a/tests/test_batch_summarize.py b/tests/test_batch_summarize.py index d77dc368..ed7404f7 100644 --- a/tests/test_batch_summarize.py +++ b/tests/test_batch_summarize.py @@ -482,3 +482,33 @@ async def test_summarize_reads_the_transcript_under_its_mapped_voice_names(recor assert "Them#B" not in summary # An undiarized tap is untouched by any of this. assert "Alice: Where are we on the migration?" in summary + + +async def test_summarize_input_clock_names_its_zone(recorder_under_test): + """The clock in the text the model reads names its zone (#447). + + The flow that surfaced this (#438) is a model reading and quoting these + times into notes nobody reads beside a clock — a bare `09:00:00` there is + an ambiguous instant. `_CAT` echoes stdin, so the summary IS the text + handed to the summarizer, zoned stamp and all.""" + rec = recorder_under_test.recordings_dir + sd = seed_merged_transcript(rec, "s") + transcript = json.loads((sd / "session-transcript.json").read_text(encoding="utf-8")) + transcript["segments"] = [ + { + "abs_start": "2026-01-01T09:00:00+00:00", + "abs_end": "2026-01-01T09:00:04+00:00", + "speaker": "Alice", + "text": "Where are we on the migration?", + "source_wav": "w.wav", + "low_confidence": False, + } + ] + (sd / "session-transcript.json").write_text(json.dumps(transcript), encoding="utf-8") + + out = await summarize_session( + recorder_under_test, + SummarizeSessionRequest(session="s", source="command", command=_CAT, prompt="Summarize"), + ) + + assert "[09:00:00+00:00] Alice: Where are we on the migration?" in out["summary"] diff --git a/tests/test_session_merge.py b/tests/test_session_merge.py index 46e754c4..84ff8b4b 100644 --- a/tests/test_session_merge.py +++ b/tests/test_session_merge.py @@ -16,8 +16,11 @@ from tapscribe.session_merge import ( SessionTranscript, merge_session, + render_transcript_text, select_session_wavs, + write_merged_transcript, ) +from tapscribe.session_paths import FILENAME_TRANSCRIPT_TXT from tapscribe.transcribers.base import TranscriptionResult, TranscriptionSegment from tapscribe.wav_cache import cached_transcribe, set_primary_transcript @@ -309,3 +312,52 @@ def test_merge_session_mixes_primaries_across_wavs(tmp_path: Path): transcript = merge_session(select_session_wavs(session_dir)) texts = [s.text for s in transcript.segments] assert texts == [f"FROM_B {wavs[0].name}", f"hello from {wavs[1].name}"] + + +# ── the zoned artefact clock (#447) ───────────────────────────────────────── +# A text artefact leaves its rendering context, so its clock must name the +# zone it is in: `[HH:MM:SS±HH:MM]`. The stamp carries the INSTANT's own +# offset and never converts it — the same convention `parse_iso` keeps for +# stored ISO. The JS mirror is `formatters.fmtClockZ`. + + +def _one_line(abs_start: object) -> str: + return render_transcript_text([{"abs_start": abs_start, "speaker": "Alice", "text": "hi"}]) + + +def test_clock_stamps_an_aware_utc_instant_with_its_offset(): + assert _one_line(datetime(2026, 5, 12, 12, 5, 12, tzinfo=UTC)) == "[12:05:12+00:00] Alice: hi" + + +def test_clock_names_the_offset_an_iso_string_already_carries(): + # The stamp names the zone it holds; it never converts to UTC. + assert _one_line("2026-05-12T14:05:12+02:00") == "[14:05:12+02:00] Alice: hi" + assert _one_line("2026-05-12T07:05:12-05:00") == "[07:05:12-05:00] Alice: hi" + + +def test_clock_renders_an_unknown_time_without_a_zone(): + # An unknown time cannot name a zone — the bare placeholder stays. + assert _one_line("not-a-timestamp") == "[??:??:??] Alice: hi" + assert _one_line(None) == "[??:??:??] Alice: hi" + + +def test_clock_treats_a_naive_stamp_as_utc(): + # The `parse_iso` naive-is-UTC convention: a naive stamp's zone is +00:00. + assert _one_line(datetime(2026, 5, 12, 12, 5, 12)) == "[12:05:12+00:00] Alice: hi" + assert _one_line("2026-05-12T12:05:12") == "[12:05:12+00:00] Alice: hi" + + +def test_merged_transcript_txt_on_disk_carries_the_zoned_clock(tmp_path: Path): + """The stored artefact, byte for byte: `write_merged_transcript` writes + `plain_text` verbatim, so the zoned stamp lands on disk, not just in the + in-memory merge.""" + session_dir = tmp_path / "s" + session_dir.mkdir() + (wav,) = _seed(session_dir, n=1) + cached_transcribe(wav, _FixedTranscriber(), initial_prompt=None, hotwords=None, hallucination_rules=[]) + + transcript = merge_session(select_session_wavs(session_dir)) + write_merged_transcript(session_dir, transcript) + + txt = (session_dir / FILENAME_TRANSCRIPT_TXT).read_text(encoding="utf-8") + assert txt == f"[09:19:55+00:00] alice: hello from {wav.name}" From 7d8b732da516a42e96fad2914d8d76defe2576ad Mon Sep 17 00:00:00 2001 From: Vortiago Date: Wed, 30 Sep 2026 06:58:59 +0200 Subject: [PATCH 2/2] fix(transcript): a transcript clock that leaves the screen names its zone (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server rendered the merged transcript clock in UTC and the browser in the viewer's zone, so the summary and session-transcript.txt disagreed with the pane and the .txt download by the viewer's offset. Text artefacts (session-transcript.txt, the summarizer input, the clipboard copy and the .txt download) now carry HH:MM:SS±HH:MM. The on-screen panes keep a bare HH:MM:SS in the viewer's zone. ADR-0026 records the rule. Closes #447 --- .../0026-transcript-clocks-name-their-zone.md | 18 +-- tapscribe/session_merge.py | 38 ++----- tapscribe/text.py | 6 +- tapscribe/web/components/next/views.html | 2 +- tapscribe/web/js/formatters.js | 103 +++++++----------- tapscribe/web/js/formatters.test.js | 34 +++++- tapscribe/web/js/next/views/transcript.js | 37 ++----- .../web/js/next/views/transcript.test.js | 24 +++- tests/e2e/test_pipeline_hallucinations.py | 2 +- tests/test_batch_summarize.py | 2 +- tests/test_session_merge.py | 7 +- 11 files changed, 129 insertions(+), 144 deletions(-) diff --git a/docs/adr/0026-transcript-clocks-name-their-zone.md b/docs/adr/0026-transcript-clocks-name-their-zone.md index 9cf575e1..5906950f 100644 --- a/docs/adr/0026-transcript-clocks-name-their-zone.md +++ b/docs/adr/0026-transcript-clocks-name-their-zone.md @@ -21,21 +21,23 @@ in the viewer's zone, which is the context there. See [Zoned clock](../../CONTEXT.md#zoned-clock). Nothing is threaded, because each renderer already holds the instant's zone. -`_clock` reads `datetime.utcoffset()` and preserves whatever offset a stored -ISO carries, since `parse_iso` never converts and merge instants are UTC. +`_clock` keeps whatever offset a datetime or a stored ISO carries, because +`parse_iso` never converts and merge instants are UTC. `formatters.fmtClockZ` resolves ICU's per-instant `longOffset` under a pinned `en-US` locale, so the artefact bytes are stable ASCII in every viewer -locale, and DST is the instant's, not today's. A naive stamp is `+00:00`, the -`parse_iso` naive-is-UTC convention. +locale, and DST is the instant's, not today's. Both sides read a naive stamp +as UTC, the `parse_iso` convention, so one stored stamp names one instant. An unreadable stamp stays `??:??:??` (JS `?`): an unknown time cannot name a zone. ## Consequences -**Old stored bodies stay bare until re-merged.** `batch_summarize._summary_input` -and `buildCopyText` fall back to the stored `plain_text`, so a transcript -merged before this change renders as stored. That is honest old data, not -wrong data, and re-merging the session rewrites the artefact zoned. +**Only the stored `session-transcript.txt` stays bare until re-merged.** +`batch_summarize._summary_input` and `buildCopyText` render again from the +stored segments, so the summarizer input, the copy and the `.txt` download of +an old session are zoned at once. They fall back to the stored `plain_text` +only for a body with no segments. Re-merging the session rewrites the file +zoned. **`fmtSessionLabel` stays bare UTC.** It labels a session from the id's own slice, not a transcript line, so the rule does not reach it. It is the diff --git a/tapscribe/session_merge.py b/tapscribe/session_merge.py index 7614c25c..54680ef6 100644 --- a/tapscribe/session_merge.py +++ b/tapscribe/session_merge.py @@ -306,40 +306,23 @@ def to_dict(self) -> dict[str, Any]: _LOW_CONFIDENCE_LOGPROB_THRESHOLD = -0.5 -def _offset_label(utcoffset: timedelta | None) -> str: - """`±HH:MM` for an instant's UTC offset; `None` (a naive stamp) is `+00:00` - — the `parse_iso` naive-is-UTC convention.""" - if utcoffset is None: - return "+00:00" - total_minutes = int(utcoffset.total_seconds()) // 60 - hours, minutes = divmod(abs(total_minutes), 60) - return f"{'-' if total_minutes < 0 else '+'}{hours:02d}:{minutes:02d}" - - def _clock(abs_start: Any) -> str: - """The zoned artefact clock `HH:MM:SS±HH:MM` from either a datetime - (mid-merge) or its ISO string (read back off `session-transcript.json`). - - A rendered transcript line's clock names the zone it is in; a line on - screen needs none (its pane is the context). The stamp carries the - instant's own offset and never converts it — `parse_iso` preserves whatever - offset a stored ISO carries, and merge instants are UTC. The JS mirror for - the browser-side artefacts is `formatters.fmtClockZ`. + """The zoned clock `HH:MM:SS±HH:MM` from either a datetime (mid-merge) or + its ISO string (read back off `session-transcript.json`). It keeps the + instant's own offset and reads a naive stamp as UTC, as `parse_iso` does. An unreadable stamp renders `??:??:??` rather than raising: `parse_iso` raises on malformed input, and this runs over a file on disk, so one bad segment would fail a whole summarize instead of costing one line its clock. - An unknown time cannot name a zone. """ - if isinstance(abs_start, datetime): - return f"{abs_start.strftime('%H:%M:%S')}{_offset_label(abs_start.utcoffset())}" + stamp = abs_start.isoformat() if isinstance(abs_start, datetime) else str(abs_start or "") try: - dt = parse_iso(str(abs_start or "")) + dt = parse_iso(stamp) except ValueError: - return "??:??:??" + dt = None if dt is None: return "??:??:??" - return f"{dt.strftime('%H:%M:%S')}{_offset_label(dt.utcoffset())}" + return dt.strftime("%H:%M:%S%:z") def render_transcript_text( @@ -349,11 +332,8 @@ def render_transcript_text( ) -> str: """The merged transcript as `[HH:MM:SS±HH:MM] Speaker: text` lines. - The clock is ZONED — each line names the zone its instant is in, because - this text leaves its rendering context (stored file, summarizer input). - A line on screen needs no zone; its pane is the context. The JS mirror - for the browser-side artefacts is `formatters.fmtClockZ` — one rule, - stated at both (ADR-0026). + The clock names its zone, because this text leaves the screen (stored + file, summarizer input). The JS twin is `formatters.fmtClockZ` (ADR-0026). The ONE spelling of that format on this side of the wire: `merge_session` builds `plain_text` through it, and `batch_summarize` re-renders the stored diff --git a/tapscribe/text.py b/tapscribe/text.py index e522078f..4e1878c9 100644 --- a/tapscribe/text.py +++ b/tapscribe/text.py @@ -116,9 +116,9 @@ def safe_name(s: str | None) -> str: def parse_iso(s: str | None) -> datetime | None: - """Parse an ISO-8601 timestamp into a tz-aware UTC datetime. Accepts - a trailing `Z`, treats naive timestamps as UTC, and returns None for - blank/missing input. Used by both the per-WAV sidecar reader and + """Parse an ISO-8601 timestamp into a tz-aware datetime. Keeps an + explicit offset, reads a trailing `Z` and a naive timestamp as UTC, and + returns None for blank/missing input. Used by both the per-WAV sidecar reader and the session merger.""" if not s: return None diff --git a/tapscribe/web/components/next/views.html b/tapscribe/web/components/next/views.html index f5c9d565..421ffd1d 100644 --- a/tapscribe/web/components/next/views.html +++ b/tapscribe/web/components/next/views.html @@ -118,7 +118,7 @@