Skip to content

iCourse Subscriber V2#25

Merged
LeafCreeper merged 45 commits into
mainfrom
dev
May 24, 2026
Merged

iCourse Subscriber V2#25
LeafCreeper merged 45 commits into
mainfrom
dev

Conversation

@LeafCreeper

Copy link
Copy Markdown
Owner

This pull request introduces a major refactor of the GitHub Actions workflows to support a new sharded and versioned database format, as well as improvements to deployment flexibility and model caching. The workflows now use a more robust and scalable approach for storing, fetching, decoding, and deploying the database, with backward compatibility for legacy formats. There are also enhancements to secret management and deployment configuration.

Database Sharding and Workflow Refactor

  • Migrated all workflows (check.yml, export.yml, reset_data.yml) to use a sharded database format, with scripts to shard/reassemble the database and support for both new and legacy formats. This includes new logic for fetching, decoding, and deploying sharded data, and improved handling of concurrent updates. [1] [2] [3] [4] [5]
  • Improved decryption logic by introducing separate environment variables for each secret, supporting both legacy and new (v2) encryption keys, and enhancing error handling for decryption failures. [1] [2] [3] [4]
  • Updated deployment steps to always re-shard and force-push the database in the new format, ensuring consistency and atomicity in the data branch. [1] [2]

Workflow and Model Improvements

  • Added caching for RapidOCR models to speed up workflow runs.
  • Fixed a typo in the ASR model cache key for consistency.

Deployment Flexibility

  • Enhanced deploy-frontend.yml to allow manual selection of the deployment branch via workflow dispatch input, improving deployment flexibility for different environments. [1] [2]

Secret and Configuration Management

  • Updated environment variable handling, passing secrets as individual variables rather than concatenated strings, and added support for new configuration options such as CRAWL_TERM, RESUMMARIZE_OLD, and DEEPSEEK_API_KEY. [1] [2]

These changes collectively make the workflows more robust, scalable, and maintainable, while setting the stage for future database growth and improved deployment practices.

Replaces the split LLM_*/GEMINI_* config constants with a single ordered
provider list in config.py. Each entry records api_key_env, base_url_env
(both optional), default_base_url, and a list of models. resolve_model_providers()
filters to providers whose API key env is set, preserving declared order.

Summarizer now iterates providers → models in order and returns the first
success with model_id "{provider}/{model}". Setting only DASHSCOPE_API_KEY
still works via the modelscope default entry.

Users can edit MODEL_PROVIDERS directly to add openai-compatible endpoints.
New password = sha256("ICSv2:" + stuid + ":" + uispsw).hexdigest().
PBKDF2 iterations 10000 → 100000. Keeps OpenSSL Salted__ envelope so
sql.js / Web Crypto frontends work unchanged.

Backward compat: workflows + frontend try v2 first, fall back to legacy
(stuid + uispsw + dashscope + smtp, 10k iter). On next deploy, data is
re-encrypted with v2. Forks with existing legacy DB upgrade silently.

- src/crypto_box.py: encrypt / decrypt / decrypt_with_fallback
- frontend/js/crypto.js: buildPasswordV2, decryptWithFallback
- 4 workflows (check/single_run/export/reset_data): v2-or-legacy bash
  decrypt_blob() helper; deploys re-encrypt with v2 + 100k iter
Pull per-page PPT screenshots through RapidOCR and align them with the
audio transcript on a 10-minute grid. The LLM now sees a bucketed prompt
containing both audio text (from sherpa-onnx FireRed ASR2 CTC, replacing
SenseVoice) and PPT board text per time window.

OCR results live in a new ppt_pages child table keyed by (sub_id, page_num)
with an ocr_status column ('pending' / 'done' / 'failed') so the work is
resumable across interrupted runs and safe under concurrent workers via
WAL row-level locks. Pages identified as the classroom desktop wallpaper
or near-duplicate of the previous slide (Jaccard ≥ 0.85) are filtered
in-memory at prompt-build time.

Transcript-level VAD segments are produced by the transcriber and held in
memory only — they feed the bucketer and are then discarded, so the DB
doesn't double-store the transcript text.

A summary_format_version=1 marker tags PPT-aware summaries; older v0
summaries are re-OCR'd and re-summarized in flat mode at the end of each
run, with emailed_at reset and a 「(含 PPT 识别·更新)」subject suffix
plus inline 更新 badge in the email digest.
Replace the single icourse.db.gz.enc file on the data branch with a
sharded layout:

    data/icourse-index.enc          encrypted JSON manifest
    data/shards/shard-NNNN.db.gz.enc per-shard encrypted gzipped sqlite

