From 5ea05eb55342d9691b2d174918f7d4abe8b845dd Mon Sep 17 00:00:00 2001 From: Mariano Gabriel Date: Fri, 26 Jun 2026 11:38:45 -0300 Subject: [PATCH] add batch --- Makefile | 45 ++++++++++++ compile_meeting.py | 135 +++++++++++++++++++++--------------- ctrl/batch.sh | 121 +++++++++++++++++++++++++++++++++ summarize_simple.py | 162 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+), 53 deletions(-) create mode 100644 Makefile create mode 100755 ctrl/batch.sh create mode 100755 summarize_simple.py 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 +# /2026/team a//... +# 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[@]}"}" &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 ] diff --git a/summarize_simple.py b/summarize_simple.py new file mode 100755 index 0000000..a807ec0 --- /dev/null +++ b/summarize_simple.py @@ -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 _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()