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

84
INDEX.md Normal file
View File

@@ -0,0 +1,84 @@
# meetus — repo index
A standalone, local-first **command-line tool**: turn screen-share meeting/training
recordings into a rich, AI-summarizable transcript. The extraction pipeline is
deterministic and offline; the summarization step is the only LLM-facing part.
meetus stands on its own. The separate **`cht`** project does the same thing in
realtime; meetus may or may not feed it, so the two are kept formally apart (the
cht-facing bits here live under `ctrl/cht/`).
> This file maps the whole repo. [`README.md`](README.md) is the detailed manual for
> the main `process_meeting.py` CLI.
## Data flow
```
video / audio
│ process_meeting.py (deterministic, offline)
output/<run>/ run folder: YYYYMMDD-NNN-<stem>/
├── <stem>.json Whisper/WhisperX transcript (base)
├── <stem>.srt / .vtt / ... extra transcript formats (optional)
├── frames/ extracted scene/interval frames
├── <stem>_enhanced.txt transcript + frame refs ◀── the product
└── manifest.json
│ ctrl/summarize/*.py (local LLM — WIP, on hold)
<stem>_summary_simple.md / _reference.md
```
`make batch` (→ `ctrl/batch.sh`) runs the pipeline over a whole directory tree,
mirroring the input folder structure into the output.
## Status legend
✅ active · 🚧 WIP, on hold · 🗄️ deprecated (kept for reference) · 🧰 one-off / niche · 📄 docs
## Layout
```
process_meeting.py ✅ the CLI tool — entry point (stays at root)
Makefile ✅ batch convenience wrapper
meetus/ ✅ core package
├─ workflow.py orchestrator (whisper → frames → merge)
├─ frame_extractor.py FFmpeg scene-detection / interval frames
├─ transcript_merger.py interleave transcript + frame refs → enhanced.txt
├─ output_manager.py run-folder naming + manifest.json
├─ cache_manager.py per-step caching (skip done work on rerun)
└─ deprecated/ 🗄️ old screen-text idea (OCR/vision/hybrid) — unwired, reference only
├─ ocr_processor.py (was --ocr-engine)
├─ vision_processor.py (was --use-vision)
├─ hybrid_processor.py (was --use-hybrid)
└─ prompts/ vision context prompts
ctrl/ control plane / operational scripts
├─ batch.sh ✅ recursive batch runner (mirrors tree, continues past failures)
├─ transcribe_oneoff.sh 🧰 high-quality re-transcription over an existing run
├─ summarize/ 🚧 last step — local-LLM summarization (WIP, on hold)
│ ├─ summarize_simple.py minimal map-and-append; reads every referenced frame
│ ├─ compile_meeting.py REFINE-pattern technical reference; on-demand frames
│ └─ summarize_meeting.py map→extract(validated facts)→reduce
└─ cht/ 🧰 bridge to the separate realtime `cht` project
└─ interleave_cht_frames.py whisperx JSON + cht frames/index.json → enhanced.txt
def/ 📄 design/decision notes, in order (the feature history)
README.md 📄 manual for process_meeting.py
INDEX.md 📄 this file
MARIAN.md 📄 genesis brainstorm (explains why the deprecated OCR path exists)
local-run.sh 📄 personal scratch invocations (gitignored)
```
## Notes
- **Default flow** uses `--embed-images` (frames referenced for the LLM to read) +
`--scene-detection --scene-threshold 10 --diarize`. `make batch` adds
`--transcript-formats srt` and writes outputs next to the sources. See `README.md`.
- **`meetus/deprecated/`** is no longer imported or reachable from the CLI (its flags
were removed and `workflow.py` no longer imports it). Kept for reference only; the
realtime continuation of the idea is the separate `cht` project.
- **`ctrl/summarize/`** scripts are standalone; run them under an env with the `openai`
client (e.g. `~/wdir/llm/.venv`) against a local OpenAI-compatible server.
## Loose ends
- `README.md`'s "Output Files" example still shows the old run-folder format
`YYYYMMDD_HHMMSS-video` (actual: `YYYYMMDD-NNN-<stem>`). Minor; worth a tidy.

View File

