asa / scripts /lib /syntopicon_reference_parser.py
defenestrate's picture
ASA Space: squashed history (instructions and settings live in the private pack)
526950e
Raw History Blame Contribute Delete
5.36 kB
"""Parse `## References` markdown tables on syntopicon entry files into
structured reference dicts, resolved against a (session_date, row_index)
lookup map produced by the lecture chunker.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_PROBLEM_ID_RE = re.compile(r"^P\d+$")
def parse_row_spec(rows_raw: str) -> list[int]:
"""Parse a Row(s) cell. Supports '159', '159, 232', '159-162'.
Returns sorted unique list. Whitespace tolerated. Returns empty list on
unparseable input.
"""
s = rows_raw.strip()
if not s:
return []
if "-" in s and "," not in s:
# Range form
try:
lo, hi = s.split("-", 1)
return list(range(int(lo.strip()), int(hi.strip()) + 1))
except ValueError:
return []
if "," in s:
try:
return sorted({int(p.strip()) for p in s.split(",") if p.strip()})
except ValueError:
return []
try:
return [int(s)]
except ValueError:
return []
def parse_references(
markdown_path: Path,
row_lookup: dict[tuple[str, int], tuple[str, str]],
) -> list[dict]:
"""Parse the ## References table in a syntopicon markdown file.
Returns a list of reference dicts. Each dict has session_date, speaker,
row_index, how_used, notes, problem_id (if notes match /^P\\d+$/),
lecture_turn_doc_id, and lecture_problem_doc_id.
Unresolvable rows are logged at WARNING and skipped. Malformed rows and
bad dates are also skipped, with logging. Files without ## References
return [] silently.
"""
text = markdown_path.read_text(encoding="utf-8")
lines = text.splitlines()
# Find ## References heading
in_section = False
in_table = False
skipped_separator = False
refs: list[dict] = []
for line in lines:
stripped = line.strip()
if stripped.lower().startswith("## references"):
in_section = True
continue
if not in_section:
continue
if stripped.startswith("## "):
# Different section; stop
break
if not stripped:
if in_table:
# Blank line ends table
break
continue
if not in_table:
# Skip until table header
if stripped.startswith("|"):
in_table = True
continue
if not skipped_separator:
# The line right after the header is the separator |---|---|
if "---" in stripped:
skipped_separator = True
continue
# No separator? still skip if it looks like one; otherwise treat as data
if stripped.startswith("|"):
skipped_separator = True
# fall through to parse this row as data? No — skip header again.
continue
# Parse a data row
cells = [c.strip() for c in stripped.strip("|").split("|")]
if len(cells) != 5:
logger.warning(
"syntopicon_reference_parser: malformed row in %s (got %d cells, want 5): %r",
markdown_path, len(cells), stripped,
)
continue
session_date, speaker, rows_raw, how_used, notes = cells
if not _DATE_RE.match(session_date):
logger.warning(
"syntopicon_reference_parser: bad date format %r in %s",
session_date, markdown_path,
)
continue
row_indices = parse_row_spec(rows_raw)
problem_id = notes if _PROBLEM_ID_RE.match(notes.strip()) else None
for row_index in row_indices:
key = (session_date, row_index)
if key not in row_lookup:
logger.warning(
"syntopicon_reference_parser: unresolvable row %s in %s (row=%d)",
session_date, markdown_path, row_index,
)
continue
turn_id, problem_did = row_lookup[key]
refs.append({
"session_date": session_date,
"speaker": speaker,
"row_index": row_index,
"how_used": how_used,
"notes": notes,
"problem_id": problem_id,
"lecture_turn_doc_id": turn_id,
"lecture_problem_doc_id": problem_did,
})
if in_section and not refs:
logger.warning(
"syntopicon_reference_parser: %s has '## References' heading but no resolvable rows",
markdown_path,
)
return refs
def parse_all_references(
syntopicon_dir: Path,
row_lookup: dict[tuple[str, int], tuple[str, str]],
) -> dict[str, list[dict]]:
"""Walk the syntopicon directory; for each *.md file (excluding _registry.json
and other non-term files), parse references. Returns {term_slug: refs}.
The term_slug is the markdown filename stem (e.g., 'demand' for 'demand.md').
"""
out: dict[str, list[dict]] = {}
for md_path in sorted(syntopicon_dir.glob("*.md")):
if md_path.stem.startswith("_"):
continue
out[md_path.stem] = parse_references(md_path, row_lookup)
return out