refactor (untested)

This commit is contained in:
Mariano Gabriel
2026-06-28 21:12:13 -03:00
parent 5ea05eb553
commit cc64544d50
26 changed files with 540 additions and 340 deletions

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Build an enhanced transcript that interleaves audio segments (from a
whisperx JSON) with screen frames sourced from a cht session's
frames/index.json. Frames are placed at their real timestamps rather
than appended at the end.
Usage:
python ctrl/cht/interleave_cht_frames.py \\
<transcript.json> <cht_frames_index.json> [output.txt]
"""
import json
import sys
from pathlib import Path
def fmt_ts(seconds: float) -> str:
seconds = int(seconds)
return f"{seconds // 60:02d}:{seconds % 60:02d}"
def load_audio(transcript_json: Path):
data = json.loads(transcript_json.read_text())
out = []
for s in data.get("segments", []):
out.append({
"type": "audio",
"ts": float(s.get("start", 0)),
"speaker": s.get("speaker"),
"text": (s.get("text") or "").strip(),
})
return out
def load_frames(index_json: Path):
data = json.loads(index_json.read_text())
out = []
for f in data:
out.append({
"type": "frame",
"ts": float(f["timestamp"]),
"path": f["path"],
"id": f.get("id", ""),
})
return out
def merge(audio, frames):
events = sorted(audio + frames, key=lambda e: e["ts"])
grouped = []
cur = None
for e in events:
if e["type"] == "frame":
if cur:
grouped.append(cur)
cur = None
grouped.append(e)
else:
if cur and cur.get("speaker") == e["speaker"]:
cur["text"] += " " + e["text"]
else:
if cur:
grouped.append(cur)
cur = {
"type": "audio",
"ts": e["ts"],
"speaker": e["speaker"],
"text": e["text"],
}
if cur:
grouped.append(cur)
return grouped
def render(grouped):
lines = [
"=" * 80,
"ENHANCED MEETING TRANSCRIPT",
"Audio transcript + Screen frames (interleaved by timestamp)",
"=" * 80,
"",
]
for e in grouped:
ts = fmt_ts(e["ts"])
if e["type"] == "frame":
lines.append(f"[{ts}] SCREEN CONTENT:")
lines.append(f" Frame: {e['path']}")
else:
spk = e["speaker"] or "UNKNOWN"
lines.append(f"[{ts}] {spk}:")
lines.append(f" {e['text']}")
lines.append("")
return "\n".join(lines)
def main():
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
sys.exit(2)
transcript = Path(sys.argv[1])
frames_index = Path(sys.argv[2])
output = Path(sys.argv[3]) if len(sys.argv) >= 4 else None
audio = load_audio(transcript)
frames = load_frames(frames_index)
grouped = merge(audio, frames)
text = render(grouped)
if output:
output.write_text(text)
print(f"Wrote {output}")
print(f" audio segments in : {len(audio)}")
print(f" frames in : {len(frames)}")
print(f" output blocks : {len(grouped)}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()

335
ctrl/summarize/compile_meeting.py Executable file
View File

@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
Compile a long meeting/training enhanced-transcript into a detailed technical
reference, using a LOCAL multimodal LLM that reads frames ON DEMAND.
This is NOT summarization — it RETAINS workflow/architecture detail and
reorganizes it out of conversation order. It uses the REFINE pattern: walk the
transcript top-to-bottom in windows, carrying a running compiled document as the
only large context. The running doc IS the memory; the raw transcript is never
held whole (which is why a 4-hour recording fits a small model).
Frames are consulted the way a human note-taker does: while reading each window,
the model decides which referenced frames it actually needs to see, and only
those images are attached (on demand) — webcam/transition frames cost nothing.
Standalone on purpose: not wired into process_meeting.py. Talks to a local
OpenAI-compatible server (vLLM or llama.cpp — see ~/wdir/llm/serve.sh); --base-url
swaps it.
Usage (start `~/wdir/llm/serve.sh qwen-vl` first, then):
~/wdir/llm/.venv/bin/python ctrl/summarize/compile_meeting.py \\
output/<run>/<stem>_enhanced.txt \\
"compile every deployment/data-flow workflow and the system architecture \\
as a technical reference; note the [mm:ss] each was shown on screen" \\
-o output/<run>/reference.md
Frame modes:
--frames ondemand (default) two-step: model lists which frames it needs,
then only those are attached. Cheapest on vision tokens.
--frames window attach every frame in the current window; model uses the
relevant ones. Simpler, more tokens.
--frames none ignore frames entirely (text-only; for non-VL models / A-B).
"""
import argparse
import base64
import json
import re
import sys
from pathlib import Path
DEFAULT_BASE_URL = "http://localhost:11000/v1"
DEFAULT_MODEL = "Qwen/Qwen2.5-VL-7B-Instruct-AWQ"
CHARS_PER_TOKEN = 4.0
REFINE_SYS = """\
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 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'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 seem useful."""
FRAME_RE = re.compile(r"Frame:\s+(\S+\.(?:jpg|jpeg|png))", re.IGNORECASE)
TS_RE = re.compile(r"\[(\d+):(\d+)\]")
def estimate_tokens(text):
return int(len(text) / CHARS_PER_TOKEN)
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")]
base = base_dir or transcript.parent
return base / f"{stem}_{kind}.md"
def content_tokens(content):
"""Estimate input tokens of a chat content (str or multimodal list)."""
if isinstance(content, str):
return estimate_tokens(content)
total = 0
for part in content:
if part.get("type") == "text":
total += estimate_tokens(part.get("text", ""))
else: # image_url etc. — rough per-image vision-token allowance
total += 800
return total
def parse_windows(path, window_tokens):
"""Split the enhanced transcript into windows of ~window_tokens, packing
blank-line-separated blocks whole. Each window keeps the frame refs that
fall inside it: {text, frames:[{id, ts, path}]}."""
raw = path.read_text()
blocks = re.split(r"\n\s*\n", raw)
windows, cur_text, cur_frames, cur_tok = [], [], [], 0
def flush():
if cur_text:
windows.append({"text": "\n\n".join(cur_text),
"frames": list(cur_frames)})
last_ts = "00:00"
for block in blocks:
ts_m = TS_RE.search(block)
if ts_m:
last_ts = f"{ts_m.group(1)}:{ts_m.group(2)}"
fm = FRAME_RE.search(block)
bt = estimate_tokens(block)
if cur_text and cur_tok + bt > window_tokens:
flush()
cur_text, cur_frames, cur_tok = [], [], 0
if fm:
p = fm.group(1)
cur_frames.append({"id": Path(p).stem, "ts": last_ts, "path": p})
# keep a compact ref line in the text instead of the bare path
cur_text.append(f"[{last_ts}] (frame {Path(p).stem})")
else:
cur_text.append(block)
cur_tok += bt
flush()
return windows
def resolve_path(ref_path, transcript_path):
p = Path(ref_path)
if p.is_absolute() and p.exists():
return p
# paths in the transcript are usually relative to the run dir
cand = transcript_path.parent / ref_path
return cand if cand.exists() else p
def encode_image(path, max_side):
data = path.read_bytes()
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
if max_side:
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(data))
if max(img.size) > max_side:
img.thumbnail((max_side, max_side))
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=85)
data, mime = buf.getvalue(), "image/jpeg"
except ImportError:
pass # PIL absent: send original (more tokens, still works)
b64 = base64.b64encode(data).decode()
return f"data:{mime};base64,{b64}"
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")
# 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):
resp = client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system},
{"role": "user", "content": content}],
temperature=temperature, max_tokens=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)
if not m:
return []
try:
ids = json.loads(m.group(0)).get("need", [])
except json.JSONDecodeError:
return []
return [i for i in ids if i in valid_ids]
def main():
p = argparse.ArgumentParser(
description="Compile a long meeting transcript into a technical reference (refine + on-demand frames).",
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
p.add_argument("transcript", type=Path)
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)
p.add_argument("--api-key", default="local")
p.add_argument("--frames", choices=["ondemand", "window", "none"], default="ondemand")
p.add_argument("--window-tokens", type=int, default=3500, help="transcript tokens per refine step (default 3500)")
p.add_argument("--max-tokens", type=int, default=8192, help="upper bound on generation; auto-capped to fit --ctx (default 8192)")
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()
if not args.transcript.is_file():
sys.exit(f"ERROR: transcript not found: {args.transcript}")
def log(m):
if not args.quiet:
print(f"[compile] {m}", file=sys.stderr)
windows = parse_windows(args.transcript, args.window_tokens)
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, args.timeout or None)
doc = "# (compilation in progress)\n"
for wi, w in enumerate(windows, 1):
wanted = []
if args.frames != "none" and w["frames"]:
if args.frames == "window":
wanted = w["frames"]
else: # ondemand: ask the model which frames it needs
listing = "\n".join(f"- {f['id']} [{f['ts']}]" for f in w["frames"])
raw = call(client, args.model,
TRIAGE_SYS.format(instruction=args.instruction),
f"Window transcript:\n{w['text']}\n\nReferenced frames:\n{listing}",
0.0, 256)
valid = {f["id"] for f in w["frames"]}
keep = set(parse_need(raw, valid))
wanted = [f for f in w["frames"] if f["id"] in keep]
# build the refine turn (multimodal if any frames wanted)
text = (f"CURRENT REFERENCE:\n{doc}\n\n"
f"NEXT TRANSCRIPT WINDOW:\n{w['text']}")
if wanted:
text += "\n\nAttached frames: " + ", ".join(f"{f['id']} [{f['ts']}]" for f in wanted)
content = [{"type": "text", "text": text}]
for f in wanted:
ip = resolve_path(f["path"], args.transcript)
if ip.exists():
content.append({"type": "image_url",
"image_url": {"url": encode_image(ip, args.max_image_side)}})
log(f" window {wi}/{len(windows)}: {len(wanted)} frame(s) attached")
else:
content = text
log(f" window {wi}/{len(windows)}: text-only")
sys_txt = REFINE_SYS.format(instruction=args.instruction)
in_tok = content_tokens(content) + estimate_tokens(sys_txt)
# 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")
if estimate_tokens(doc) > args.window_tokens * 4 and not args.quiet:
log(f" note: running doc ~{estimate_tokens(doc)} tok and growing — "
f"if it nears the context window, switch to a 32k-context profile (qwen14b-gguf)")
if args.stdout:
print(doc)
else:
# --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}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
Summarize / reformat a meeting's enhanced transcript with a LOCAL LLM.
Standalone on purpose: this is NOT wired into process_meeting.py. The pipeline
(transcribe + frames + OCR) stays fully deterministic and offline; this is the
one non-deterministic, network-*capable* step, so it gets its own entry point.
By default it talks to a local vLLM OpenAI-compatible server (no cloud), but the
--base-url swap lets you point it at a company-sanctioned endpoint instead.
The steering instruction is a first-class argument — pass any nuance you want
("focus on names and their roles", "read the closing signals", "reformat as a
decisions+action-items table, English output"). The instruction is threaded
into every stage (map, extract, reduce), not just the final synthesis, so the
per-chunk pass never discards the detail you asked to keep.
Architecture (ports the "let the architecture carry correctness" rule to the
summarization failure mode — hallucinated names/facts + long-input drift):
1. map — chunk the transcript, summarize each chunk under the instruction
2. extract — emit a validated JSON of facts (participants/roles/decisions/...)
3. reduce — write the final output from the validated facts + chunk notes
Usage:
# start the local server first (`~/wdir/llm/serve.sh qwen7b`), then:
python ctrl/summarize/summarize_meeting.py output/<run>/<stem>_enhanced.txt \\
"focus on the names mentioned and their roles, output in English" \\
-o output/<run>/summary_en.md
Run it under the venv that has the `openai` client (e.g. ~/wdir/llm/.venv).
"""
import argparse
import json
import re
import sys
from pathlib import Path
DEFAULT_BASE_URL = "http://localhost:11000/v1"
DEFAULT_MODEL = "Qwen/Qwen2.5-7B-Instruct-AWQ"
# Rough char->token heuristic so we don't need a tokenizer dependency. Mixed
# ES/EN prose lands around ~3.6 chars/token; 4.0 keeps us conservative (we
# under-fill rather than overflow the context window).
CHARS_PER_TOKEN = 4.0
# Shared rules injected into every stage. This is the hallucination guard: the
# model condenses what is present, it never invents — especially names/roles.
GROUNDING_RULES = """\
Rules you must follow:
- Be faithful to the transcript. Never invent names, roles, numbers, or facts.
- If something is unclear or not stated, say so — do not guess.
- Preserve proper nouns exactly as written (people, companies, tools).
- When you state a concrete claim, anchor it to its [mm:ss] timestamp.
- The transcript is machine-generated and may contain ASR errors; prefer the
most consistent reading across the whole transcript over any single garbled
line, and flag a name/term you are unsure about rather than normalizing it
silently.
- Pay special attention to acronyms and product/tool names: a slightly-off
acronym or a homophone that makes no sense in context is almost certainly an
ASR mis-hearing — flag it as "(heard: X — likely Y?)" using context to infer
the intended term, rather than repeating the nonsensical form."""
MAP_SYSTEM = """\
You are condensing ONE chunk of a longer meeting transcript.
The user's instruction for the final output is:
<instruction>
{instruction}
</instruction>
Produce dense, factual notes for THIS chunk that preserve everything relevant to
that instruction. Keep every name, role, decision, date, number, and notable
quote with its [mm:ss]. Do not write a polished summary yet — these notes are
raw material for a later synthesis pass, so keep detail over readability.
{rules}"""
EXTRACT_SYSTEM = """\
Extract structured facts from the meeting notes/transcript as STRICT JSON only —
no prose, no markdown fences. Use exactly this schema; use [] or "" when unknown:
{{
"participants": [{{"name": "", "role": "", "org": "", "evidence_ts": ""}}],
"people_mentioned": [{{"name": "", "role": "", "org": "", "evidence_ts": ""}}],
"orgs": [{{"name": "", "what": ""}}],
"decisions": [{{"decision": "", "ts": ""}}],
"action_items": [{{"item": "", "owner": "", "due": "", "ts": ""}}],
"dates": [{{"what": "", "when": "", "ts": ""}}],
"key_quotes": [{{"speaker": "", "ts": "", "quote": ""}}],
"open_questions": [""]
}}
Only include entries actually supported by the text. Distinguish participants
(present on the call) from people merely mentioned. This JSON is the source of
truth for names/roles in the final output, so be precise and do not invent.
{rules}"""
REDUCE_SYSTEM = """\
You are writing the FINAL output of a meeting from (a) the user's instruction,
(b) a validated JSON of facts, and (c) per-chunk notes.
The user's instruction is authoritative — follow it for focus, structure, and
language:
<instruction>
{instruction}
</instruction>
Use the validated facts JSON as the source of truth for all names, roles, and
dates (the notes may contain ASR noise; the JSON has been checked). Write only
what the instruction asks for. Output clean Markdown.
{rules}"""
def estimate_tokens(text: str) -> int:
return int(len(text) / CHARS_PER_TOKEN)
def default_output(transcript: Path, kind: str) -> Path:
"""Write next to the transcript, in the same run folder, following the
pipeline's <stem>_<kind> naming (e.g. keneth_aponte_summary.md)."""
stem = transcript.stem
if stem.endswith("_enhanced"):
stem = stem[: -len("_enhanced")]
return transcript.parent / f"{stem}_{kind}.md"
def load_transcript(path: Path, keep_frames: bool) -> str:
text = path.read_text()
if keep_frames:
return text
# Drop the "Frame: <path>.jpg" noise lines and their "SCREEN CONTENT:"
# headers — a text-only model can't use a file path, and they waste tokens.
# Real OCR text (if any) does not match these patterns and is kept.
lines = text.splitlines()
out = []
skip_next_blank = False
for line in lines:
if re.match(r"\s*\[\d+:\d+\]\s+SCREEN CONTENT:\s*$", line):
skip_next_blank = True
continue
if re.match(r"\s*Frame:\s+.*\.(jpg|jpeg|png)\s*$", line):
continue
out.append(line)
return "\n".join(out)
def chunk_transcript(text: str, chunk_tokens: int) -> list:
"""Split on blank-line block boundaries, packing blocks up to chunk_tokens
so we never cut a speaker turn in half."""
blocks = re.split(r"\n\s*\n", text)
chunks, cur, cur_tok = [], [], 0
for block in blocks:
bt = estimate_tokens(block)
if cur and cur_tok + bt > chunk_tokens:
chunks.append("\n\n".join(cur))
cur, cur_tok = [], 0
cur.append(block)
cur_tok += bt
if cur:
chunks.append("\n\n".join(cur))
return chunks
def make_client(base_url: str, api_key: str):
try:
from openai import OpenAI
except ImportError:
sys.exit(
"ERROR: the `openai` client is not installed in this interpreter.\n"
"Run this under the venv that has it, e.g.:\n"
" ~/wdir/llm/.venv/bin/python summarize_meeting.py ..."
)
return OpenAI(base_url=base_url, api_key=api_key)
def call(client, model, system, user, temperature, max_tokens):
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=temperature,
max_tokens=max_tokens,
)
return resp.choices[0].message.content.strip()
def parse_json_lenient(raw: str):
"""vLLM models sometimes wrap JSON in ``` fences or add a stray prefix."""
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.MULTILINE)
try:
return json.loads(raw)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", raw, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
return None
def summarize(client, args, transcript: str) -> str:
rules = GROUNDING_RULES
instr = args.instruction
budget = args.chunk_tokens
def log(msg):
if not args.quiet:
print(f"[summarize] {msg}", file=sys.stderr)
total_tok = estimate_tokens(transcript)
single_pass = args.no_map_reduce or total_tok <= budget
# --- map ---------------------------------------------------------------
if single_pass:
log(f"single-pass (~{total_tok} tok <= chunk budget {budget})")
notes = transcript
else:
chunks = chunk_transcript(transcript, budget)
log(f"map: {len(chunks)} chunks (~{total_tok} tok total)")
chunk_notes = []
for i, ch in enumerate(chunks, 1):
log(f" map chunk {i}/{len(chunks)}")
note = call(
client, args.model,
MAP_SYSTEM.format(instruction=instr, rules=rules),
ch, args.temperature, args.max_tokens,
)
chunk_notes.append(f"### Chunk {i} notes\n{note}")
notes = "\n\n".join(chunk_notes)
# --- extract -----------------------------------------------------------
facts_json = "{}"
if not args.no_extract:
log("extract: pulling structured facts")
raw = call(
client, args.model,
EXTRACT_SYSTEM.format(rules=rules),
notes if single_pass else notes + "\n\n" + transcript[: budget * 4],
0.0, args.max_tokens,
)
facts = parse_json_lenient(raw)
if facts is None:
log(" WARNING: extraction did not return valid JSON; continuing without it")
else:
facts_json = json.dumps(facts, ensure_ascii=False, indent=2)
if args.extract_only:
return facts_json
# --- reduce ------------------------------------------------------------
log("reduce: writing final output")
user = (
f"VALIDATED FACTS (source of truth for names/roles/dates):\n{facts_json}\n\n"
f"NOTES / TRANSCRIPT:\n{notes}"
)
return call(
client, args.model,
REDUCE_SYSTEM.format(instruction=instr, rules=rules),
user, args.temperature, args.max_tokens,
)
def main():
p = argparse.ArgumentParser(
description="Summarize/reformat a meeting enhanced-transcript with a local LLM.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("transcript", type=Path, help="path to *_enhanced.txt (or any text)")
p.add_argument(
"instruction", nargs="?", default="Summarize this meeting clearly.",
help='steering instruction, e.g. "focus on names and roles, English output"',
)
p.add_argument("-o", "--output", type=Path, help="write here (default: <run>/<stem>_summary.md next to the transcript)")
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, help=f"OpenAI-compatible endpoint (default: {DEFAULT_BASE_URL})")
p.add_argument("--model", default=DEFAULT_MODEL, help=f"model id (default: {DEFAULT_MODEL})")
p.add_argument("--api-key", default="local", help="ignored by vLLM; set for a real provider")
p.add_argument("--chunk-tokens", type=int, default=6000, help="map-reduce chunk budget (default: 6000)")
p.add_argument("--max-tokens", type=int, default=4096, help="generation cap per call (default: 4096)")
p.add_argument("--temperature", type=float, default=0.2, help="sampling temperature (default: 0.2)")
p.add_argument("--no-map-reduce", action="store_true", help="force single-pass (short transcripts)")
p.add_argument("--no-extract", action="store_true", help="skip the structured-facts grounding pass")
p.add_argument("--extract-only", action="store_true", help="print only the extracted JSON facts and exit")
p.add_argument("--keep-frames", action="store_true", help="keep 'Frame: <path>' lines (default: strip them)")
p.add_argument("-q", "--quiet", action="store_true", help="suppress progress on stderr")
args = p.parse_args()
if not args.transcript.is_file():
sys.exit(f"ERROR: transcript not found: {args.transcript}")
transcript = load_transcript(args.transcript, args.keep_frames)
if not transcript.strip():
sys.exit("ERROR: transcript is empty after loading.")
client = make_client(args.base_url, args.api_key)
result = summarize(client, args, transcript)
if args.stdout:
print(result)
else:
out = args.output or default_output(args.transcript, "summary")
out.write_text(result + "\n")
if not args.quiet:
print(f"[summarize] wrote {out}", file=sys.stderr)
if __name__ == "__main__":
main()

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 ctrl/summarize/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()