@@ -104,7 +104,7 @@ python process_meeting.py samples/meeting.mkv --embed-images --interval 3 --diar
python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 10 --diarize python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 10 --diarize
# Iterate on scene threshold (reuse whisper transcript) # Iterate on scene threshold (reuse whisper transcript)
python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames --skip-cache-analysis python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames
# Re-run whisper only # Re-run whisper only
python process_meeting.py samples/meeting.mkv --embed-images --skip-cache-whisper python process_meeting.py samples/meeting.mkv --embed-images --skip-cache-whisper
@@ -150,7 +150,6 @@ The tool automatically reuses the most recent output directory for the same vide
- `--no-cache`: Force complete reprocessing - `--no-cache`: Force complete reprocessing
- `--skip-cache-frames`: Re-extract frames only - `--skip-cache-frames`: Re-extract frames only
- `--skip-cache-whisper`: Re-run transcription only - `--skip-cache-whisper`: Re-run transcription only
- `--skip-cache-analysis`: Re-run analysis only
This allows you to iterate on scene detection thresholds without re-running Whisper! This allows you to iterate on scene detection thresholds without re-running Whisper!
@@ -169,7 +168,7 @@ python process_meeting.py samples/meeting.mkv --embed-images --scene-detection -
python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 10 --diarize python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 10 --diarize
# Adjust scene threshold (keeps cached whisper transcript) # Adjust scene threshold (keeps cached whisper transcript)
python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames --skip-cache-analysis python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames
``` ```
### Example Prompt for Claude ### Example Prompt for Claude
@@ -186,42 +185,17 @@ Please summarize this meeting transcript. Pay special attention to:
## Command Reference ## Command Reference
``` `process_meeting.py --help` is the source of truth for flags — run it rather than
usage: process_meeting.py [-h] [--transcript TRANSCRIPT] [--run-whisper] relying on a copy here. The essentials:
[--whisper-model {tiny,base,small,medium,large}]
[--diarize] [--output OUTPUT] [--output-dir OUTPUT_DIR]
[--interval INTERVAL] [--scene-detection]
[--scene-threshold SCENE_THRESHOLD]
[--embed-images] [--embed-quality EMBED_QUALITY]
[--no-cache] [--skip-cache-frames] [--skip-cache-whisper]
[--skip-cache-analysis] [--no-deduplicate]
[--extract-only] [--format {detailed,compact}]
[--verbose] video
Main Options: - `--diarize` — WhisperX with speaker diarization (needs a HuggingFace token)
video Path to video file - `--embed-images` — reference frames in the transcript for the LLM (default behavior)
--diarize Use WhisperX with speaker diarization - `--scene-detection` / `--scene-threshold N` — frame extraction (lower = more frames)
--embed-images Add frame file references to transcript (recommended) - `--interval N` — fixed-interval extraction instead of scene detection
- `--transcript-formats srt,vtt,…` — extra transcript formats alongside JSON
- `--no-cache` / `--skip-cache-frames` / `--skip-cache-whisper` — cache control
Frame Extraction: For batches, prefer `make batch IN=<dir>` (see [`INDEX.md`](INDEX.md)).
--scene-detection Use FFmpeg scene detection (recommended)
--scene-threshold Detection sensitivity 0-100 (default: 15, lower=more sensitive)
--interval Extract frame every N seconds (alternative to scene detection)
Caching:
--no-cache Force complete reprocessing
--skip-cache-frames Re-extract frames only
--skip-cache-whisper Re-run transcription only
--skip-cache-analysis Re-run analysis only
Other:
--run-whisper Run Whisper (without diarization)
--whisper-model Whisper model: tiny, base, small, medium, large (default: medium)
--transcript, -t Path to existing Whisper transcript (JSON or TXT)
--output, -o Output file for enhanced transcript
--output-dir Directory for output files (default: output/)
--verbose, -v Enable verbose logging
```
## Tips for Best Results ## Tips for Best Results
@@ -238,10 +212,6 @@ Other:
- **Whisper** (`--run-whisper`): Standard transcription, fast - **Whisper** (`--run-whisper`): Standard transcription, fast
- **WhisperX** (`--run-whisper --diarize`): Adds speaker identification, requires HuggingFace token - **WhisperX** (`--run-whisper --diarize`): Adds speaker identification, requires HuggingFace token
### Deduplication
- Enabled by default - removes similar consecutive frames
- Disable with `--no-deduplicate` if slides/screens change subtly
## Troubleshooting ## Troubleshooting
### Frame Extraction Issues ### Frame Extraction Issues
@@ -274,44 +244,37 @@ Other:
**Want to re-run specific steps** **Want to re-run specific steps**
- `--skip-cache-frames`: Re-extract frames - `--skip-cache-frames`: Re-extract frames
- `--skip-cache-whisper`: Re-run transcription - `--skip-cache-whisper`: Re-run transcription
- `--skip-cache-analysis`: Re-run analysis
- `--no-cache`: Force complete reprocessing - `--no-cache`: Force complete reprocessing
## Experimental Features ## Deprecated Features (kept for reference)
### OCR and Vision Analysis ### OCR and Vision Analysis
OCR (`--ocr-engine`) and Vision analysis (`--use-vision`) options are available but experimental. The recommended approach is to use `--embed-images` which embeds frame references directly in the transcript, letting your LLM analyze the images. OCR, Vision and Hybrid screen-text analysis were the original approach but went nowhere. They have been **removed from the CLI** (the `--ocr-engine` / `--use-vision` / `--use-hybrid` flags no longer exist) and now live, unwired, in `meetus/deprecated/` for reference only. The tool always references frames (`--embed-images`) so your LLM reads them directly. The realtime continuation of the idea is the separate `cht` project. See [`INDEX.md`](INDEX.md).
```bash
# Experimental: OCR extraction
python process_meeting.py samples/meeting.mkv --run-whisper --ocr-engine tesseract
# Experimental: Vision model analysis
python process_meeting.py samples/meeting.mkv --run-whisper --use-vision --vision-model llava:13b
# Experimental: Hybrid OpenCV + OCR
python process_meeting.py samples/meeting.mkv --run-whisper --use-hybrid
```
## Project Structure ## Project Structure
See [`INDEX.md`](INDEX.md) for the full repo map. In brief:
``` ```
meetus/ meetus/
├── meetus/ # Main package ├── process_meeting.py # Main CLI script (entry point)
│ ├── __init__.py ├── Makefile # `make batch` convenience wrapper
├── meetus/ # Core package
│ ├── workflow.py # Processing orchestrator │ ├── workflow.py # Processing orchestrator
│ ├── output_manager.py # Output directory & manifest management │ ├── frame_extractor.py # Frame extraction (FFmpeg scene detection)
│ ├── cache_manager.py # Caching logic │ ├── transcript_merger.py # Transcript + frame-ref merging
│ ├── frame_extractor.py # Video frame extraction (FFmpeg scene detection) │ ├── output_manager.py # Run dirs (YYYYMMDD-NNN-<stem>) & manifest
│ ├── vision_processor.py # Vision model analysis (experimental) │ ├── cache_manager.py # Per-step caching
── ocr_processor.py # OCR processing (experimental) ── deprecated/ # Old OCR/vision/hybrid analysis (reference only)
│ └── transcript_merger.py # Transcript merging ├── ctrl/ # Control plane / operational scripts
├── process_meeting.py # Main CLI script │ ├── batch.sh # Recursive batch runner
├── requirements.txt # Python dependencies │ ├── transcribe_oneoff.sh # High-quality re-transcription
├── output/ # Timestamped output directories │ ├── summarize/ # Local-LLM summarization (WIP, on hold)
│ └── YYYYMMDD_HHMMSS-video/ # Auto-generated per video │ └── cht/ # Bridge to the realtime `cht` project
├── samples/ # Sample videos (gitignored) ├── def/ # Design/decision notes
├── output/ # Run directories (gitignored)
├── samples/ # Sample inputs (gitignored)
└── README.md # This file └── README.md # This file
``` ```

View File

