commit 6c4467d10b58898acf7e853bde2701e70da7df9f
parent b2f77dfe021c4bd1a39769905781c48dd5ff5d8c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:24:23 -0400
tags S3.6: umtool hands a walked report's judgement to the archive
"Tag cited videos as …" beside the sources table — whole project, or one
section, tag or untag. A report is a set of adjudicated claims about specific
recordings, which is exactly what a curated tag records; re-deriving it in the
editor by hand (or by a rule that half-matches) throws that judgement away.
It writes nothing itself. /api/report/tag forwards to the editor's
/api/ops/tag-videos with the shared WORKER_TOKEN, the same convention
report-to-video/fetch-via-editor.mjs uses to ask for a clip window, with
`source: "umtool:<projectId>"` so a pin can be traced back to the report it came
from. One request per scope, so ninety cited videos are one write of tags.json.
The token stays server-side, which is why the vocabulary for the picker is
fetched through this route too rather than from the editor by the browser.
THE IDS ARE THE ARCHIVE'S. A clip names its video the way a local cue directory
does; the archive keys by the record id, and for Rumble those differ (embed id
vs URL slug), so `siteVideo` / `siteChannel` win when a clip carries them.
`citeUrl` is deliberately not consulted — it may cite a different recording.
A clip that names no channel at all is REPORTED BY ID, not quietly dropped from
a tag the operator believes covers the whole report.
What the control prints is the editor's own `changed`: how many videos actually
moved. Re-tagging what is already tagged changes nothing, and printing "90
tagged" over an answer of 0 would be this page lying about the archive.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
3 files changed, 432 insertions(+), 0 deletions(-)
diff --git a/umtool/app/api/report/tag/route.ts b/umtool/app/api/report/tag/route.ts
@@ -0,0 +1,236 @@
+import { projectRef } from "@/lib/projects";
+import { channelFor, clipsOf, readManifest } from "@/lib/projects/report.mjs";
+import { sectionOf } from "@/lib/report/deliver.mjs";
+
+export const dynamic = "force-dynamic";
+
+// TAG THE VIDEOS THIS REPORT CITES — in the archive, through the editor.
+//
+// A report is a set of claims about specific recordings, already adjudicated by
+// a person. That is exactly the fact the archive's curated tags are for, and
+// re-deriving it there by hand (or by a rule that half-matches) throws away the
+// judgement this project already contains.
+//
+// IT WRITES NOTHING ITSELF. The corpus belongs to the editor; this POSTs to
+// /api/ops/tag-videos with the shared WORKER_TOKEN, exactly as
+// report-to-video/fetch-via-editor.mjs asks for a clip window. One request for
+// the whole scope, so tagging ninety cited videos is one write of tags.json.
+//
+// THE IDS ARE THE ARCHIVE'S, WHICH ARE NOT ALWAYS THE MANIFEST'S. A clip names
+// the video by the id a local cue directory uses; the archive (and therefore an
+// assignment) keys by the record id, and for Rumble those differ — the embed id
+// versus the URL slug. A clip carrying `siteVideo` / `siteChannel` is saying
+// what the archive calls it, so those win when present. `citeUrl` is NOT usable
+// here: it may deliberately cite a different recording (a mirror that reads
+// better). See umtool/report-to-video/cues.mjs.
+
+const DEFAULT_EDITOR = "http://localhost:3001";
+
+type ClipEntry = {
+ id?: string;
+ video?: string;
+ channel?: string | null;
+ siteVideo?: string;
+ siteChannel?: string;
+};
+
+type Manifest = { timeline?: unknown[]; provenance?: Record<string, unknown> };
+
+function editorUrl(): string {
+ return (process.env.ARCHILYZER_EDITOR_URL ?? DEFAULT_EDITOR).replace(
+ /\/+$/,
+ "",
+ );
+}
+
+// The distinct archive videos cited by this project, optionally narrowed to one
+// section. A section is the clip id's letter prefix — the only thing that ties
+// a clip to a section in the manifest (deliver.mjs's sectionOf).
+function citedVideos(
+ manifest: Manifest,
+ section: string | null,
+): { videos: { slug: string; id: string }[]; clips: number; missing: string[] } {
+ const seen = new Set<string>();
+ const videos: { slug: string; id: string }[] = [];
+ const missing: string[] = [];
+ let clips = 0;
+ for (const raw of clipsOf(manifest) as ClipEntry[]) {
+ if (section && sectionOf(raw.id) !== section) continue;
+ clips += 1;
+ const slug = raw.siteChannel ?? channelFor(manifest, raw);
+ const id = raw.siteVideo ?? raw.video;
+ if (!slug || !id) {
+ // NAMED, NOT DROPPED. A clip whose channel is nowhere — not on the entry,
+ // not in the manifest's provenance — would otherwise be silently excluded
+ // from a tag the operator believes covers the whole report.
+ missing.push(raw.id ?? "(unnamed clip)");
+ continue;
+ }
+ const key = `${slug}/${id}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ videos.push({ slug, id });
+ }
+ return { videos, clips, missing };
+}
+
+// GET ?project=<id> — what the project page's control needs: the sections it
+// can scope to, the archive's vocabulary, and whether this machine is even
+// configured to ask. The token never reaches the browser.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) {
+ return Response.json({ error: "no such project" }, { status: 404 });
+ }
+ const manifest = (await readManifest(project.dir)) as Manifest | null;
+ if (!manifest) {
+ return Response.json({ error: "no manifest" }, { status: 400 });
+ }
+
+ const whole = citedVideos(manifest, null);
+ const bySection = new Map<string, number>();
+ for (const raw of clipsOf(manifest) as ClipEntry[]) {
+ const letter = sectionOf(raw.id);
+ bySection.set(letter, (bySection.get(letter) ?? 0) + 1);
+ }
+ const sections = [...bySection.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([letter, clips]) => ({
+ letter,
+ clips,
+ videos: citedVideos(manifest, letter).videos.length,
+ }));
+
+ const token = process.env.WORKER_TOKEN ?? "";
+ let tags: { id: string; label: string }[] = [];
+ let reachable = false;
+ if (token) {
+ try {
+ const res = await fetch(`${editorUrl()}/api/ops/tags`, {
+ headers: { authorization: `Bearer ${token}` },
+ cache: "no-store",
+ });
+ if (res.ok) {
+ const body = (await res.json()) as {
+ tags?: { id: string; label?: string }[];
+ };
+ tags = (body.tags ?? []).map((t) => ({ id: t.id, label: t.label ?? t.id }));
+ reachable = true;
+ }
+ } catch {
+ // Unreachable editor is a state the control renders, not a 500 here.
+ }
+ }
+
+ return Response.json(
+ {
+ project: project.id,
+ editor: editorUrl(),
+ configured: Boolean(token),
+ reachable,
+ tags,
+ sections,
+ videos: whole.videos.length,
+ clips: whole.clips,
+ missing: whole.missing,
+ },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
+
+// POST { project, tag, op?: "add"|"remove", section? } -> the editor's answer.
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => ({}))) as Record<
+ string,
+ unknown
+ >;
+ const project = await projectRef(String(body.project ?? ""));
+ if (!project) {
+ return Response.json({ error: "no such project" }, { status: 404 });
+ }
+ const tag = String(body.tag ?? "").trim();
+ if (!tag) return Response.json({ error: "no tag given" }, { status: 400 });
+ const op = body.op === "remove" ? "remove" : "add";
+ const section = body.section ? String(body.section).toUpperCase() : null;
+
+ const token = process.env.WORKER_TOKEN ?? "";
+ if (!token) {
+ // NAME THE VARIABLE, the way fetch-via-editor.mjs does: a bare 401 from an
+ // endpoint the operator has never heard of is a twenty-minute detour.
+ return Response.json(
+ {
+ error:
+ "WORKER_TOKEN is not set, so the editor cannot be asked to tag anything. " +
+ "Set it to the same value the editor runs with.",
+ },
+ { status: 400 },
+ );
+ }
+
+ const manifest = (await readManifest(project.dir)) as Manifest | null;
+ if (!manifest) {
+ return Response.json({ error: "no manifest" }, { status: 400 });
+ }
+ const { videos, clips, missing } = citedVideos(manifest, section);
+ if (videos.length === 0) {
+ return Response.json(
+ {
+ error: section
+ ? `section ${section} cites no video this tool can name`
+ : "this report cites no video this tool can name",
+ missing,
+ },
+ { status: 400 },
+ );
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(`${editorUrl()}/api/ops/tag-videos`, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ tag,
+ op,
+ videos,
+ // WHO ASKED, recorded on every pin the editor writes. A tag that came
+ // from a report can be traced back to the report.
+ source: `umtool:${project.id}`,
+ }),
+ });
+ } catch (err) {
+ return Response.json(
+ { error: `could not reach the editor at ${editorUrl()}: ${(err as Error).message}` },
+ { status: 502 },
+ );
+ }
+
+ const answer = (await res.json().catch(() => ({}))) as {
+ ok?: boolean;
+ changed?: number;
+ error?: string;
+ };
+ if (!res.ok || answer.ok === false) {
+ return Response.json(
+ { error: answer.error ?? `editor answered HTTP ${res.status}` },
+ { status: res.status === 401 || res.status === 503 ? res.status : 400 },
+ );
+ }
+ return Response.json({
+ ok: true,
+ tag,
+ op,
+ // The editor's own number: how many videos actually MOVED. Re-tagging what
+ // is already tagged changes nothing, and saying "90 tagged" when the answer
+ // was 0 would be the report lying about the archive.
+ changed: answer.changed ?? 0,
+ videos: videos.length,
+ clips,
+ ...(section ? { section } : {}),
+ ...(missing.length ? { missing } : {}),
+ });
+}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -21,6 +21,7 @@ import DeliverSection from "./DeliverSection";
import FetchUnfetchedButton from "./FetchUnfetchedButton";
import ReportBuildChain from "./ReportBuildChain";
import SnapshotButton from "./SnapshotButton";
+import TagCitedButton from "./TagCitedButton";
import { decisionsForProject } from "@/lib/projects";
import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -294,6 +295,12 @@ export default async function ReportProject({
<CheckSourcesButton projects={[project.id]} label="re-check availability" />
</span>
</div>
+ {/* Beside the sources, because these ARE the sources: tagging them in
+ the archive is a statement about the recordings this table lists,
+ and the count in the control is the count in its heading. */}
+ <div className="mt-1.5">
+ <TagCitedButton project={project.id} />
+ </div>
<div className="num text-[11px] text-[var(--color-dim)]">
cues from <code className="font-mono">{channelsDir}</code>
{shadowExists && (
diff --git a/umtool/components/projects/TagCitedButton.tsx b/umtool/components/projects/TagCitedButton.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { buttonVariants } from "@/components/ui/button";
+
+// ---------------------------------------------------------------------------
+// "Tag cited videos as …"
+//
+// A walked report is a set of adjudicated claims about specific recordings.
+// That is exactly what the archive's curated tags record, so this hands the
+// judgement over instead of making somebody re-derive it there — whole project,
+// or one section at a time when the sections mean different things ("on mic" in
+// A, "discussed" in C).
+//
+// IT ASKS THE EDITOR; IT DOES NOT WRITE THE CORPUS. The POST goes to this
+// tool's own route, which forwards to /api/ops/tag-videos with the shared
+// WORKER_TOKEN — the same convention report-to-video/fetch-via-editor.mjs uses
+// for a clip window. The token never reaches the browser, which is why the
+// vocabulary is fetched through that route too rather than from the editor
+// directly.
+//
+// WHAT IT REPORTS IS THE EDITOR'S NUMBER. `changed` is how many videos actually
+// moved: re-tagging what is already tagged changes nothing, and printing "90
+// tagged" over an answer of 0 would be this page lying about the archive.
+// ---------------------------------------------------------------------------
+
+type State = {
+ configured: boolean;
+ reachable: boolean;
+ editor: string;
+ tags: { id: string; label: string }[];
+ sections: { letter: string; clips: number; videos: number }[];
+ videos: number;
+ clips: number;
+ missing: string[];
+};
+
+export default function TagCitedButton({ project }: { project: string }) {
+ const [state, setState] = useState<State | null>(null);
+ const [tag, setTag] = useState("");
+ const [section, setSection] = useState("");
+ const [op, setOp] = useState<"add" | "remove">("add");
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState<string | null>(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let live = true;
+ void fetch(`/api/report/tag?project=${encodeURIComponent(project)}`, {
+ cache: "no-store",
+ })
+ .then((r) => r.json())
+ .then((s: State & { error?: string }) => {
+ if (!live || s.error) return;
+ setState(s);
+ setTag((t) => t || s.tags[0]?.id || "");
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, [project]);
+
+ if (!state) return null;
+
+ const scopeVideos = section
+ ? (state.sections.find((s) => s.letter === section)?.videos ?? 0)
+ : state.videos;
+
+ async function run() {
+ setBusy(true);
+ setFailed(false);
+ setMsg(null);
+ try {
+ const res = await fetch("/api/report/tag", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ project,
+ tag,
+ op,
+ ...(section ? { section } : {}),
+ }),
+ });
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ changed?: number;
+ videos?: number;
+ missing?: string[];
+ };
+ if (!res.ok || body.error) {
+ setFailed(true);
+ setMsg(body.error ?? `HTTP ${res.status}`);
+ return;
+ }
+ const verb = op === "add" ? "tagged" : "untagged";
+ setMsg(
+ `${verb} ${body.changed ?? 0} of ${body.videos ?? 0} cited video${
+ (body.videos ?? 0) === 1 ? "" : "s"
+ }` +
+ (body.missing?.length
+ ? ` — ${body.missing.length} clip(s) name no channel: ${body.missing.join(", ")}`
+ : ""),
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ <div className="flex flex-wrap items-center gap-2" data-tag-cited="">
+ <span className="micro">tag cited videos</span>
+ {!state.configured ? (
+ <span className="text-[11px] text-[var(--color-dim)]">
+ WORKER_TOKEN is not set here, so the editor cannot be asked.
+ </span>
+ ) : !state.reachable ? (
+ <span className="text-[11px] text-[var(--color-dim)]">
+ no editor at <code className="font-mono">{state.editor}</code>
+ </span>
+ ) : state.tags.length === 0 ? (
+ <span className="text-[11px] text-[var(--color-dim)]">
+ the archive defines no tags yet — define one on the editor’s
+ Tags page
+ </span>
+ ) : (
+ <>
+ <select
+ aria-label="tag"
+ data-tag-cited-tag=""
+ value={tag}
+ onChange={(e) => setTag(e.target.value)}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-1.5 py-1 text-[11px]"
+ >
+ {state.tags.map((t) => (
+ <option key={t.id} value={t.id}>
+ {t.label}
+ </option>
+ ))}
+ </select>
+ <select
+ aria-label="scope"
+ data-tag-cited-scope=""
+ value={section}
+ onChange={(e) => setSection(e.target.value)}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-1.5 py-1 text-[11px]"
+ >
+ <option value="">whole project ({state.videos})</option>
+ {state.sections.map((s) => (
+ <option key={s.letter} value={s.letter}>
+ section {s.letter} ({s.videos})
+ </option>
+ ))}
+ </select>
+ <select
+ aria-label="operation"
+ data-tag-cited-op=""
+ value={op}
+ onChange={(e) => setOp(e.target.value as "add" | "remove")}
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-1.5 py-1 text-[11px]"
+ >
+ <option value="add">tag</option>
+ <option value="remove">untag</option>
+ </select>
+ <button
+ type="button"
+ data-action="tag-cited"
+ data-state={busy ? "busy" : failed ? "failed" : msg ? "done" : "idle"}
+ disabled={busy || !tag || scopeVideos === 0}
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => void run()}
+ >
+ {busy
+ ? "asking the editor…"
+ : `${op === "add" ? "tag" : "untag"} ${scopeVideos} video${scopeVideos === 1 ? "" : "s"}`}
+ </button>
+ </>
+ )}
+ {msg && (
+ <span
+ data-tag-cited-msg=""
+ className={`text-[11px] ${failed ? "text-[var(--color-bad)]" : "text-[var(--color-dim)]"}`}
+ >
+ {msg}
+ </span>
+ )}
+ </div>
+ );
+}