add batch
This commit is contained in:
45
Makefile
Normal file
45
Makefile
Normal 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
|
||||||
@@ -42,53 +42,30 @@ DEFAULT_BASE_URL = "http://localhost:11000/v1"
|
|||||||
DEFAULT_MODEL = "Qwen/Qwen2.5-VL-7B-Instruct-AWQ"
|
DEFAULT_MODEL = "Qwen/Qwen2.5-VL-7B-Instruct-AWQ"
|
||||||
CHARS_PER_TOKEN = 4.0
|
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 = """\
|
REFINE_SYS = """\
|
||||||
You maintain a growing TECHNICAL REFERENCE compiled from a training recording.
|
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
|
||||||
The user's compilation instruction is authoritative:
|
your own judgment for structure, depth, ordering, and emphasis:
|
||||||
<instruction>
|
<instruction>
|
||||||
{instruction}
|
{instruction}
|
||||||
</instruction>
|
</instruction>
|
||||||
|
|
||||||
You are given the CURRENT REFERENCE so far and the NEXT WINDOW of transcript
|
You get the document so far and the next window of transcript (sometimes with
|
||||||
(and possibly some screen frames). Integrate any new workflow/architecture detail
|
screen frames). Fold the new material into the document and return the COMPLETE
|
||||||
from this window into the reference, slotting it into the correct topical section
|
updated document, dropping nothing important from before. Keep concrete detail —
|
||||||
(create sections as needed). Return the COMPLETE updated reference in Markdown —
|
names, numbers, steps, configs, specifics — rather than collapsing to general
|
||||||
not a diff, not just the new part. Do not drop earlier content.
|
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
|
||||||
{rules}"""
|
spots (an odd acronym is probably a mis-hearing). Beyond that, write and organize
|
||||||
|
it however reads best to you."""
|
||||||
|
|
||||||
TRIAGE_SYS = """\
|
TRIAGE_SYS = """\
|
||||||
You are reading one window of a training transcript while compiling technical
|
You're compiling a detailed reference and reading this window of transcript. The
|
||||||
notes per this instruction:
|
frames listed below are screenshots referenced in it (id + [mm:ss]). List the ids
|
||||||
<instruction>
|
of any you'd find worth actually looking at — lean toward looking whenever a frame
|
||||||
{instruction}
|
might carry detail the words alone don't. Reply with JSON only:
|
||||||
</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:
|
|
||||||
{{"need": ["<frame-id>", ...]}}
|
{{"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)
|
FRAME_RE = re.compile(r"Frame:\s+(\S+\.(?:jpg|jpeg|png))", re.IGNORECASE)
|
||||||
TS_RE = re.compile(r"\[(\d+):(\d+)\]")
|
TS_RE = re.compile(r"\[(\d+):(\d+)\]")
|
||||||
@@ -98,13 +75,15 @@ def estimate_tokens(text):
|
|||||||
return int(len(text) / CHARS_PER_TOKEN)
|
return int(len(text) / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
def default_output(transcript, kind):
|
def default_output(transcript, kind, base_dir=None):
|
||||||
"""Write next to the transcript, in the same run folder, following the
|
"""Write into base_dir (default: next to the transcript, in the same run
|
||||||
pipeline's <stem>_<kind> naming (e.g. training_reference.md)."""
|
folder), following the pipeline's <stem>_<kind> naming (e.g.
|
||||||
|
training_reference.md)."""
|
||||||
stem = transcript.stem
|
stem = transcript.stem
|
||||||
if stem.endswith("_enhanced"):
|
if stem.endswith("_enhanced"):
|
||||||
stem = stem[: -len("_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):
|
def content_tokens(content):
|
||||||
@@ -183,12 +162,15 @@ def encode_image(path, max_side):
|
|||||||
return f"data:{mime};base64,{b64}"
|
return f"data:{mime};base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
def make_client(base_url, api_key):
|
def make_client(base_url, api_key, timeout):
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
except ImportError:
|
except ImportError:
|
||||||
sys.exit("ERROR: `openai` not installed here. Run under ~/wdir/llm/.venv")
|
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):
|
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()
|
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):
|
def parse_need(raw, valid_ids):
|
||||||
raw = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE)
|
raw = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE)
|
||||||
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
@@ -221,6 +227,10 @@ def main():
|
|||||||
p.add_argument("instruction", nargs="?",
|
p.add_argument("instruction", nargs="?",
|
||||||
default="Compile a detailed technical reference of the workflows and architecture covered.")
|
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("-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("--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("--base-url", default=DEFAULT_BASE_URL)
|
||||||
p.add_argument("--model", default=DEFAULT_MODEL)
|
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("--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("--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("--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("--checkpoint", type=Path, help="write the running doc here after each window (resumable progress)")
|
||||||
p.add_argument("-q", "--quiet", action="store_true")
|
p.add_argument("-q", "--quiet", action="store_true")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
@@ -246,7 +257,7 @@ def main():
|
|||||||
nframes = sum(len(w["frames"]) for w in windows)
|
nframes = sum(len(w["frames"]) for w in windows)
|
||||||
log(f"{len(windows)} windows, {nframes} frame refs, mode={args.frames}")
|
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"
|
doc = "# (compilation in progress)\n"
|
||||||
|
|
||||||
for wi, w in enumerate(windows, 1):
|
for wi, w in enumerate(windows, 1):
|
||||||
@@ -280,13 +291,22 @@ def main():
|
|||||||
content = text
|
content = text
|
||||||
log(f" window {wi}/{len(windows)}: text-only")
|
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)
|
in_tok = content_tokens(content) + estimate_tokens(sys_txt)
|
||||||
out_budget = max(512, min(args.max_tokens, args.ctx - in_tok - 256))
|
# proportional margin absorbs token-estimate error; call_fit self-corrects
|
||||||
if in_tok > args.ctx - 512:
|
# from the server's real count if it's still off.
|
||||||
log(f" WARNING: doc+window ~{in_tok} tok ≥ ctx {args.ctx}; output will truncate — "
|
out_budget = max(256, min(args.max_tokens, args.ctx - in_tok - max(512, in_tok // 20)))
|
||||||
f"use a 32k profile (qwen14b-gguf) or lower --window-tokens for the full training")
|
try:
|
||||||
doc = call(client, args.model, sys_txt, content, args.temperature, out_budget)
|
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:
|
if args.checkpoint:
|
||||||
args.checkpoint.write_text(doc + "\n")
|
args.checkpoint.write_text(doc + "\n")
|
||||||
@@ -297,7 +317,16 @@ def main():
|
|||||||
if args.stdout:
|
if args.stdout:
|
||||||
print(doc)
|
print(doc)
|
||||||
else:
|
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")
|
out.write_text(doc + "\n")
|
||||||
log(f"wrote {out}")
|
log(f"wrote {out}")
|
||||||
|
|
||||||
|
|||||||
121
ctrl/batch.sh
Executable file
121
ctrl/batch.sh
Executable 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
162
summarize_simple.py
Executable 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()
|
||||||
Reference in New Issue
Block a user