@@ -6,7 +6,7 @@ frames/index.json. Frames are placed at their real timestamps rather
than appended at the end. than appended at the end.
Usage: Usage:
python interleave_cht_frames.py \\ python ctrl/cht/interleave_cht_frames.py \\
<transcript.json> <cht_frames_index.json> [output.txt] <transcript.json> <cht_frames_index.json> [output.txt]
""" """
import json import json

View File

@@ -18,7 +18,7 @@ OpenAI-compatible server (vLLM or llama.cpp — see ~/wdir/llm/serve.sh); --base
swaps it. swaps it.
Usage (start `~/wdir/llm/serve.sh qwen-vl` first, then): Usage (start `~/wdir/llm/serve.sh qwen-vl` first, then):
~/wdir/llm/.venv/bin/python compile_meeting.py \\ ~/wdir/llm/.venv/bin/python ctrl/summarize/compile_meeting.py \\
output/<run>/<stem>_enhanced.txt \\ output/<run>/<stem>_enhanced.txt \\
"compile every deployment/data-flow workflow and the system architecture \\ "compile every deployment/data-flow workflow and the system architecture \\
as a technical reference; note the [mm:ss] each was shown on screen" \\ as a technical reference; note the [mm:ss] each was shown on screen" \\

View File

@@ -22,7 +22,7 @@ summarization failure mode — hallucinated names/facts + long-input drift):
Usage: Usage:
# start the local server first (`~/wdir/llm/serve.sh qwen7b`), then: # start the local server first (`~/wdir/llm/serve.sh qwen7b`), then:
python summarize_meeting.py output/<run>/<stem>_enhanced.txt \\ python ctrl/summarize/summarize_meeting.py output/<run>/<stem>_enhanced.txt \\
"focus on the names mentioned and their roles, output in English" \\ "focus on the names mentioned and their roles, output in English" \\
-o output/<run>/summary_en.md -o output/<run>/summary_en.md

View File

@@ -8,7 +8,7 @@ Edit SYS below to change the steer.
Talks to a local OpenAI-compatible endpoint (ollama / vLLM / llama.cpp). Talks to a local OpenAI-compatible endpoint (ollama / vLLM / llama.cpp).
Usage: Usage:
~/wdir/llm/.venv/bin/python summarize_simple.py <stem>_enhanced.txt \\ ~/wdir/llm/.venv/bin/python ctrl/summarize/summarize_simple.py <stem>_enhanced.txt \\
--base-url http://localhost:11434/v1 --model gemma3-27b-16k --base-url http://localhost:11434/v1 --model gemma3-27b-16k
""" """
import argparse, base64, io, re, sys import argparse, base64, io, re, sys

View File

@@ -4,7 +4,7 @@
# merger so the enhanced transcript is regenerated using the existing frames. # merger so the enhanced transcript is regenerated using the existing frames.
# #
# Usage: # Usage:
# ./transcribe_oneoff.sh <video> [language] # ./ctrl/transcribe_oneoff.sh <video> [language]
# language: optional ISO code (es, en). Omit for auto-detect. # language: optional ISO code (es, en). Omit for auto-detect.
# #
# What this does differently from the main pipeline: # What this does differently from the main pipeline:

View File

@@ -0,0 +1,11 @@
"""
Deprecated frame-analysis code, kept for reference only.
These were the original approach (extract screen text via OCR / vision / hybrid
and bake it into the transcript). They were superseded by the --embed-images flow,
where frames are referenced and the summarizing LLM reads them directly.
No longer wired into the CLI: the --use-vision / --use-hybrid / --ocr-engine flags
were removed and workflow.py no longer imports this package. Kept for reference only.
The realtime continuation of this idea lives in the separate `cht` project.
"""

View File

@@ -1,6 +1,10 @@
""" """
Hybrid frame analysis: OpenCV text detection + OCR for accurate extraction. Hybrid frame analysis: OpenCV text detection + OCR for accurate extraction.
Better than pure vision models which tend to hallucinate text content. Better than pure vision models which tend to hallucinate text content.
STATUS: deprecated, reference only the hybrid path was superseded by --embed-images
(frames are referenced so the summarizing LLM reads them directly). The --use-hybrid /
--hybrid-llm-cleanup flags were removed; this is no longer wired into the CLI. See INDEX.md.
""" """
from typing import List, Tuple, Dict, Optional from typing import List, Tuple, Dict, Optional
from pathlib import Path from pathlib import Path

View File

@@ -1,6 +1,10 @@
""" """
OCR processing for extracted video frames. OCR processing for extracted video frames.
Supports multiple OCR engines and text deduplication. Supports multiple OCR engines and text deduplication.
STATUS: deprecated, reference only the OCR path was superseded by --embed-images
(frames are referenced so the summarizing LLM reads them directly). The --ocr-engine
flag was removed; this is no longer wired into the CLI. See INDEX.md.
""" """
from typing import List, Tuple, Dict, Optional from typing import List, Tuple, Dict, Optional
from pathlib import Path from pathlib import Path

View File

@@ -1,6 +1,10 @@
""" """
Vision-based frame analysis using local vision-language models via Ollama. Vision-based frame analysis using local vision-language models via Ollama.
Better than OCR for understanding dashboards, code, and console output. Better than OCR for understanding dashboards, code, and console output.
STATUS: deprecated, reference only the vision path was superseded by --embed-images
(frames are referenced so the summarizing LLM reads them directly). The --use-vision
flag was removed; this is no longer wired into the CLI. See INDEX.md.
""" """
from typing import List, Tuple, Dict, Optional from typing import List, Tuple, Dict, Optional
from pathlib import Path from pathlib import Path

View File

@@ -110,8 +110,7 @@ class OutputManager:
"outputs": { "outputs": {
"frames": str(self.frames_dir.relative_to(self.output_dir)), "frames": str(self.frames_dir.relative_to(self.output_dir)),
"enhanced_transcript": f"{self.video_path.stem}_enhanced.txt", "enhanced_transcript": f"{self.video_path.stem}_enhanced.txt",
"whisper_transcript": f"{self.video_path.stem}.json" if config.get("run_whisper") else None, "whisper_transcript": f"{self.video_path.stem}.json" if config.get("run_whisper") else None
"analysis": f"{self.video_path.stem}_{'vision' if config.get('use_vision') else 'ocr'}.json"
} }
} }

