Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aumtool/app/api/report/tag/route.ts | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ReportProject.tsx | 7+++++++
Aumtool/components/projects/TagCitedButton.tsx | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&rsquo;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> + ); +}