add batch

This commit is contained in:
Mariano Gabriel
2026-06-26 11:38:45 -03:00
parent 3c96de85ad
commit 5ea05eb553
4 changed files with 410 additions and 53 deletions

45
Makefile Normal file
View File

@@ -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

View File

@@ -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>
{instruction}
</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>
{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": ["<frame-id>", ...]}}
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 <stem>_<kind> 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 <stem>_<kind> 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: <run>/<stem>_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}")

121
ctrl/batch.sh Executable file
View File

@@ -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 <input-dir> -o <output-dir> [-e "mkv mp4 ..."] [-n] \
# [-- <process_meeting.py flags>]
#
# 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 <input>/2026/team a/session 1.mkv produces
# <output>/2026/team a/<YYYYMMDD-NNN-session 1>/...
# i.e. the run folder lands inside the mirrored subtree.
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
# python: honor $PYTHON, else prefer python3
PYTHON="${PYTHON:-}"
if [ -z "$PYTHON" ]; then
if command -v python3 >/dev/null 2>&1; then PYTHON=python3; else PYTHON=python; fi
fi
usage() { sed -n '2,32p' "$0"; }
INPUT=""
OUTPUT=""
EXTS="mkv mp4 mov avi m4v webm wmv ogg mp3 wav m4a opus flac aac"
DRY=false
FORWARD=()
while [[ $# -gt 0 ]]; do
case "$1" in
-i|--input) INPUT="$2"; shift 2 ;;
-o|--output) OUTPUT="$2"; shift 2 ;;
-e|--ext) EXTS="$2"; shift 2 ;;
-n|--dry-run) DRY=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; FORWARD=("$@"); break ;;
*) echo "Unknown arg: $1" >&2; usage; exit 1 ;;
esac
done
[ -n "$INPUT" ] || { echo "ERROR: -i/--input is required" >&2; exit 1; }
[ -n "$OUTPUT" ] || { echo "ERROR: -o/--output is required" >&2; exit 1; }
[ -d "$INPUT" ] || { echo "ERROR: input dir not found: $INPUT" >&2; exit 1; }
# Absolute paths so relative-path math is stable regardless of where we cd'd.
INPUT="$(realpath "$INPUT")"
mkdir -p "$OUTPUT"
OUTPUT="$(realpath "$OUTPUT")"
# Build the find extension filter: \( -iname '*.mkv' -o -iname '*.mp4' ... \)
find_expr=()
for ext in $EXTS; do
find_expr+=( -iname "*.${ext}" -o )
done
unset 'find_expr[${#find_expr[@]}-1]' # drop the trailing -o
echo "Input : $INPUT"
echo "Output: $OUTPUT"
echo "Exts : $EXTS"
[ "$DRY" = true ] && echo "(dry run — nothing will be processed)"
echo
total=0 ok=0 fail=0
# Process substitution (not a pipe) so counters survive into the summary.
while IFS= read -r -d '' video; do
total=$((total + 1))
rel="${video#"$INPUT"/}" # path relative to the input root
reldir="$(dirname "$rel")"
if [ "$reldir" = "." ]; then
outdir="$OUTPUT" # video sat directly in the input root
else
outdir="$OUTPUT/$reldir" # mirror the subfolder structure
fi
echo "[$total] $rel"
echo " -> $outdir"
if [ "$DRY" = true ]; then
continue
fi
mkdir -p "$outdir"
# stdin from /dev/null: process_meeting's children (ffmpeg/whisperx) otherwise
# inherit the loop's stdin and eat the rest of the file list — which stalls the
# batch after the first item.
if "$PYTHON" "$PROJECT_DIR/process_meeting.py" "$video" \
--output-dir "$outdir" "${FORWARD[@]+"${FORWARD[@]}"}" </dev/null; then
ok=$((ok + 1))
else
echo " !! FAILED (continuing)" >&2
fail=$((fail + 1))
fi
echo
done < <(find "$INPUT" -type f \( "${find_expr[@]}" \) -print0 | sort -z)
echo "----------------------------------------"
if [ "$DRY" = true ]; then
echo "Found $total video(s)."
else
echo "Done. $total video(s): $ok ok, $fail failed."
fi
[ "$fail" -eq 0 ]