Each shard is a self-contained sqlite holding only the courses + lectures
+ ppt_pages rows for the courses it owns; courses are packed greedily into
~10MB shards so a single course growing doesn't churn unrelated data.

The frontend now fetches the data branch tree once, content-addresses
shards by their git blob sha, and only downloads shards whose sha changed
since last load (cached as decrypted bytes in IndexedDB). Typical
incremental updates fall from re-downloading the whole DB to <1MB.

CI workflows (check, single_run, export, reset_data) reassemble the
shards into a working sqlite via the new scripts/db_shard.py, run as
before, then re-shard for deploy. A legacy fall-back keeps reading
icourse.db.gz.enc / icourse.db.enc until the first sharded deploy
completes.

Manual summary editing in the frontend is retired (the sharded layout
makes single-row push-back unworkable); buttons stay as no-op stubs
until subproject D removes them along with the new three-state viewer.
The lecture detail page now cycles between summary, transcript, and PPT
identification on a single button. The button label always advertises the
*next* state ("切换到转录" / "切换到 PPT 识别" / "切换到摘要"), so it
reads as an action regardless of the current view.

The PPT view renders one block per kept page with a `[页 N @ mm:ss]` tag
above the OCR text, mirroring the format the bucketer feeds the LLM.
Pages whose ocr_status isn't 'done' (or whose text is empty) are filtered
out at query time, so the UI never shows 'pending' placeholders.

Manual summary editing was retired in subproject C when the data branch
moved to sharded storage (no clean way to push back a single row from
the browser). Removing the button + textarea + the now-unused
edit-textarea CSS keeps the UI honest about what the frontend actually
supports.
The previous design encrypted each shard with a fresh os.urandom salt and
gzip-compressed with default mtime, so re-sharding identical course content
produced different bytes each deploy. Combined with `git push --force` on
the data branch, every deploy invalidated every shard's git blob sha and
forced the frontend to re-download every shard — defeating the entire
point of sharding for incremental fetch.

Add a deterministic flag to crypto_box.encrypt that derives the salt from
sha256(plaintext)[:8], and pin gzip mtime=0 in sharder. Same plaintext now
produces identical ciphertext, so unchanged courses keep their git blob
sha across deploys and the IndexedDB shard cache hits as designed.

Updates the sharder stability test from "only name+course_ids stable" to
asserting actual sha256 equality, plus a raw-bytes equality double-check.
…lidators

AES-CBC + PKCS7 has a ~1/256 chance of accepting a wrong key (last byte of
the garbage plaintext happens to be 0x01). Without a sanity check the bytes
get propagated downstream and either crash unhelpfully or fail silently.

Add 3 magic-byte validators (is_sqlite, is_gzip, is_json_obj) and wire them
through:
- crypto_box.decrypt_with_fallback gains a validate= param: if v2 plaintext
  fails validation, fall through to legacy; if legacy also fails, raise.
- sharder.load_index / reassemble_database validate plaintext after each
  plain decrypt so the CI reassembly loop fails loudly on wrong key.
- frontend mirror in js/crypto.js plus pass-through in app.js _loadShard /
  _loadFromShardManifest / _loadFromLegacyBlob / testAndSave.
Previously after exhausting all max_attempts on IncompleteAudioError, we
fell back to transcriber._last_*, i.e. the *most recent* partial result.
That punished the case where the first attempt got 90% of the audio and
a retry only got 30% — we'd ship the 30%.

Track best_duration / best_transcript / best_segments across attempts so
the final fallback uses the longest partial we saw. Updates the misleading
'using best result' log to 'using longest (Ns)'.
The previous shape returned a fresh ICourseClient on session expiry, which
forced every caller to ' client = _check_session(client) ' or silently
discard the refreshed instance. Worse, anything that captured the old
client (closures, threads, retry helpers) kept hitting the dead session.

Mutate client.vpn / client._userinfo in place so all references see the
re-login automatically. Five caller sites simplified.
Schema CREATE statements + migration column lists previously lived in four
places (database.py, sharder.py, merge_db.py, frontend/js/db.js), drifting
silently every time we added a column.  This commit makes src/schema.py the
single Python source and frontend/js/schema.js a manual mirror — both files
carry a header reminding the next editor to sync the other.

