feat(web): say what reconciliation did, and never claim a second opinion that was not there
CI / Lint / Test / Vet (pull_request) Successful in 21s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped

Reconciled: the two engines, the deciding model and the exact counts; the contested
spots (changed or uncertain) listed with a minute mark and both readings, the first
50 shown and the rest counted from the exact totals. Single-engine and
model-unavailable results say why. A nil reconciliation renders nothing. Readings are
escaped. Mutation-checked: the cap, the minute rounding and the 'more' count.

uploads.go and the templ pair are one commit: the helpers and the template need each other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:23:39 +02:00
co-authored by Claude Sonnet 5.5
parent d6ba11d288
commit 6fb7a52980
3 changed files with 390 additions and 18 deletions
+47
View File
@@ -2,6 +2,7 @@ package web
import (
"fmt"
"strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
@@ -65,6 +66,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by <span class="chip">{ u.Endpoint }</span>.
</p>
@reconciliationNote(u.Reconciliation)
if chatEnabled {
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
}
@@ -123,3 +125,48 @@ templ videoNotice(hadVideo bool) {
</p>
}
}
// reconciliationNote says what was done when two engines transcribed the audio
// (ADR-034), and never claims a second opinion that was not there: nil means a single
// engine and renders nothing. Spots the model changed or was unsure about are listed
// with a minute mark and both readings so the user can check them against the audio.
templ reconciliationNote(r *domain.Reconciliation) {
if r != nil {
switch r.Status {
case domain.ReconcileDone:
<div class="reconciliation">
<p>
Reconciled from two transcripts ({ r.Base } and { r.Alt }):
{ strconv.Itoa(r.Disputes) } differences,
{ strconv.Itoa(r.AutoResolved) } settled automatically,
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
{ strconv.Itoa(r.Unsure) } uncertain.
</p>
if len(r.Contested) > 0 {
<details>
<summary>Spots to check against the audio</summary>
<ul>
for _, c := range shownSpots(r) {
<li>
{ minuteMark(c.Seconds) }: { c.Base } / { c.Alt } —
if c.Unsure {
<span class="chip chip-warn">uncertain</span> kept { c.Used }
} else {
<span class="chip">changed</span> used { c.Used }
}
</li>
}
</ul>
if moreSpots(r) > 0 {
<p class="muted">and { strconv.Itoa(moreSpots(r)) } more not listed</p>
}
</details>
}
</div>
case domain.ReconcileSingle:
<p class="muted reconciliation">Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.</p>
case domain.ReconcileSkipped:
<p class="muted reconciliation">Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.</p>
}
}
}