refactor (untested)
This commit is contained in:
120
ctrl/cht/interleave_cht_frames.py
Normal file
120
ctrl/cht/interleave_cht_frames.py
Normal 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
335
ctrl/summarize/compile_meeting.py
Executable 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()
|
||||
313
ctrl/summarize/summarize_meeting.py
Executable file
313
ctrl/summarize/summarize_meeting.py
Executable 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()
|
||||
162
ctrl/summarize/summarize_simple.py
Executable file
162
ctrl/summarize/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 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
121
ctrl/transcribe_oneoff.sh
Executable 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."
|
||||
Reference in New Issue
Block a user