162
summarize_simple.py Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Minimal meeting summarizer: walk an enhanced transcript in order, summarizing as
you go, LOOKING AT EVERY referenced frame for context. No triage, no grounding
rules, no instruction block — just the SYS prompt + transcript + every frame.
Edit SYS below to change the steer.
Talks to a local OpenAI-compatible endpoint (ollama / vLLM / llama.cpp).
Usage:
~/wdir/llm/.venv/bin/python summarize_simple.py <stem>_enhanced.txt \\
--base-url http://localhost:11434/v1 --model gemma3-27b-16k
"""
import argparse, base64, io, re, sys
from pathlib import Path
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "gemma3-27b-16k"
CHARS_PER_TOKEN = 4.0
FRAME_RE = re.compile(r"Frame:\s+(\S+\.(?:jpg|jpeg|png))", re.IGNORECASE)
TS_RE = re.compile(r"\[(\d+):(\d+)\]")
SYS = """\
summarize a meeting/training from its transcript,
read screen frames interlieved in the dialog"""
def est(t): return int(len(t) / CHARS_PER_TOKEN)
def windows(path, wtok, fcap):
"""Break on EITHER the text budget OR fcap frames, so every referenced frame
lands in some window and gets attached — dense stretches just become more
(smaller) windows. Nothing is sampled away."""
blocks = re.split(r"\n\s*\n", path.read_text())
out, cur, frames, tok, ts = [], [], [], 0, "00:00"
def flush():
if cur: out.append({"text": "\n\n".join(cur), "frames": list(frames)})
for b in blocks:
m = TS_RE.search(b)
if m: ts = f"{m.group(1)}:{m.group(2)}"
fm = FRAME_RE.search(b)
bt = est(b)
if cur and (tok + bt > wtok or (fm and len(frames) >= fcap)):
flush(); cur, frames, tok = [], [], 0
if fm:
frames.append({"ts": ts, "path": fm.group(1)})
cur.append(f"[{ts}] (frame)")
else:
cur.append(b)
tok += bt
flush()
return out
def resolve(p, transcript):
pp = Path(p)
if pp.is_absolute() and pp.exists(): return pp
c = transcript.parent / p
return c if c.exists() else pp
def encode(path, max_side):
data = path.read_bytes(); mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
try:
from PIL import Image
img = Image.open(io.BytesIO(data))
if max_side and max(img.size) > max_side:
img.thumbnail((max_side, max_side))
buf = io.BytesIO(); img.convert("RGB").save(buf, "JPEG", quality=85)
data, mime = buf.getvalue(), "image/jpeg"
except ImportError:
pass
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("transcript", type=Path)
p.add_argument("-o", "--output", type=Path)
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("--base-url", default=DEFAULT_BASE_URL)
p.add_argument("--model", default=DEFAULT_MODEL)
p.add_argument("--api-key", default="local")
p.add_argument("--window-tokens", type=int, default=1500, help="transcript tokens per step")
p.add_argument("--max-frames", type=int, default=6, help="frames per step — also forces a new window, so EVERY frame is still seen")
p.add_argument("--max-side", type=int, default=768, help="downscale frames to this max side")
p.add_argument("--ctx", type=int, default=16384)
p.add_argument("--max-tokens", type=int, default=4096)
p.add_argument("--temperature", type=float, default=0.3)
p.add_argument("--timeout", type=float, default=0, help="per-request seconds; 0 = no limit (slow local models)")
p.add_argument("--checkpoint", type=Path)
args = p.parse_args()
if not args.transcript.is_file():
sys.exit(f"ERROR: not found: {args.transcript}")
try:
from openai import OpenAI
except ImportError:
sys.exit("ERROR: `openai` not installed here. Run under ~/wdir/llm/.venv")
client = OpenAI(base_url=args.base_url, api_key=args.api_key, timeout=(args.timeout or None), max_retries=0)
wins = windows(args.transcript, args.window_tokens, args.max_frames)
nfr = sum(len(w["frames"]) for w in wins)
print(f"[simple] {len(wins)} windows, {nfr} frame refs (all inspected)", file=sys.stderr)
# --output-dir is the PARENT; auto-create the run folder under it (named after
# the transcript's own run folder), matching process_meeting.py's layout.
base_dir = (args.output_dir / args.transcript.parent.name) if args.output_dir else args.transcript.parent
out_path = args.output
if not out_path:
stem = args.transcript.stem
if stem.endswith("_enhanced"): stem = stem[:-9]
out_path = base_dir / f"{stem}_summary_simple.md"
elif not out_path.is_absolute():
out_path = base_dir / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
# MAP + APPEND: summarize each window independently and APPEND it under its
# timestamp — no carried/re-emitted running summary, so nothing collapses and
# every segment is kept. The output file IS the accumulator (also the checkpoint).
doc = ""
for i, w in enumerate(wins, 1):
m = TS_RE.search(w["text"])
start_ts = f"{m.group(1)}:{m.group(2)}" if m else "?"
content = [{"type": "text", "text": f"PART OF THE MEETING:\n{w['text']}"}]
attached = 0
for f in w["frames"]:
ip = resolve(f["path"], args.transcript)
if ip.exists():
content.append({"type": "image_url", "image_url": {"url": encode(ip, args.max_side)}})
attached += 1
print(f"[simple] window {i}/{len(wins)} [{start_ts}]: {attached} frame(s)", file=sys.stderr)
in_tok = est(str(content)) + est(SYS) + attached * 300 # rough, incl. vision
out = max(256, min(args.max_tokens, args.ctx - in_tok - max(512, in_tok // 20)))
try:
r = client.chat.completions.create(
model=args.model,
messages=[{"role": "system", "content": SYS}, {"role": "user", "content": content}],
temperature=args.temperature, max_tokens=out)
part = r.choices[0].message.content.strip()
except Exception as e:
if "context length" in str(e) or "maximum context" in str(e):
print(f"[simple] window {i}: too big for ctx — skipping (lower --max-frames "
f"or --window-tokens to avoid). Continuing.", file=sys.stderr)
continue
raise
doc += f"## [{start_ts}]\n\n{part}\n\n"
out_path.write_text(doc) # the file grows as we go (= live result)
if args.checkpoint:
args.checkpoint.write_text(doc) # mirror, so `tail -f` keeps working
print(f"[simple] wrote {out_path}", file=sys.stderr)
if __name__ == "__main__":
main()