diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d11763f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,45 @@
+# meetus — convenience wrapper around ctrl/batch.sh (adhoc tool, nothing
+# standardized here; this just saves typing the batch flags).
+#
+# make batch IN="/mnt/win/trainings" # outputs next to sources
+# make batch IN="..." AUDIO_LANG=es # set audio language
+# make batch IN="..." OUT=output/run1 # collect outputs elsewhere
+# make batch IN="..." EXTRA="--skip-cache-whisper" # one-off extra flags
+# make dry IN="..." # list what would run
+# make batch IN="..." EXT="mp4 mov" # limit extensions
+# make batch IN="..." FLAGS="--embed-images" # replace the base flags
+#
+# IN is required. OUT defaults to IN (write in place); otherwise the input
+# folder structure is mirrored under OUT.
+
+IN ?=
+# Default: write outputs next to the source files (run folders land in the same
+# dir as each video/audio). Pass OUT=... to collect them elsewhere instead.
+OUT ?= $(IN)
+
+# The base combo used on every run. LANG/EXTRA below add to this without
+# needing to retype it; override FLAGS only to change the base itself.
+FLAGS ?= --embed-images --scene-detection --scene-threshold 10 --diarize --transcript-formats srt
+
+# Per-run knobs (appended after FLAGS): language, plus any one-off extra flags
+# (e.g. --skip-cache-whisper, --transcript-formats srt).
+# NB: named AUDIO_LANG, not LANG — LANG is the shell locale env var.
+AUDIO_LANG ?=
+EXTRA ?=
+
+# Optional: restrict scanned extensions / pick the python interpreter.
+EXT ?=
+PYTHON ?=
+export PYTHON
+
+.PHONY: batch dry help
+
+batch:
+ @ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") -- \
+ $(FLAGS) $(if $(AUDIO_LANG),--language $(AUDIO_LANG)) $(EXTRA)
+
+dry:
+ @ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") -n
+
+help:
+ @sed -n '3,14p' Makefile
diff --git a/compile_meeting.py b/compile_meeting.py
index 3ef32e4..3d082ca 100755
--- a/compile_meeting.py
+++ b/compile_meeting.py
@@ -42,53 +42,30 @@ DEFAULT_BASE_URL = "http://localhost:11000/v1"
DEFAULT_MODEL = "Qwen/Qwen2.5-VL-7B-Instruct-AWQ"
CHARS_PER_TOKEN = 4.0
-GROUNDING = """\
-Rules:
-- Be faithful. Never invent names, components, commands, numbers, or steps.
-- Preserve proper nouns and identifiers exactly as written.
-- This is a COMPILATION, not a summary: keep technical detail (workflows step by
- step, architecture components and how they connect, configs, commands, gotchas).
-- Reorganize by TOPIC, not by conversation order. Merge new info into the right
- existing section rather than appending chronologically.
-- Anchor concrete items to the [mm:ss] where they were said/shown.
-- ASR vigilance on TERMS, especially acronyms and product/tool names: this
- transcript is machine-transcribed, so a term that reads oddly or makes no sense
- in context is likely a mis-hearing (a slightly-off acronym, a homophone, a
- split or merged word). Flag it like "(heard: X — likely Y?)", using context to
- infer the intended term; never silently propagate a nonsensical token, and
- never silently "correct" a term you are unsure about.
-- If something is unclear or only partially stated, mark it (e.g. "(unclear)")
- rather than guessing."""
-
REFINE_SYS = """\
-You maintain a growing TECHNICAL REFERENCE compiled from a training recording.
-
-The user's compilation instruction is authoritative:
+You are building up a DETAILED REFERENCE DOCUMENT from a meeting/training
+transcript, one window at a time. The user's intent — follow it; otherwise use
+your own judgment for structure, depth, ordering, and emphasis:
{instruction}
-You are given the CURRENT REFERENCE so far and the NEXT WINDOW of transcript
-(and possibly some screen frames). Integrate any new workflow/architecture detail
-from this window into the reference, slotting it into the correct topical section
-(create sections as needed). Return the COMPLETE updated reference in Markdown —
-not a diff, not just the new part. Do not drop earlier content.
-
-{rules}"""
+You get the document so far and the next window of transcript (sometimes with
+screen frames). Fold the new material into the document and return the COMPLETE
+updated document, dropping nothing important from before. Keep concrete detail —
+names, numbers, steps, configs, specifics — rather than collapsing to general
+ideas; this is a reference, not a recap. Stay faithful to the transcript and
+don't invent. It's machine-transcribed, so use your own judgment on garbled
+spots (an odd acronym is probably a mis-hearing). Beyond that, write and organize
+it however reads best to you."""
TRIAGE_SYS = """\
-You are reading one window of a training transcript while compiling technical
-notes per this instruction:
-
-{instruction}
-
-
-The window references the screen frames listed below (id + [mm:ss]). Decide which
-frames you would need to SEE to capture workflow/architecture/config detail the
-text alone doesn't convey (diagrams, slides, terminal output, code). Ignore
-webcam/transition frames. Reply with STRICT JSON only:
+You're compiling a detailed reference and reading this window of transcript. The
+frames listed below are screenshots referenced in it (id + [mm:ss]). List the ids
+of any you'd find worth actually looking at — lean toward looking whenever a frame
+might carry detail the words alone don't. Reply with JSON only:
{{"need": ["", ...]}}
-Empty list if none are needed."""
+Empty list if none seem useful."""
FRAME_RE = re.compile(r"Frame:\s+(\S+\.(?:jpg|jpeg|png))", re.IGNORECASE)
TS_RE = re.compile(r"\[(\d+):(\d+)\]")
@@ -98,13 +75,15 @@ def estimate_tokens(text):
return int(len(text) / CHARS_PER_TOKEN)
-def default_output(transcript, kind):
- """Write next to the transcript, in the same run folder, following the
- pipeline's _ naming (e.g. training_reference.md)."""
+def default_output(transcript, kind, base_dir=None):
+ """Write into base_dir (default: next to the transcript, in the same run
+ folder), following the pipeline's _ naming (e.g.
+ training_reference.md)."""
stem = transcript.stem
if stem.endswith("_enhanced"):
stem = stem[: -len("_enhanced")]
- return transcript.parent / f"{stem}_{kind}.md"
+ base = base_dir or transcript.parent
+ return base / f"{stem}_{kind}.md"
def content_tokens(content):
@@ -183,12 +162,15 @@ def encode_image(path, max_side):
return f"data:{mime};base64,{b64}"
-def make_client(base_url, api_key):
+def make_client(base_url, api_key, timeout):
try:
from openai import OpenAI
except ImportError:
sys.exit("ERROR: `openai` not installed here. Run under ~/wdir/llm/.venv")
- return OpenAI(base_url=base_url, api_key=api_key)
+ # timeout=None => no limit (a slow mixed GPU/CPU model can take >>10min per call;
+ # the client default of ~600s is what raised RequestTimedOut). max_retries=0 so a
+ # rare hiccup doesn't silently re-send a 40-minute generation.
+ return OpenAI(base_url=base_url, api_key=api_key, timeout=timeout, max_retries=0)
def call(client, model, system, content, temperature, max_tokens):
@@ -201,6 +183,30 @@ def call(client, model, system, content, temperature, max_tokens):
return resp.choices[0].message.content.strip()
+def call_fit(client, model, system, content, temperature, ctx, init_out):
+ """Like call(), but bulletproof against token-estimate error: if the server
+ rejects the request for exceeding context, parse the REAL input-token count
+ from its error and retry with an exactly-fitting output budget. Re-raises the
+ context error only when the input alone fills the window (the doc-too-big
+ case the caller handles by stopping)."""
+ out = init_out
+ last = None
+ for _ in range(4):
+ try:
+ return call(client, model, system, content, temperature, out)
+ except Exception as e:
+ last = e
+ m = re.search(r"at least (\d+) input tokens", str(e))
+ if "maximum context length" in str(e) and m:
+ real_in = int(m.group(1))
+ out = ctx - real_in - 64
+ if out < 256:
+ break # input itself ~fills the window — genuine overflow
+ continue
+ raise
+ raise last
+
+
def parse_need(raw, valid_ids):
raw = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE)
m = re.search(r"\{.*\}", raw, re.DOTALL)
@@ -221,6 +227,10 @@ def main():
p.add_argument("instruction", nargs="?",
default="Compile a detailed technical reference of the workflows and architecture covered.")
p.add_argument("-o", "--output", type=Path, help="write here (default: /_reference.md next to the transcript)")
+ p.add_argument("--output-dir", type=Path,
+ help="base/parent directory; the run folder (taken from the "
+ "transcript's folder name) is auto-created under it and the "
+ "output written inside (default: the transcript's folder)")
p.add_argument("--stdout", action="store_true", help="print to stdout instead of writing a file")
p.add_argument("--base-url", default=DEFAULT_BASE_URL)
p.add_argument("--model", default=DEFAULT_MODEL)
@@ -231,6 +241,7 @@ def main():
p.add_argument("--ctx", type=int, default=16384, help="model context window; output is auto-capped so input+output fit (default 16384)")
p.add_argument("--max-image-side", type=int, default=1280, help="downscale frames to this max side (0=off)")
p.add_argument("--temperature", type=float, default=0.2)
+ p.add_argument("--timeout", type=float, default=0, help="per-request timeout in seconds; 0 = no limit (default, for slow local models)")
p.add_argument("--checkpoint", type=Path, help="write the running doc here after each window (resumable progress)")
p.add_argument("-q", "--quiet", action="store_true")
args = p.parse_args()
@@ -246,7 +257,7 @@ def main():
nframes = sum(len(w["frames"]) for w in windows)
log(f"{len(windows)} windows, {nframes} frame refs, mode={args.frames}")
- client = make_client(args.base_url, args.api_key)
+ client = make_client(args.base_url, args.api_key, args.timeout or None)
doc = "# (compilation in progress)\n"
for wi, w in enumerate(windows, 1):
@@ -280,13 +291,22 @@ def main():
content = text
log(f" window {wi}/{len(windows)}: text-only")
- sys_txt = REFINE_SYS.format(instruction=args.instruction, rules=GROUNDING)
+ sys_txt = REFINE_SYS.format(instruction=args.instruction)
in_tok = content_tokens(content) + estimate_tokens(sys_txt)
- out_budget = max(512, min(args.max_tokens, args.ctx - in_tok - 256))
- if in_tok > args.ctx - 512:
- log(f" WARNING: doc+window ~{in_tok} tok ≥ ctx {args.ctx}; output will truncate — "
- f"use a 32k profile (qwen14b-gguf) or lower --window-tokens for the full training")
- doc = call(client, args.model, sys_txt, content, args.temperature, out_budget)
+ # proportional margin absorbs token-estimate error; call_fit self-corrects
+ # from the server's real count if it's still off.
+ out_budget = max(256, min(args.max_tokens, args.ctx - in_tok - max(512, in_tok // 20)))
+ try:
+ doc = call_fit(client, args.model, sys_txt, content,
+ args.temperature, args.ctx, out_budget)
+ except Exception as e:
+ if "maximum context length" in str(e):
+ log(f" STOP at window {wi}/{len(windows)}: the running doc filled the "
+ f"{args.ctx}-token window. Partial reference is in the checkpoint. A {args.ctx // 1024}k "
+ f"refine cannot hold a whole long-meeting doc — use the chunk-at-breaks + "
+ f"compact-carry + merge design, or a 32k-context profile.")
+ break
+ raise
if args.checkpoint:
args.checkpoint.write_text(doc + "\n")
@@ -297,7 +317,16 @@ def main():
if args.stdout:
print(doc)
else:
- out = args.output or default_output(args.transcript, "reference")
+ # --output-dir is the PARENT; auto-create the run folder under it (named
+ # after the transcript's own run folder), matching process_meeting.py.
+ base_dir = (args.output_dir / args.transcript.parent.name) if args.output_dir else args.transcript.parent
+ if args.output:
+ out = args.output
+ if not out.is_absolute():
+ out = base_dir / out
+ else:
+ out = default_output(args.transcript, "reference", base_dir)
+ out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(doc + "\n")
log(f"wrote {out}")
diff --git a/ctrl/batch.sh b/ctrl/batch.sh
new file mode 100755
index 0000000..836d0f8
--- /dev/null
+++ b/ctrl/batch.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+# Batch-process every video under a directory through process_meeting.py,
+# mirroring the input folder structure into the output base.
+#
+# Recursive and space-safe (paths come off a Windows mount, so they often have
+# spaces). Each video's run folder is auto-created by process_meeting.py inside
+# the mirrored output subfolder.
+#
+# Usage:
+# ctrl/batch.sh -i -o [-e "mkv mp4 ..."] [-n] \
+# [-- ]
+#
+# Examples:
+# # Everything under a mounted share, default extraction flags forwarded
+# ctrl/batch.sh -i "/mnt/win/trainings" -o output/batch \
+# -- --embed-images --scene-detection --diarize
+#
+# # Dry run: just show which videos map to which output folders
+# ctrl/batch.sh -i "/mnt/win/trainings" -o output/batch -n
+#
+# # Only mp4/mov, custom whisper model
+# ctrl/batch.sh -i "./recordings" -o output/batch -e "mp4 mov" \
+# -- --run-whisper --whisper-model large
+#
+# A video at /2026/team a/session 1.mkv produces
+#