Adds the ppt_pages.dhash column (TEXT, nullable) used by commit 6's
perceptual-hash dedup pipeline; existing DBs pick it up via ALTER TABLE in
Database._init_tables and merge_db._ensure_schema.

frontend/index.html now loads js/schema.js before js/db.js so the schema
constant is available when the SQL.Database is created.
… filter

The old LCS-based 'classroom desktop' filter only caught one screen and the
Jaccard adjacent-frame dedup ran post-OCR — meaning we paid OCR cost for
every dropped page. Replace both:

* Pre-OCR dHash dedup. compute_dhash() runs on raw image bytes; dedup_dhash
  walks a sliding window (w=5, threshold=4) so adjacent near-duplicates
  collapse to one survivor. Already-dropped pages are skipped as anchors,
  preventing 'next-near-last-kept' chains from cascading.

* Post-OCR INVALID_PAGE_PATTERNS. ~20 long, topic-specific feature strings
  drawn from both the classroom desktop wallpaper and the e-learning
  resource portal screen, all matched after stripping punctuation /
  whitespace so OCR noise still hits. Bare campus names that could
  legitimately appear in real PPTs (邯郸校区 etc.) are deliberately omitted.

Pipeline status flow per page: pending → (downloaded + dhash written, still
pending) → dedup_dropped | (OCR'd: done | invalid | failed). get_done_ppt_pages
only returns 'done', so dropped/invalid/failed naturally vanish from prompts.

Also: _resummarize_old_lectures now takes course_ids and uses the new
get_lectures_to_resummarize_for_courses, so we don't pay re-OCR cost for
courses outside the current run's COURSE_IDS list.
Single-lecture wall-clock now collapses from
  download_video + transcribe + sum(page_download + page_ocr)
to roughly
  max(download_video + transcribe, all_page_downloads + all_page_ocr)
plus across-lecture overlap of next lecture's image fetching with the
current lecture's LLM round-trip.

Pieces:
* 20-worker image-download pool (IO bound — GitHub runner has bandwidth).
* 3-worker OCR pool (CPU bound; RapidOCR's ONNX runtime releases the GIL
  on native calls so threads get real parallelism). 2 ASR cores + 3 OCR
  workers on a 4-vCPU runner is mild oversubscription, hidden behind IO
  waits — matches the 'a touch hotter than perfect' tuning request.
* PrefetchCache: per-lecture image pre-fetcher. main schedules N+1's
  downloads before calling summarizer.summarize on N, so the LLM
  round-trip masks the next lecture's network IO.
* run() flattens lectures across all courses into one queue, so prefetch
  works across course boundaries instead of restarting per course.
* Database: check_same_thread=False + threading.Lock around
  update_ppt_page / update_ppt_page_dhash. Reads are main-thread only
  between worker dispatches so they skip the lock.
* OCR worker function classifies + persists (done | invalid | failed)
  and returns status; main collects via as_completed for accounting.
* _check_session is main-thread only; workers see refreshed
  client.vpn cookies automatically because commit 4 made it mutate
  in place rather than returning a new client.
Reorganize 18 modules into 5 themed subpackages so the layering is
visible at the directory level rather than buried in import statements.

Mapping:
  api/      webvpn, icourse, emailer
  data/     database, schema, sharder, crypto_box
  ai/       transcriber, ocr, summarizer, bucketer, ppt_dedup
  runtime/  scheduler, reporter, config
  pipeline/ lecture_runner, ppt_pipeline

All imports rewritten to absolute form (from src.SUBPKG.X import Y) so the
dependency graph reads consistently across src/ internals, tests/, scripts/,
and main.py.  72 tests pass post-move.

Also incidentally bundles work already in flight on this branch:
  - merge_db all_courses section guarded for old local DBs lacking the table
  - explicit DETACH on Windows so tests' os.unlink doesn't hit a file lock
  - PPT pipeline (dHash dedup + 30-pattern invalid-page filter)
  - 10-min bucketed prompts + summary_format_version v2
  - v2 password (sha256+PBKDF2) with legacy fallback
  - Sharded encrypted DB with deterministic shard hashes
  - Frontend: subscriptions editor, prev/next, star pin, summary/PPT cycle
  - MODEL_PROVIDERS list with DASHSCOPE backward compat
  - Reporter image/CPU/concurrency snapshots
…e log

Three coordinated changes to fix the CPU-under-utilization observed in run
#126 (ASR sat at ~45% CPU on a 4-vCPU runner because num_threads was hard-
coded to 2):

1. transcriber: num_threads 2 → 4 so ASR can use all cores when alone.

