asa / scripts /lib /lecture_chunker.py
defenestrate's picture
ASA Space: squashed history (instructions and settings live in the private pack)
526950e
Raw History Blame Contribute Delete
6.84 kB
"""Lecture-JSONL chunker.
Reads upstream `lecture_memory_*.jsonl` files (session_header + turn +
problem_outcome records) and emits per-problem retrieval citizens and
per-turn citation targets, plus a (session_date, row_index) -> doc_ids
lookup map for syntopicon reference resolution.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class ChunkerOutput:
lecture_problems: list[dict] = field(default_factory=list)
lecture_turns: list[dict] = field(default_factory=list)
# (session_date, row_index) -> (lecture_turn_doc_id, lecture_problem_doc_id)
row_lookup: dict[tuple[str, int], tuple[str, str]] = field(default_factory=dict)
def _hash8(*parts: str) -> str:
h = hashlib.sha256("|".join(parts).encode("utf-8"))
return h.hexdigest()[:8]
def _problem_doc_id(session_date: str, problem_id: str) -> str:
return f"lecture_problem_{session_date}_{problem_id}_{_hash8(session_date, problem_id)}"
def _turn_doc_id(session_date: str, row_index: int) -> str:
return f"lecture_turn_{session_date}_R{row_index:04d}_{_hash8(session_date, str(row_index))}"
def chunk_lecture_jsonl(
path: Path,
*,
term_registry: set[str] | None = None,
) -> ChunkerOutput:
"""Read a lecture_memory_*.jsonl file and produce per-problem + per-turn records.
term_registry: if provided, lecture_problem.concepts_present is filtered
to terms in this set. If None, concepts_present is the raw union of upstream
'terms' fields (used by tests that don't care about registry filtering).
"""
out = ChunkerOutput()
sessions: dict[str, dict] = {} # session_date -> session_header dict
# (session_date, problem_id) -> list of turn dicts
problems: dict[tuple[str, str], list[dict]] = {}
# session_date -> instructor_name (for fallback if turn lacks)
session_instructor: dict[str, str] = {}
for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
raw = raw.strip()
if not raw:
continue
try:
rec = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(
f"malformed JSONL at {path}:{line_no}: {e.msg}"
) from e
rt = rec.get("record_type")
if rt == "session_header":
sd = rec.get("lecture_date")
sessions[sd] = rec
session_instructor[sd] = rec.get("instructor_name", "")
elif rt == "turn":
sd = rec.get("lecture_date")
pid = rec.get("problem_id") or "P_unstructured"
problems.setdefault((sd, pid), []).append(rec)
# problem_outcome records ignored (no retrieval value at chunk level)
# Emit lecture_turn records and lecture_problem records.
for (session_date, problem_id), turns in problems.items():
if not turns:
continue
# Sort by row_index ascending
turns_sorted = sorted(turns, key=lambda t: t.get("row_index", 0))
problem_doc_id = _problem_doc_id(session_date, problem_id)
child_turn_doc_ids: list[str] = []
for t in turns_sorted:
if "row_index" not in t:
raise ValueError(
f"turn record in {path} session {session_date} "
f"problem {problem_id} is missing required field 'row_index'"
)
row_index = t["row_index"]
turn_doc_id = _turn_doc_id(session_date, row_index)
child_turn_doc_ids.append(turn_doc_id)
out.lecture_turns.append({
"kind": "lecture_turn",
"doc_id": turn_doc_id,
"lecture_date": session_date,
"row_index": row_index,
"turn_id": t.get("turn_id", ""),
"start": t.get("start"),
"end": t.get("end"),
"speaker": t.get("speaker", ""),
"label": t.get("label", ""),
"text": t.get("text", ""),
"word_count": t.get("word_count", 0),
"duration": t.get("duration"),
"parent_problem_doc_id": problem_doc_id,
})
out.row_lookup[(session_date, row_index)] = (turn_doc_id, problem_doc_id)
text_body = "\n".join(t.get("text", "") for t in turns_sorted)
terms_union: set[str] = set()
for t in turns_sorted:
for term in t.get("terms", []) or []:
# The upstream JSONL may emit terms as plain strings (test fixtures)
# or as dicts with a "term" key (production data schema v1.0+).
if isinstance(term, dict):
slug = term.get("term", "")
if slug:
terms_union.add(slug)
elif isinstance(term, str) and term:
terms_union.add(term)
if term_registry is not None:
terms_present = sorted(terms_union & term_registry)
else:
terms_present = sorted(terms_union)
first_q = next(
(t.get("problem_question", "") for t in turns_sorted if t.get("problem_question")),
"",
)
session = sessions.get(session_date, {})
row_indices = [t["row_index"] for t in turns_sorted]
out.lecture_problems.append({
"kind": "lecture_problem",
"doc_id": problem_doc_id,
"lecture_date": session_date,
"lecture_index_of_total": session.get("lecture_index_of_total", ""),
"problem_id": problem_id,
"problem_question": first_q,
"instructor_name": session_instructor.get(session_date, ""),
"child_turn_doc_ids": child_turn_doc_ids,
"row_range": [min(row_indices), max(row_indices)],
"text": text_body,
"concepts_present": terms_present,
})
return out
def row_lookup_to_json(row_lookup: dict[tuple[str, int], tuple[str, str]]) -> str:
"""Serialize the (session_date, row_index) -> (turn_doc_id, problem_doc_id) map.
JSON doesn't support tuple keys, so we flatten to a list of records.
"""
flat = [
{
"session_date": k[0],
"row_index": k[1],
"lecture_turn_doc_id": v[0],
"lecture_problem_doc_id": v[1],
}
for k, v in sorted(row_lookup.items())
]
return json.dumps(flat, indent=2)
def row_lookup_from_json(text: str) -> dict[tuple[str, int], tuple[str, str]]:
"""Deserialize the lookup map produced by row_lookup_to_json."""
flat = json.loads(text)
return {
(rec["session_date"], rec["row_index"]):
(rec["lecture_turn_doc_id"], rec["lecture_problem_doc_id"])
for rec in flat
}