121
ctrl/transcribe_oneoff.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# One-off "high-quality" transcription that overrides the cached transcript
# in the most recent run directory for the given video, then re-runs the
# merger so the enhanced transcript is regenerated using the existing frames.
#
# Usage:
# ./ctrl/transcribe_oneoff.sh <video> [language]
# language: optional ISO code (es, en). Omit for auto-detect.
#
# What this does differently from the main pipeline:
# 1. Reuses the existing run directory (frames cache stays put).
# 2. Preprocesses audio: loudnorm + light denoise + speech-band filter.
# 3. Uses whisperx large-v3 with int8 quantization (fits in ~3-4 GB GPU).
# 4. Stricter no-speech / logprob thresholds to suppress hallucinations on
# silent stretches (the source of the random Arabic/etc. drift).
# 5. Backs up the old <stem>.json before overwriting, so it is recoverable.
# 6. Re-runs process_meeting.py without --run-whisper/--diarize so the new
# transcript is picked up from cache and merged with the cached frames.
set -euo pipefail
VIDEO="${1:?usage: $0 <video> [language]}"
LANG="${2:-}"
VENV_DIR="/home/mariano/wdir/venv/def"
if [[ ! -f "$VENV_DIR/bin/activate" ]]; then
echo "ERROR: venv not found at $VENV_DIR" >&2
exit 1
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
WHISPERX="whisperx"
PYTHON="python"
if [[ ! -f "$VIDEO" ]]; then
echo "ERROR: video not found: $VIDEO" >&2
exit 1
fi
STEM="$(basename "${VIDEO%.*}")"
# Locate the most recent run dir for this video stem.
RUN_DIR="$(ls -1dt output/*-"$STEM" 2>/dev/null | head -n1 || true)"
if [[ -z "$RUN_DIR" || ! -d "$RUN_DIR" ]]; then
echo "ERROR: no existing run dir found under output/ for stem '$STEM'." >&2
echo " Run process_meeting.py at least once first to extract frames." >&2
exit 1
fi
echo "==> Using run dir: $RUN_DIR"
TRANSCRIPT_JSON="$RUN_DIR/${STEM}.json"
# Back up the old transcript before overwrite.
if [[ -f "$TRANSCRIPT_JSON" ]]; then
BACKUP="$TRANSCRIPT_JSON.bak.$(date +%Y%m%d-%H%M%S)"
cp "$TRANSCRIPT_JSON" "$BACKUP"
echo "==> Backed up old transcript → $BACKUP"
fi
# No audio preprocessing: previous attempts with afftdn/loudnorm caused VAD
# to drop the bulk of the meeting after ~20min. Feed the raw video directly;
# whisperx will extract audio internally.
INPUT_AUDIO="$VIDEO"
# cuDNN libs for whisperx (mirrors what process_meeting.py does).
SITE_PKGS="$(python -c 'import site; print(site.getsitepackages()[0])')"
CUDNN_LIB="$SITE_PKGS/nvidia/cudnn/lib"
if [[ -d "$CUDNN_LIB" ]]; then
export LD_LIBRARY_PATH="$CUDNN_LIB:${LD_LIBRARY_PATH:-}"
fi
# whisperx writes <input_basename>.json into --output_dir.
# Our input basename is "${STEM}_clean", so we redirect to a temp dir and
# move the result into place under the canonical name.
TX_TMP="$(mktemp -d)"
trap 'rm -rf "$TX_TMP"' EXIT
CMD=(
"$WHISPERX" "$INPUT_AUDIO"
--model large-v3
--compute_type int8
--batch_size 4
--output_format json
--output_dir "$TX_TMP"
--diarize
)
if [[ -n "$LANG" ]]; then
echo "==> Forcing language: $LANG"
CMD+=(--language "$LANG")
else
echo "==> Auto-detecting language"
fi
if [[ -n "${HF_TOKEN:-}" ]]; then
CMD+=(--hf_token "$HF_TOKEN")
fi
echo "==> Running: ${CMD[*]}"
"${CMD[@]}"
# Move whisperx output into place under the canonical name expected by the cache.
# whisperx names output by the input basename (without extension).
NEW_JSON="$TX_TMP/${STEM}.json"
if [[ ! -f "$NEW_JSON" ]]; then
echo "ERROR: expected whisperx output not found: $NEW_JSON" >&2
exit 1
fi
mv "$NEW_JSON" "$TRANSCRIPT_JSON"
echo "==> Wrote new transcript → $TRANSCRIPT_JSON"
echo
echo "==> Re-running merger to regenerate enhanced transcript with cached frames"
"$PYTHON" process_meeting.py "$VIDEO" \
--embed-images \
--scene-detection \
--scene-threshold 10
echo
echo "==> Done."