Skip to content

Commit 43d71ba

Browse files
committed
feat: use official iCourse transcript when available, skip ASR
Adds get_transcript_segments() to ICourseClient returning timed segments. In LectureRunner, probes the official transcript API before firing up ASR — if the transcript exists and has no silence gaps >20 minutes, uses it directly. Saves ~5 min of ASR CPU per lecture. Segments use the same {start_ms, end_ms, text} format as our ASR, so the bucketer works unchanged.
1 parent 93a24ba commit 43d71ba

2 files changed

Lines changed: 59 additions & 12 deletions

File tree

src/api/icourse.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,25 @@ def get_lecture_detail(self, course_id: str, sub_id: str) -> dict:
362362
)
363363

364364
def get_transcript(self, sub_id: str) -> str | None:
365-
"""Get the transcript text for a lecture.
365+
"""Get the transcript text for a lecture (flat string).
366366
367367
Returns the full transcript text, empty string if no transcript,
368368
or None on error.
369369
"""
370+
segments = self.get_transcript_segments(sub_id)
371+
if segments is None:
372+
return None
373+
if not segments:
374+
return ""
375+
return " ".join(s["text"] for s in segments if s["text"])
376+
377+
def get_transcript_segments(self, sub_id: str) -> list[dict] | None:
378+
"""Get transcript as timed segments. Returns None on API error,
379+
empty list if no transcript exists.
380+
381+
Each segment: {"start_ms": int, "end_ms": int, "text": str}
382+
Sorted by start_ms ascending.
383+
"""
370384
url = f"{self.base_url}/courseapi/v3/web-socket/search-trans-result"
371385
resp = self.vpn.get(
372386
url, params={"sub_id": sub_id, "format": "json"}
@@ -379,15 +393,23 @@ def get_transcript(self, sub_id: str) -> str | None:
379393

380394
result_list = data.get("list", [])
381395
if not result_list:
382-
return ""
396+
return []
383397

384398
all_content = result_list[0].get("all_content", [])
385399
if not all_content:
386-
return ""
387-
388-
all_content.sort(key=lambda x: x.get("BeginSec", 0))
389-
return " ".join(
390-
seg.get("Text", "") for seg in all_content if seg.get("Text")
400+
return []
401+
402+
return sorted(
403+
(
404+
{
405+
"start_ms": int(seg.get("BeginSec", 0)) * 1000,
406+
"end_ms": int(seg.get("EndSec", seg.get("BeginSec", 0))) * 1000,
407+
"text": seg.get("Text", ""),
408+
}
409+
for seg in all_content
410+
if seg.get("Text", "").strip()
411+
),
412+
key=lambda s: s["start_ms"],
391413
)
392414

393415
def get_sub_detail(self, course_id: str, sub_id: str) -> dict:

src/pipeline/lecture_runner.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,26 @@ def _schedule_next(self, next_info: Optional[tuple[str, str]]):
173173
# semaphore until a download slot frees.
174174
self._scheduler.prefetch_lecture(self._client, next_course, next_sub)
175175

176+
@staticmethod
177+
def _official_transcript_usable(segments: list[dict] | None,
178+
max_gap_minutes: int = 20) -> bool:
179+
"""True if the official transcript is complete enough to use."""
180+
if not segments:
181+
return False
182+
max_gap_s = 0
183+
for i in range(1, len(segments)):
184+
gap = segments[i]["start_ms"] - segments[i - 1]["end_ms"]
185+
if gap > max_gap_s:
186+
max_gap_s = gap
187+
return max_gap_s <= max_gap_minutes * 60_000
188+
176189
def _get_transcript(self, existing: dict | None, course_id: str,
177190
sub_id: str) -> tuple[Optional[str], Optional[list]]:
178191
"""Return (transcript, segments) or (None, None) on skip.
179192
180-
Reuses an existing transcript if present (segments==None — bucketer
181-
falls back to flat mode). Otherwise pulls the prefetched audio
182-
handle, runs ``transcribe_tail``, and writes the transcript. On
183-
``NoAudioStreamError`` returns (None, None) after marking the
184-
lecture as a deliberate skip.
193+
Tries the official iCourse transcript first — when available and
194+
complete-enough (no >20 min silence gaps) it replaces the ASR
195+
step entirely, saving ~5 min of CPU time per lecture.
185196
"""
186197
if existing and existing.get("transcript"):
187198
self._reporter.info(
@@ -191,6 +202,20 @@ def _get_transcript(self, existing: dict | None, course_id: str,
191202
)
192203
return existing["transcript"], None
193204

205+
# Try official transcript before firing up ASR.
206+
try:
207+
official = self._client.get_transcript_segments(sub_id)
208+
if self._official_transcript_usable(official):
209+
text = " ".join(s["text"] for s in official)
210+
self._reporter.info(
211+
f" Using official transcript "
212+
f"({len(text)} chars, {len(official)} segments)"
213+
)
214+
self._db.update_transcript(sub_id, text)
215+
return text, official
216+
except Exception:
217+
pass # fall through to ASR
218+
194219
# Pull the audio handle. ``schedule`` is idempotent — usually the
195220
# previous lecture already kicked it off (Phase C), but for the
196221
# first lecture in the batch we still need to fire it ourselves.

0 commit comments

Comments
 (0)