View File

@@ -12,8 +12,6 @@ from typing import Dict, Any, Optional
from .output_manager import OutputManager from .output_manager import OutputManager
from .cache_manager import CacheManager from .cache_manager import CacheManager
from .frame_extractor import FrameExtractor from .frame_extractor import FrameExtractor
from .ocr_processor import OCRProcessor
from .vision_processor import VisionProcessor
from .transcript_merger import TranscriptMerger from .transcript_merger import TranscriptMerger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,25 +49,7 @@ class WorkflowConfig:
self.scene_threshold = kwargs.get('scene_threshold', 15.0) self.scene_threshold = kwargs.get('scene_threshold', 15.0)
self.interval = kwargs.get('interval', 5) self.interval = kwargs.get('interval', 5)
# Analysis options
self.use_vision = kwargs.get('use_vision', False)
self.use_hybrid = kwargs.get('use_hybrid', False)
self.hybrid_llm_cleanup = kwargs.get('hybrid_llm_cleanup', False)
self.hybrid_llm_model = kwargs.get('hybrid_llm_model', 'llama3.2:3b')
self.vision_model = kwargs.get('vision_model', 'llava:13b')
self.vision_context = kwargs.get('vision_context', 'meeting')
self.ocr_engine = kwargs.get('ocr_engine', 'tesseract')
# Validation: can't use both vision and hybrid
if self.use_vision and self.use_hybrid:
raise ValueError("Cannot use both --use-vision and --use-hybrid. Choose one.")
# Validation: LLM cleanup requires hybrid mode
if self.hybrid_llm_cleanup and not self.use_hybrid:
raise ValueError("--hybrid-llm-cleanup requires --use-hybrid")
# Processing options # Processing options
self.no_deduplicate = kwargs.get('no_deduplicate', False)
self.no_cache = kwargs.get('no_cache', False) self.no_cache = kwargs.get('no_cache', False)
self.skip_cache_frames = kwargs.get('skip_cache_frames', False) self.skip_cache_frames = kwargs.get('skip_cache_frames', False)
self.skip_cache_whisper = kwargs.get('skip_cache_whisper', False) self.skip_cache_whisper = kwargs.get('skip_cache_whisper', False)
@@ -124,11 +104,7 @@ class WorkflowConfig:
"scene_threshold": self.scene_threshold if self.scene_detection else None "scene_threshold": self.scene_threshold if self.scene_detection else None
}, },
"analysis": { "analysis": {
"method": "vision" if self.use_vision else ("hybrid" if self.use_hybrid else "ocr"), "method": "embed-images"
"vision_model": self.vision_model if self.use_vision else None,
"vision_context": self.vision_context if self.use_vision else None,
"ocr_engine": self.ocr_engine if (not self.use_vision) else None,
"deduplication": not self.no_deduplicate
}, },
"output_format": self.format "output_format": self.format
} }
@@ -172,18 +148,7 @@ class ProcessingWorkflow:
logger.info("=" * 80) logger.info("=" * 80)
logger.info(f"Video: {self.config.video_path.name}") logger.info(f"Video: {self.config.video_path.name}")
# Determine analysis method logger.info("Screen content: frame references (embed-images)")
if self.config.use_vision:
analysis_method = f"Vision Model ({self.config.vision_model})"
logger.info(f"Analysis: {analysis_method}")
logger.info(f"Context: {self.config.vision_context}")
elif self.config.use_hybrid:
analysis_method = f"Hybrid (OpenCV + {self.config.ocr_engine})"
logger.info(f"Analysis: {analysis_method}")
else:
analysis_method = f"OCR ({self.config.ocr_engine})"
logger.info(f"Analysis: {analysis_method}")
logger.info(f"Frame extraction: {'Scene detection' if self.config.scene_detection else f'Every {self.config.interval}s'}") logger.info(f"Frame extraction: {'Scene detection' if self.config.scene_detection else f'Every {self.config.interval}s'}")
logger.info(f"Caching: {'Disabled' if self.config.no_cache else 'Enabled'}") logger.info(f"Caching: {'Disabled' if self.config.no_cache else 'Enabled'}")
logger.info("=" * 80) logger.info("=" * 80)
@@ -198,8 +163,8 @@ class ProcessingWorkflow:
logger.error("No frames extracted") logger.error("No frames extracted")
raise RuntimeError("Frame extraction failed") raise RuntimeError("Frame extraction failed")
# Step 2: Analyze frames # Step 2: Prepare frame references
screen_segments = self._analyze_frames(frames_info) screen_segments = self._prepare_frames(frames_info)
if self.config.extract_only: if self.config.extract_only:
logger.info("Done! (extract-only mode)") logger.info("Done! (extract-only mode)")
@@ -243,16 +208,6 @@ class ProcessingWorkflow:
raise RuntimeError("Whisper not installed") raise RuntimeError("Whisper not installed")
transcribe_cmd = "whisper" transcribe_cmd = "whisper"
# Unload Ollama model to free GPU memory for Whisper (if using vision)
if self.config.use_vision:
logger.info("Freeing GPU memory for Whisper...")
try:
subprocess.run(["ollama", "stop", self.config.vision_model],
capture_output=True, check=False)
logger.info("✓ Ollama model unloaded")
except Exception as e:
logger.warning(f"Could not unload Ollama model: {e}")
if use_diarize: if use_diarize:
logger.info(f"Running WhisperX transcription with diarization (model: {self.config.whisper_model})...") logger.info(f"Running WhisperX transcription with diarization (model: {self.config.whisper_model})...")
else: else:
@@ -391,155 +346,19 @@ class ProcessingWorkflow:
logger.info(f"✓ Extracted {len(frames_info)} frames") logger.info(f"✓ Extracted {len(frames_info)} frames")
return frames_info return frames_info
def _analyze_frames(self, frames_info): def _prepare_frames(self, frames_info):
"""Analyze frames with vision, hybrid, or OCR.""" """Build screen segments that reference each extracted frame by path.
# Skip analysis if just embedding images
if self.config.embed_images: The transcript merger embeds these references so the summarizing LLM reads
logger.info("Step 2: Skipping analysis (images will be embedded)") the frames directly. This is the only screen-content mode (the old in-process
# Create minimal segments with just frame paths and timestamps OCR / vision / hybrid analysis lives in meetus/deprecated/, unwired)."""
screen_segments = [ screen_segments = [
{ {'timestamp': timestamp, 'text': '', 'frame_path': frame_path}
'timestamp': timestamp,
'text': '', # No text extraction needed
'frame_path': frame_path
}
for frame_path, timestamp in frames_info for frame_path, timestamp in frames_info
] ]
logger.info(f" Prepared {len(screen_segments)} frames for embedding") logger.info(f"Step 2: Prepared {len(screen_segments)} frame references")
return screen_segments return screen_segments
# Determine analysis type
if self.config.use_vision:
analysis_type = 'vision'
elif self.config.use_hybrid:
analysis_type = 'hybrid'
else:
analysis_type = 'ocr'
# Check cache
cached_analysis = self.cache_mgr.get_analysis_cache(analysis_type)
if cached_analysis:
return cached_analysis
if self.config.use_vision:
return self._run_vision_analysis(frames_info)
elif self.config.use_hybrid:
return self._run_hybrid_analysis(frames_info)
else:
return self._run_ocr_analysis(frames_info)
def _run_vision_analysis(self, frames_info):
"""Run vision analysis on frames."""
logger.info("Step 2: Running vision analysis on extracted frames...")
logger.info(f"Loading vision model {self.config.vision_model} to GPU...")
# Load audio segments for context if transcript exists
audio_segments = []
transcript_path = self.config.transcript_path or self._get_cached_transcript()
if transcript_path:
transcript_file = Path(transcript_path)
if transcript_file.exists():
logger.info("Loading audio transcript for context...")
merger = TranscriptMerger()
audio_segments = merger.load_whisper_transcript(str(transcript_file))
logger.info(f"✓ Loaded {len(audio_segments)} audio segments for context")
try:
vision = VisionProcessor(model=self.config.vision_model)
screen_segments = vision.process_frames(
frames_info,
context=self.config.vision_context,
deduplicate=not self.config.no_deduplicate,
audio_segments=audio_segments
)
logger.info(f"✓ Analyzed {len(screen_segments)} frames with vision model")
# Debug: Show sample analysis results
if screen_segments:
logger.debug(f"First analysis result: timestamp={screen_segments[0].get('timestamp')}, text_length={len(screen_segments[0].get('text', ''))}")
logger.debug(f"First analysis text preview: {screen_segments[0].get('text', '')[:200]}...")
if len(screen_segments) > 1:
logger.debug(f"Last analysis result: timestamp={screen_segments[-1].get('timestamp')}, text_length={len(screen_segments[-1].get('text', ''))}")
# Cache results
self.cache_mgr.save_analysis('vision', screen_segments)
return screen_segments
except ImportError as e:
logger.error(f"{e}")
raise
def _get_cached_transcript(self) -> Optional[str]:
"""Get cached Whisper transcript if available."""
cached = self.cache_mgr.get_whisper_cache()
return str(cached) if cached else None
def _run_hybrid_analysis(self, frames_info):
"""Run hybrid analysis on frames (OpenCV + OCR)."""
if self.config.hybrid_llm_cleanup:
logger.info("Step 2: Running hybrid analysis (OpenCV + OCR + LLM cleanup)...")
else:
logger.info("Step 2: Running hybrid analysis (OpenCV text detection + OCR)...")
try:
from .hybrid_processor import HybridProcessor
hybrid = HybridProcessor(
ocr_engine=self.config.ocr_engine,
use_llm_cleanup=self.config.hybrid_llm_cleanup,
llm_model=self.config.hybrid_llm_model
)
screen_segments = hybrid.process_frames(
frames_info,
deduplicate=not self.config.no_deduplicate
)
logger.info(f"✓ Processed {len(screen_segments)} frames with hybrid analysis")
# Debug: Show sample hybrid results
if screen_segments:
logger.debug(f"First hybrid result: timestamp={screen_segments[0].get('timestamp')}, text_length={len(screen_segments[0].get('text', ''))}")
logger.debug(f"First hybrid text preview: {screen_segments[0].get('text', '')[:200]}...")
if len(screen_segments) > 1:
logger.debug(f"Last hybrid result: timestamp={screen_segments[-1].get('timestamp')}, text_length={len(screen_segments[-1].get('text', ''))}")
# Cache results
self.cache_mgr.save_analysis('hybrid', screen_segments)
return screen_segments
except ImportError as e:
logger.error(f"{e}")
raise
def _run_ocr_analysis(self, frames_info):
"""Run OCR analysis on frames."""
logger.info("Step 2: Running OCR on extracted frames...")
try:
ocr = OCRProcessor(engine=self.config.ocr_engine)
screen_segments = ocr.process_frames(
frames_info,
deduplicate=not self.config.no_deduplicate
)
logger.info(f"✓ Processed {len(screen_segments)} frames with OCR")
# Debug: Show sample OCR results
if screen_segments:
logger.debug(f"First OCR result: timestamp={screen_segments[0].get('timestamp')}, text_length={len(screen_segments[0].get('text', ''))}")
logger.debug(f"First OCR text preview: {screen_segments[0].get('text', '')[:200]}...")
if len(screen_segments) > 1:
logger.debug(f"Last OCR result: timestamp={screen_segments[-1].get('timestamp')}, text_length={len(screen_segments[-1].get('text', ''))}")
# Cache results
self.cache_mgr.save_analysis('ocr', screen_segments)
return screen_segments
except ImportError as e:
logger.error(f"{e}")
logger.error(f"To install {self.config.ocr_engine}:")
logger.error(f" pip install {self.config.ocr_engine}")
raise
def _merge_transcripts(self, transcript_path, screen_segments): def _merge_transcripts(self, transcript_path, screen_segments):
"""Merge audio and screen transcripts.""" """Merge audio and screen transcripts."""
merger = TranscriptMerger( merger = TranscriptMerger(
@@ -586,18 +405,9 @@ class ProcessingWorkflow:
def _build_result(self, transcript_path=None, screen_segments=None, enhanced_transcript=None): def _build_result(self, transcript_path=None, screen_segments=None, enhanced_transcript=None):
"""Build result dictionary.""" """Build result dictionary."""
# Determine analysis filename
if self.config.use_vision:
analysis_type = 'vision'
elif self.config.use_hybrid:
analysis_type = 'hybrid'
else:
analysis_type = 'ocr'
return { return {
"output_dir": str(self.output_mgr.output_dir), "output_dir": str(self.output_mgr.output_dir),
"transcript": transcript_path, "transcript": transcript_path,
"analysis": f"{self.config.video_path.stem}_{analysis_type}.json",
"frames_count": len(screen_segments) if screen_segments else 0, "frames_count": len(screen_segments) if screen_segments else 0,
"enhanced_transcript": enhanced_transcript, "enhanced_transcript": enhanced_transcript,
"manifest": str(self.output_mgr.get_path("manifest.json")) "manifest": str(self.output_mgr.get_path("manifest.json"))

View File

@@ -38,14 +38,8 @@ Examples:
# Adjust frame extraction quality (lower = smaller files) # Adjust frame extraction quality (lower = smaller files)
python process_meeting.py samples/meeting.mkv --run-whisper --embed-images --embed-quality 60 --scene-detection python process_meeting.py samples/meeting.mkv --run-whisper --embed-images --embed-quality 60 --scene-detection
# Hybrid approach: OpenCV + OCR (extracts text from frames)
python process_meeting.py samples/meeting.mkv --run-whisper --use-hybrid --scene-detection
# Hybrid + LLM cleanup (best for code formatting)
python process_meeting.py samples/meeting.mkv --run-whisper --use-hybrid --hybrid-llm-cleanup --scene-detection
# Iterate on scene threshold (reuse whisper transcript) # Iterate on scene threshold (reuse whisper transcript)
python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames --skip-cache-analysis python process_meeting.py samples/meeting.mkv --embed-images --scene-detection --scene-threshold 5 --skip-cache-frames
""" """
) )
@@ -123,45 +117,6 @@ Examples:
default=15.0 default=15.0
) )
# Analysis options
parser.add_argument(
'--ocr-engine',
choices=['tesseract', 'easyocr', 'paddleocr'],
help='OCR engine to use (default: tesseract)',
default='tesseract'
)
parser.add_argument(
'--use-vision',
action='store_true',
help='Use local vision model (Ollama) instead of OCR for better context understanding'
)
parser.add_argument(
'--use-hybrid',
action='store_true',
help='Use hybrid approach: OpenCV text detection + OCR (more accurate than vision models)'
)
parser.add_argument(
'--hybrid-llm-cleanup',
action='store_true',
help='Use LLM to clean up OCR output and preserve code formatting (requires --use-hybrid)'
)
parser.add_argument(
'--hybrid-llm-model',
help='LLM model for cleanup (default: llama3.2:3b)',
default='llama3.2:3b'
)
parser.add_argument(
'--vision-model',
help='Vision model to use with Ollama (default: llava:13b)',
default='llava:13b'
)
parser.add_argument(
'--vision-context',
choices=['meeting', 'dashboard', 'code', 'console'],
help='Context hint for vision analysis (default: meeting)',
default='meeting'
)
# Processing options # Processing options
parser.add_argument( parser.add_argument(
'--no-cache', '--no-cache',
@@ -171,27 +126,17 @@ Examples:
parser.add_argument( parser.add_argument(
'--skip-cache-frames', '--skip-cache-frames',
action='store_true', action='store_true',
help='Skip cached frames, re-extract from video (but keep whisper/analysis cache)' help='Skip cached frames, re-extract from video (but keep whisper cache)'
) )
parser.add_argument( parser.add_argument(
'--skip-cache-whisper', '--skip-cache-whisper',
action='store_true', action='store_true',
help='Skip cached whisper transcript, re-run transcription (but keep frames/analysis cache)' help='Skip cached whisper transcript, re-run transcription (but keep frames cache)'
)
parser.add_argument(
'--skip-cache-analysis',
action='store_true',
help='Skip cached analysis, re-run OCR/vision (but keep frames/whisper cache)'
)
parser.add_argument(
'--no-deduplicate',
action='store_true',
help='Disable text deduplication'
) )
parser.add_argument( parser.add_argument(
'--extract-only', '--extract-only',
action='store_true', action='store_true',
help='Only extract frames and analyze, skip transcript merging' help='Only extract frames + transcript, skip the enhanced-transcript merge'
) )
parser.add_argument( parser.add_argument(
'--format', '--format',
@@ -202,7 +147,8 @@ Examples:
parser.add_argument( parser.add_argument(
'--embed-images', '--embed-images',
action='store_true', action='store_true',
help='Skip OCR/vision analysis and reference frame files directly (faster, lets LLM analyze images)' help='Reference extracted frames in the transcript for the LLM to read '
'(now the default behavior; flag kept for compatibility)'
) )
parser.add_argument( parser.add_argument(
'--embed-quality', '--embed-quality',

29
tests/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
"""Smoke + focused tests for meetus.
Run from the repo root:
python -m unittest discover -s tests -t . -v
This package's import makes the repo importable and provides a `cv2` stub (only
cv2 is missing in minimal envs; numpy/PIL are real and not shadowed).
"""
import os
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
STUBS_DIR = Path(__file__).resolve().parent / "_stubs"
for _p in (str(STUBS_DIR), str(REPO_ROOT)):
if _p not in sys.path:
sys.path.insert(0, _p)
def subprocess_env(extra=None):
"""Env for subprocess tests: repo root + cv2 stub on PYTHONPATH."""
env = dict(os.environ)
pp = env.get("PYTHONPATH", "")
parts = [str(REPO_ROOT), str(STUBS_DIR)] + ([pp] if pp else [])
env["PYTHONPATH"] = os.pathsep.join(parts)
if extra:
env.update(extra)
return env

2
tests/_stubs/cv2.py Normal file
View File

@@ -0,0 +1,2 @@
# Empty stub so meetus.frame_extractor (which does `import cv2`) imports in test
# environments without OpenCV. Tests never exercise cv2 functionality.

109
tests/test_batch.py Normal file
View File

@@ -0,0 +1,109 @@
"""Deeper tests for ctrl/batch.sh: recursive + space-safe discovery, audio
inclusion, structure mirroring, flag forwarding, failure resilience, and the
stdin-isolation fix (a greedy child must not stall the batch)."""
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from tests import REPO_ROOT
BATCH = REPO_ROOT / "ctrl" / "batch.sh"
def run_batch(args, env_extra=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
return subprocess.run(["bash", str(BATCH), *args], cwd=REPO_ROOT, env=env,
capture_output=True, text=True)
def stub(d, body):
p = d / "pystub"
p.write_text("#!/bin/bash\n" + body + "\n")
p.chmod(0o755)
return str(p)
class TestBatchDry(unittest.TestCase):
def test_recursive_spaces_audio_and_mirror(self):
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ind = tmp / "in"
(ind / "team a" / "sub one").mkdir(parents=True)
(ind / "team a" / "sub one" / "session 1.mkv").touch()
(ind / "clip.MP4").touch() # case-insensitive
(ind / "radio.ogg").touch() # audio
(ind / "notes.txt").touch() # ignored
r = run_batch(["-i", str(ind), "-o", str(tmp / "out"), "-n"])
self.assertEqual(r.returncode, 0, r.stderr)
out = r.stdout
self.assertIn("session 1.mkv", out)
self.assertIn("clip.MP4", out)
self.assertIn("radio.ogg", out)
self.assertNotIn("notes.txt", out)
self.assertIn("Found 3 video", out)
self.assertIn(str(tmp / "out" / "team a" / "sub one"), out)
class TestBatchRun(unittest.TestCase):
def test_forward_flags_and_mirror(self):
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ind = tmp / "in" / "team a"
ind.mkdir(parents=True)
(ind / "session 1.mkv").touch()
r = run_batch(["-i", str(tmp / "in"), "-o", str(tmp / "out"),
"--", "--embed-images", "--diarize"],
env_extra={"PYTHON": "echo"})
self.assertEqual(r.returncode, 0, r.stderr)
line = next(l for l in r.stdout.splitlines() if "process_meeting.py" in l)
self.assertIn("--output-dir", line)
self.assertIn(str(tmp / "out" / "team a"), line)
self.assertIn("--embed-images", line)
self.assertIn("session 1.mkv", line)
def test_empty_forward_does_not_crash(self):
# set -u + empty FORWARD array guard.
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ind = tmp / "in"
ind.mkdir()
(ind / "x.mp4").touch()
r = run_batch(["-i", str(ind), "-o", str(tmp / "out")],
env_extra={"PYTHON": "echo"})
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("process_meeting.py", r.stdout)
def test_continues_past_failures(self):
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ind = tmp / "in"
ind.mkdir()
(ind / "a.mp4").touch()
(ind / "b.mp4").touch()
r = run_batch(["-i", str(ind), "-o", str(tmp / "out")],
env_extra={"PYTHON": stub(tmp, "exit 1")})
self.assertNotEqual(r.returncode, 0) # nonzero when any failed
self.assertIn("0 ok, 2 failed", r.stdout)
self.assertIn("FAILED", r.stderr)
def test_stdin_isolation_processes_all_items(self):
# A child that drains stdin (like ffmpeg) must NOT eat the file list.
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ind = tmp / "in"
ind.mkdir()
for n in ("a.mp4", "b.mp4", "c.mp4"):
(ind / n).touch()
greedy = stub(tmp, 'shift; cat >/dev/null; echo "DID $1"')
r = run_batch(["-i", str(ind), "-o", str(tmp / "out")],
env_extra={"PYTHON": greedy})
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(r.stdout.count("DID "), 3)
if __name__ == "__main__":
unittest.main()

77
tests/test_config.py Normal file
View File

@@ -0,0 +1,77 @@
"""Deeper tests for WorkflowConfig — the --transcript-formats parsing and the
post-cleanup shape (embed-images only; deprecated analysis attrs gone)."""
import unittest
from meetus.workflow import WorkflowConfig
def cfg(**kw):
kw.setdefault("video", "sample.mp4")
return WorkflowConfig(**kw)
class TestTranscriptFormats(unittest.TestCase):
def test_default_is_json_only(self):
self.assertEqual(cfg().transcript_formats, ["json"])
def test_srt_implies_json(self):
# User shouldn't have to list json; it's always the base.
self.assertEqual(cfg(transcript_formats="srt").transcript_formats,
["json", "srt"])
def test_multiple(self):
self.assertEqual(cfg(transcript_formats="srt,txt").transcript_formats,
["json", "srt", "txt"])
def test_all_expands(self):
self.assertEqual(set(cfg(transcript_formats="all").transcript_formats),
{"json", "srt", "vtt", "txt", "tsv"})
def test_dedup_and_json_kept(self):
self.assertEqual(cfg(transcript_formats="json,srt,srt").transcript_formats,
["json", "srt"])
def test_whitespace_and_case(self):
self.assertEqual(cfg(transcript_formats=" SRT , Txt ").transcript_formats,
["json", "srt", "txt"])
def test_invalid_raises(self):
with self.assertRaises(ValueError):
cfg(transcript_formats="srt,bogus")
class TestDefaults(unittest.TestCase):
def test_scalar_defaults(self):
c = cfg()
self.assertEqual(c.embed_quality, 80)
self.assertEqual(c.format, "detailed")
self.assertEqual(c.whisper_model, "medium")
self.assertEqual(c.scene_threshold, 15.0)
def test_to_dict_analysis_is_embed_images(self):
self.assertEqual(cfg().to_dict()["analysis"]["method"], "embed-images")
def test_to_dict_whisper_has_diarize_and_formats(self):
d = cfg(diarize=True, transcript_formats="srt").to_dict()
self.assertTrue(d["whisper"]["diarize"])
self.assertEqual(d["whisper"]["transcript_formats"], ["json", "srt"])
class TestDeprecatedRemoved(unittest.TestCase):
"""The OCR/vision/hybrid surface was removed in the cleanup."""
def test_no_deprecated_attrs(self):
c = cfg()
for attr in ("use_vision", "use_hybrid", "hybrid_llm_cleanup",
"hybrid_llm_model", "vision_model", "vision_context",
"ocr_engine", "no_deduplicate"):
self.assertFalse(hasattr(c, attr), f"{attr} should be removed")
def test_extra_kwargs_are_ignored(self):
# Stray kwargs (e.g. an old flag) must not crash construction.
c = cfg(use_vision=True, ocr_engine="tesseract")
self.assertFalse(hasattr(c, "use_vision"))
if __name__ == "__main__":
unittest.main()

55
tests/test_makefile.py Normal file
View File

@@ -0,0 +1,55 @@
"""Smoke for the Makefile wrapper: OUT defaults to IN, the baked-in FLAGS, and
the AUDIO_LANG knob (named to avoid the LANG locale env var)."""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
from tests import REPO_ROOT
def make(target, **vars):
args = ["make", target] + [f"{k}={v}" for k, v in vars.items()]
return subprocess.run(args, cwd=REPO_ROOT, capture_output=True, text=True,
env=dict(os.environ))
@unittest.skipUnless(shutil.which("make"), "make not installed")
class TestMakefile(unittest.TestCase):
def test_dry_out_defaults_to_in(self):
with tempfile.TemporaryDirectory() as tmp:
ind = Path(tmp) / "in"
ind.mkdir()
(ind / "x.mp4").touch()
r = make("dry", IN=str(ind))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn(f"Output: {ind}", r.stdout)
self.assertIn("Found 1 video", r.stdout)
def test_batch_baked_in_flags(self):
with tempfile.TemporaryDirectory() as tmp:
ind = Path(tmp) / "in"
ind.mkdir()
(ind / "x.mp4").touch()
r = make("batch", IN=str(ind), PYTHON="echo")
self.assertEqual(r.returncode, 0, r.stderr)
line = next(l for l in r.stdout.splitlines() if "process_meeting.py" in l)
self.assertIn("--scene-threshold 10", line)
self.assertIn("--transcript-formats srt", line)
self.assertIn("--embed-images", line)
self.assertNotIn("--language", line) # no AUDIO_LANG -> no --language
def test_audio_lang_appends_language(self):
with tempfile.TemporaryDirectory() as tmp:
ind = Path(tmp) / "in"
ind.mkdir()
(ind / "x.mp4").touch()
r = make("batch", IN=str(ind), PYTHON="echo", AUDIO_LANG="es")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("--language es", r.stdout)
if __name__ == "__main__":
unittest.main()

57
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,57 @@
"""Smoke: the wired package imports, deprecated stays unwired, and the cleaned
CLI surface is what we expect."""
import subprocess
import sys
import unittest
from tests import REPO_ROOT, subprocess_env
class TestImports(unittest.TestCase):
def test_core_modules_import(self):
import meetus # noqa: F401
import meetus.workflow # noqa: F401 (frame_extractor → cv2 stub)
import meetus.frame_extractor # noqa: F401
import meetus.transcript_merger # noqa: F401
import meetus.output_manager # noqa: F401
import meetus.cache_manager # noqa: F401
def test_deprecated_importable_for_reference(self):
import meetus.deprecated.ocr_processor # noqa: F401
import meetus.deprecated.vision_processor # noqa: F401
def test_workflow_does_not_import_deprecated(self):
src = (REPO_ROOT / "meetus" / "workflow.py").read_text()
self.assertNotIn("from .deprecated", src)
self.assertNotIn("import deprecated", src)
class TestCliHelp(unittest.TestCase):
def _help(self, rel):
return subprocess.run(
[sys.executable, str(REPO_ROOT / rel), "--help"],
cwd=REPO_ROOT, env=subprocess_env(), capture_output=True, text=True,
)
def test_process_meeting_help_is_clean(self):
r = self._help("process_meeting.py")
self.assertEqual(r.returncode, 0, r.stderr)
out = r.stdout
for kept in ("--embed-images", "--diarize", "--transcript-formats",
"--scene-detection", "--skip-cache-frames", "--extract-only"):
self.assertIn(kept, out, f"{kept} should still be offered")
for gone in ("--use-vision", "--use-hybrid", "--ocr-engine",
"--hybrid-llm", "--vision-model", "--vision-context",
"--no-deduplicate", "--skip-cache-analysis"):
self.assertNotIn(gone, out, f"{gone} should be removed from the CLI")
def test_summarizers_offer_output_dir(self):
for rel in ("ctrl/summarize/summarize_simple.py",
"ctrl/summarize/compile_meeting.py"):
r = self._help(rel)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("--output-dir", r.stdout, rel)
if __name__ == "__main__":
unittest.main()

46
tests/test_summarize.py Normal file
View File

@@ -0,0 +1,46 @@
"""Deeper test for the summarizer --output-dir work: compile_meeting.default_output
(strips _enhanced, honors a base dir)."""
import importlib.util
import unittest
from pathlib import Path
from tests import REPO_ROOT
def _load(rel, name):
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / rel)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class TestCompileDefaultOutput(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cm = _load("ctrl/summarize/compile_meeting.py", "compile_meeting")
def test_next_to_transcript_strips_enhanced(self):
t = Path("/x/20260101-001-foo/foo_enhanced.txt")
self.assertEqual(self.cm.default_output(t, "reference"),
Path("/x/20260101-001-foo/foo_reference.md"))
def test_base_dir_override(self):
t = Path("/x/20260101-001-foo/foo_enhanced.txt")
self.assertEqual(self.cm.default_output(t, "reference", Path("/out")),
Path("/out/foo_reference.md"))
def test_no_enhanced_suffix(self):
t = Path("/x/run/foo.txt")
self.assertEqual(self.cm.default_output(t, "reference"),
Path("/x/run/foo_reference.md"))
def test_run_folder_mirroring(self):
# How main() builds base_dir for --output-dir: <out>/<transcript's run dir>.
t = Path("/runs/20260101-001-foo/foo_enhanced.txt")
base = Path("/out") / t.parent.name
self.assertEqual(self.cm.default_output(t, "reference", base),
Path("/out/20260101-001-foo/foo_reference.md"))
if __name__ == "__main__":
unittest.main()