2. scheduler.ResourceMonitor: track an asr_active flag, exposed via
   Scheduler.set_asr_active().  When True, OCR concurrency caps at
   OCR_MAX_TARGET_WHEN_ASR_ACTIVE (default 2 = half cores).  When False
   (resummarize phase, between lectures), OCR can ramp up to the full
   OCR_MAX_TARGET (8).  Flag flip immediately clamps the in-flight target
   down if needed so a 5-worker OCR run doesn't keep stealing cores from
   ASR's first second.

3. lecture_runner: wrap the transcribe_tail call with
   set_asr_active(True/False) in a try/finally so even an ASR exception
   restores the OCR cap.

Also adds the OCR-throughput logging that was missing: every 20 finished
pages (and at end) print `[OCR <sub_id>] [bar] N/total (R page/s)`. This
is throughput, distinct from the existing cpu_snapshot's "OCR busy/target/
max" which reports pool occupancy.  Both are needed to read run health —
high occupancy with low throughput would now be visible immediately.

Resummarize log: per-lecture progress index (idx/total) at start + total
elapsed seconds at end, so a long resummarize phase is observable.

Summarizer log: include prompt_tokens / completion_tokens in the per-call
"Done" line when the provider returns response.usage.  Token counts drive
both billing and rate-limit decisions, character counts don't.
…MMARIZE_OLD

Production-mode end-to-end test (scripts/test_prod_lecture.py):
  CLI that picks one lecture and runs the full pipeline (PPT crawl, dHash
  dedup, OCR, audio download + ASR, bucketed prompt, LLM summary) without
  touching the SQLite database.  Every intermediate artifact lands in
  test_prod_out/<sub_id>/: raw images, dedup decisions, per-page OCR
  text, transcript, segments JSON, bucketed prompt, summary, plus
  metrics.json with per-stage timing and throughput numbers.

  Used for A/B comparing ASR backends, measuring per-stage CPU/rate on
  the runner, and validating OCR / dedup / bucketer quality without
  needing to re-decrypt the production DB.  Default target is course
  33974 (机器学习系统) — picked because it has real slide PPTs, not
  just 板书 camera, so OCR + summary are actually exercised.

Resummarize gate:
  RESUMMARIZE_OLD=1 (default off) — re-OCRing + re-summarizing every old
  lecture turns a 5-min nightly run into a 2-hour run; we don't want
  that as the default cadence.  When unset, the resummarize phase logs
  a reminder line and skips.  Flip on for one-shot manual workflow runs.

.gitignore hygiene:
  - Stop tracking _run_logs/, 任务描述2.md, .pytest_cache
  - Add sherpa-onnx-fire-red-* and sherpa-onnx-zipformer-* (downloaded
    at runtime; same treatment as the existing sense-voice glob)
  - Add test_prod_out/ for the new test script's artifacts
Make Transcriber dispatch on a backend string at construction so the
production runtime can switch between ASR models via env without touching
code, and the upcoming A/B profiling can build several Transcribers with
different backend= kwargs side-by-side over the same audio.

Backends:
  firered    sherpa-onnx-fire-red-asr2-ctc-* (CTC, single model.onnx)
             OfflineRecognizer.from_fire_red_asr_ctc(...)
  sensevoice sherpa-onnx-sense-voice-* (multi-lang CTC, single model)
             OfflineRecognizer.from_sense_voice(..., use_itn=True)
  zipformer  sherpa-onnx-zipformer-* (transducer)
             OfflineRecognizer.from_transducer(encoder, decoder, joiner)

The zipformer branch globs the model_dir for encoder*/decoder*/joiner*
since the exact filename suffix (epoch-N-avg-M) varies across upstream
releases.

New env knobs in src/runtime/config.py:
  ASR_BACKEND       default firered
  ASR_NUM_THREADS   default 4 (matches the GitHub runner's 4 vCPU)

The 4-thread default is the same as the previous commit's hard-coded
value, now centralised + tunable so a runner with more cores doesn't
need a code edit.

Refactor extracted _resolve_first() helper for the FireRed/SenseVoice
case (same single-file lookup pattern); zipformer keeps its own glob
since it picks three files.
The end-to-end test script (one-lecture no-DB run with full artifact
dump + per-stage metrics) is a profiling/benchmarking tool, not a
production code path.  Keep dev clean by holding the script on a
dedicated 'profiling' branch instead.

The Transcriber backend pluggability stays on dev — that's a config
knob production users will want regardless of profiling.
- config.ASR_BACKEND defaults to "sensevoice"; ASR_MODEL_DIR to
  sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17 bundle.
- workflows (check.yml, single_run.yml) now cache + download the
  SenseVoice bundle instead of FireRed.
- transcriber: generic per-segment cleanup applied to every backend's
  output — strip JP kana, Korean hangul, bracket tokens (<sil>, <|zh|>),
  and a fixed whitelist of English filler words (tech terms survive).
  Collapse orphan-punctuation runs left behind by the deletions.
- VAD: bump min_silence_duration 0.25 → 0.8 and cap max_speech_duration
  at 30s so SenseVoice gets longer, more coherent chunks (its training
  receptive field) and fewer hallucinated leading kana.
- scripts/dump_lectures.py: local-only helper to dump decrypted DB
  contents per-lecture as markdown.
When resummarize_old_lectures upgrades a v0 row to v2, the original
summary now lands in lectures.old_summary instead of being overwritten.
The frontend can render an old-vs-new diff, and the email pipeline can
optionally include the historical version.

- schema: add old_summary TEXT to the CREATE TABLE + migration list, so
  existing DBs get the column via ALTER TABLE on startup.
- frontend/js/schema.js: mirror the new column.
- Database.update_summary_v2 uses COALESCE(old_summary, summary) so:
    • first overwrite (typically v0 → v2): captures old text.
    • later overwrites: never clobber the once-captured value.
    • first write on a fresh lecture: stays NULL.
- merge_db.py: include old_summary in the cross-DB INSERT/UPDATE so
  merging a recently-resummarized local into remote doesn't drop it.
deploy-frontend.yml now accepts a 'branch' input on workflow_dispatch
so you can test frontend changes from dev (or any feature branch)
without merging to main first.  The auto push-to-main trigger is
unchanged — actions/checkout's 'ref' falls back to github.ref when
inputs.branch is empty.
Data-driven stopword list (derived from 7 lectures x 5 courses)
removes standalone lines that exactly match known ribbon labels,
status-bar items, and keyboard shortcut hints.  Regex patterns
handle variable-status-bar text (zoom %, word count, page N/M).
Cleanup is per-line and only drops exact matches — the same word
embedded in lecture content (e.g. "编辑" in a sentence) survives.

Reduces OCR text volume 7.8-18.1% across real lectures, all of
which is wasted LLM prompt budget from PowerPoint window chrome.
Old shards produced by prior code versions lack migration columns
(e.g. old_summary).  INSERT ... SELECT * would fail with 'N columns
but M values'.  _migrate_shard_schema now aligns attached shard
tables to main before the copy, matching on LECTURES_MIGRATION_COLUMNS
and PPT_PAGES_MIGRATION_COLUMNS from src/data/schema.py.
RapidOCR is single-threaded CPU-bound — one page saturates one core.
Raising concurrency past 2 never increases throughput but produces
noisy target-churn logs every second as ResourceMonitor oscillates.
…rrency

OCR scheduling analysis showed that the CPU-based dynamic adjustment
between 1 and 2 concurrent workers provides no real throughput benefit
since RapidOCR is single-threaded CPU-bound. The complexity of
DynamicSemaphore, ResourceMonitor, psutil polling, and six config knobs
was not justified for a ±1 adjustment on a 4-core runner.

Changes:
- Remove DynamicSemaphore class entirely
- Remove ResourceMonitor class entirely
- Replace both with a fixed threading.BoundedSemaphore(OCR_MAX_TARGET=2)
- Remove set_asr_active() from Scheduler and LectureRunner
- Remove psutil dependency from requirements.txt
- Remove config knobs: OCR_INITIAL_TARGET, OCR_MIN_TARGET,
  OCR_MAX_TARGET_WHEN_ASR_ACTIVE, RESOURCE_MONITOR_CPU_HIGH/LOW
- Remove cpu_snapshot() and cpu_target_changed() from Reporter
- Remove scheduler.start() from main.py (no monitor thread to start)

Also fix: GITHUB_TOKEN push auth in check.yml and single_run.yml —
use 'x-access-token' username format required by the GitHub API
instead of the actor username.
Existing resummarize_old_lectures() serializes PPT OCR and LLM calls
for each lecture.  This script uses a ThreadPoolExecutor to overlap
OCR (CPU-bound) with LLM API waits (IO-bound), reducing wall-clock
time from the sum of both phases to the max of either.

Usage:
  python scripts/resummarize_parallel.py              # all courses
  python scripts/resummarize_parallel.py --workers 6  # 6 concurrent
  python scripts/resummarize_parallel.py --limit 50   # first 50 only
Phase 1 — Catalog: scan all targets, register PPT pages in DB.
Phase 2a — OCR saturation: N threads each run PPT pipeline for a
batch of lectures, push completed results to a shared queue.
Phase 2b — LLM concurrent: M workers pull from the queue, assemble
prompts, call the summarizer API in parallel.

The two pipelines overlap: CPU-bound OCR and IO-bound LLM API calls
run simultaneously.  Wall-clock time = max(ΣOCR, ΣLLM) not ΣOCR+ΣLLM.
Groups courses into ~5MB shards by update recency:
  - "Active" courses (processed within last 14 days or with
    pending/errored lectures) are grouped together so frequently
    changing data stays in the same shard.
  - "Stable" courses (fully processed, no recent activity) are
    packed by first-fit into separate shards.  Since they never
    change, these shard hashes are stable across runs.
  - A course is never split across shards.

Output format uses the same index + shard layout as sharder.py
so the frontend and db_shard.py can consume it unchanged.
Active courses now use a 3MB per-shard target (instead of 5MB) so
frequently-changing data produces smaller update payloads.  Stable
courses remain at 5MB.  Factorised the packing logic into a shared
_pack() helper used by both groups.
When a page is dropped from the window, the next non-dropped page
beyond the original window boundary fills the vacated slot.  This
ensures the anchor always compares against exactly ``window``
non-dropped items, making dedup more aggressive on long runs of
near-identical screenshots (e.g. a static slide held for minutes).
Sliding-window dedup for PPT animation reveals. Uses 3-gram directional
containment (not symmetric Jaccard): if a page's 3-gram set is >85%
contained in a nearby page that is >10% longer, the shorter page is
dropped as a progressive-disclosure subset.  No line heuristics (OCR
line breaks are unreliable).  <55 lines total.
Use explicit refspec for git fetch so origin/data tracking ref is
always created.  Replace rev-parse with actual file-existence check
so force-pushed branches with different history are detected.
Shallow clones reject non-fast-forward ref updates without the + prefix.
Use git ls-tree (reads tree directly) instead of git cat-file -e
(requires blob fetch) to detect which DB format is on the data branch.
OCR is now deferred (defer_ocr=True) in LectureRunner.  The PPT pipeline
downloads images and runs dHash dedup before ASR, but OCR submission is
postponed to handle.drain() which runs after ASR completes.  This gives
ASR exclusive CPU access during transcription — previously OCR competed
with ASR and degraded transcription throughput.

The handle stores pre-dedup'd image bytes and submits OCR lazily on
drain(), so no architecture changes are needed beyond the defer_ocr flag.
Appends (cpu=N% mem=N%) to Transcriber progress, OCR progress, and
image download progress lines using psutil (added back to deps).
The meter is lazy-imported on first call, so startup is unaffected
on systems without psutil.
Between Phase E (current OCR drain) and Phase F (LLM call), kick off
the next lecture's PPT pipeline (download → dedup → OCR) so its OCR
runs in the background pool during the 60-120s LLM API wait.

prefetch_and_ocr() is a light submit() that doesn't drain or discard
the prefetch cache.  submit() detects if pages were already OCR'd by
checking for pre-existing dhash values and skips re-processing.
prefetch_and_ocr stores OCR futures in PPTPipeline._prefetched_ocr.
submit() pops and drains them before starting ASR if the LLM returned
before OCR completed.  This prevents OCR from competing with ASR for
CPU in the edge case where the LLM API responds faster than expected.
Progress lines now include:
  [CPU] or [IO] tag indicating the limiting resource
  down=XXXKB/s (network receive throughput, delta-sampled)

Example output:
  [Transcriber] Progress: 1154s ... 65 segments so far  [CPU] (cpu=99% mem=68% down=120KB/s)
  [Images 616503] [####] 30/94 (10.4 pic/s)  [IO] (cpu=12% mem=68% down=8500KB/s)
  [OCR 616503] [####] 20/88 (0.53 page/s)  [CPU] (cpu=100% mem=68% down=5KB/s)
@LeafCreeper
LeafCreeper requested a review from Copilot May 24, 2026 04:14
@LeafCreeper
LeafCreeper merged commit 56f9946 into main May 24, 2026
@LeafCreeper
LeafCreeper removed the request for review from Copilot May 24, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant