ClassLensPortal / chatkit /backend /seed_from_table.py
taboola
fix in-app question upload sharing stale parser, hide weak-area toggle after generation, retry passage question shortfalls
c5f6b64
Raw
History Blame Contribute Delete
27.2 kB
#!/usr/bin/env python3
"""Seed questions from Google Sheets HTML table exports into the question_bank.
Supports three column layouts detected automatically:
Format A — year-based, positional (103-114 會考單題.html):
Col 0: year Col 2: stem Col 3: choices Col 4: answer letter
→ GPT classifies main_category + tags
Format B — pre-labelled, positional (總表.html):
Col 2: main_category Col 4: stem Col 5: choices no answer
Format C — labelled by header text, any column order (e.g. 年度/題號/
段落文章/題目/選項/答案/詳解/本題關鍵/題型分類 sheets): columns are found by
matching header cells like "題型分類"/"題目"/"選項"/"答案"/"段落文章" instead
of a fixed position, so exports with extra columns (explanations, key
points, etc.) still parse correctly.
For B and C: category taken directly from the sheet (no GPT call for
main_category). Rows with no valid category in the sheet fall back to full
GPT classification, same as Format A. Rows whose (sheet-given or
GPT-assigned) category has sub-tags in the taxonomy still get a GPT call
for tags only.
Usage:
cd chatkit/backend
python seed_from_table.py file1.html [file2.html ...]
"""
from __future__ import annotations
import asyncio
import html as html_lib
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from openai import AsyncOpenAI
from app.config import get_settings
from app.database import save_question_bank_batch
from app.question_rewriter import PASSAGE_CATEGORIES
from app.taxonomy import ALL_TAGS, MAIN_CATEGORIES, TAXONOMY, taxonomy_prompt_block
BATCH_SIZE = 25
_VALID_CATS = set(MAIN_CATEGORIES)
# Questions that reference an image or an external passage that isn't inline
_SKIP_RE = re.compile(
r"look at the picture"
r"|in the picture"
r"|the picture shows"
r"|the following picture"
r"|according to the (?:passage|article|reading|text|graph|chart|table)"
r"|based on the (?:passage|article|reading|text)"
r"|the (?:passage|article|reading) (?:above|below|following)"
r"|the following (?:passage|article|reading)",
re.I,
)
def _should_skip(question_text: str) -> bool:
"""Return True for questions referencing a non-existent image or passage."""
return bool(_SKIP_RE.search(question_text))
CLASSIFY_SYSTEM = f"""You are ClassLens, an assistant for Taiwanese 國中 English exam analysis.
Classify each question into the pre-defined taxonomy. Return ONLY a JSON array:
[{{"idx": 0, "main_category": "語法結構", "tags": ["時態", "過去簡單式"]}}, ...]
Rules:
- main_category should be one of the 6 categories below, or "" if no category clearly fits.
- tags must come directly from that category's tag list. Use [] if none fit or if main_category is "".
- Classify by the PRIMARY grammar or vocabulary skill the question tests.
- Do NOT force a category if the question doesn't clearly belong to one — prefer "" over a weak match.
{taxonomy_prompt_block()}"""
# ---------------------------------------------------------------------------
# HTML table parser
# ---------------------------------------------------------------------------
class _TableParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.rows: list[list[str]] = []
self._row: list[str] = []
self._cell: list[str] = []
self._in_cell = False
self._in_thead = False
self._skip_row = False
self._skip_cell = False
def handle_starttag(self, tag: str, attrs: list) -> None:
if tag == "thead":
self._in_thead = True
elif tag == "tr":
self._row = []
# Google Sheets wraps its own spreadsheet column-letter row
# (A, B, C, ...) in <thead> — that's never real data or the
# sheet's own header row (which is the first row of <tbody>), so
# it must be dropped the same way a freezebar row is, or
# everything downstream mistakes it for the header.
self._skip_row = self._in_thead or any(
k == "class" and v and "freezebar" in v for k, v in attrs
)
elif tag in ("td", "th"):
cls = dict(attrs).get("class", "") or ""
# Skip row-number header cells (the <th> on the left of each row)
if "row-headers-background" in cls:
self._skip_cell = True
self._in_cell = False
else:
self._skip_cell = False
self._cell = []
self._in_cell = True
def handle_endtag(self, tag: str) -> None:
if tag == "thead":
self._in_thead = False
elif tag in ("td", "th"):
if not self._skip_cell:
self._row.append("".join(self._cell).strip())
self._in_cell = False
self._skip_cell = False
elif tag == "tr" and not self._skip_row and self._row:
self.rows.append(self._row)
def handle_data(self, data: str) -> None:
if self._in_cell:
self._cell.append(data)
def handle_entityref(self, name: str) -> None:
if self._in_cell:
self._cell.append(html_lib.unescape(f"&{name};"))
def handle_charref(self, name: str) -> None:
if self._in_cell:
self._cell.append(html_lib.unescape(f"&#{name};"))
def _fix_mojibake(s: str) -> str:
"""Fix UTF-8 bytes that were decoded as Latin-1 (common in Google Sheets exports)."""
try:
return s.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
return s
def parse_html_bytes(raw_bytes: bytes) -> list[list[str]]:
"""Parse a Google Sheets HTML export's raw bytes into rows, fixing
mojibake per cell. Shared by the CLI (_parse_html reads a file first)
and the in-app upload endpoint (already has the bytes from the upload) —
a single implementation so both paths always parse identically."""
raw = raw_bytes.decode("utf-8", errors="replace")
p = _TableParser()
p.feed(raw)
return [[_fix_mojibake(cell) for cell in row] for row in p.rows]
def _parse_html(html_path: Path) -> list[list[str]]:
return parse_html_bytes(html_path.read_bytes())
# ---------------------------------------------------------------------------
# Format detection + extraction
# ---------------------------------------------------------------------------
def _detect_format(rows: list[list[str]]) -> str:
"""Return 'C' (header-labeled), 'B' (pre-labelled positional), 'A'
(year-based positional), or 'unknown'."""
if rows and _detect_header_columns(rows[0]):
return "C"
for row in rows[1:6]: # skip header, sample a few data rows
if len(row) < 3:
continue
col2 = row[2].strip()
if col2 in _VALID_CATS:
return "B"
if row[0].strip().isdigit():
return "A"
return "unknown"
def _extract_format_a(rows: list[list[str]]) -> list[dict]:
"""Year-based format: year in col 0, stem col 2, choices col 3, answer col 4."""
questions: list[dict] = []
for row_idx, row in enumerate(rows):
if row_idx == 0 or len(row) < 5:
continue
if not row[0].strip().isdigit():
continue
stem = row[2].strip()
opts = row[3].strip()
ans = row[4].strip().upper()
if not stem or len(stem) < 5:
continue
question_text = f"{stem} {opts}".strip() if opts else stem
if _should_skip(question_text):
continue
answer = ans[0] if ans and ans[0] in "ABCD" else ""
questions.append({"question_text": question_text, "answer": answer})
return questions
def _extract_format_b(rows: list[list[str]]) -> list[dict]:
"""Pre-labelled format: category col 2, stem col 4, choices col 5 (6-col)
or stem col 3, choices col 4 (5-col single-category sheets).
A row with no valid category in col 2 is NOT skipped as long as it has a
real question stem — it's kept with main_category="" so the caller can
send just those to GPT for full classification, same as Format A."""
questions: list[dict] = []
for row_idx, row in enumerate(rows):
if row_idx == 0:
continue
cat = row[2].strip() if len(row) > 2 else ""
if cat not in _VALID_CATS:
cat = ""
# 6-col (總表 layout): col4=stem, col5=choices
# 5-col (single-category sheets): col3=stem, col4=choices
if len(row) >= 6:
stem = row[4].strip()
opts = row[5].strip()
else:
stem = row[3].strip() if len(row) > 3 else ""
opts = row[4].strip() if len(row) > 4 else ""
# Skip placeholder rows and image/passage references
if not stem or len(stem) < 5 or "未提供" in stem:
continue
question_text = f"{stem} {opts}".strip() if opts else stem
if _should_skip(question_text):
continue
questions.append({
"question_text": question_text,
"answer": "", # no answer column in this format
"main_category": cat, # "" if the sheet didn't give a valid one
"tags": [],
})
return questions
_HEADER_ALIASES: dict[str, tuple[str, ...]] = {
"category": ("題型分類", "分類", "類別"),
"stem": ("題目",),
"options": ("選項",),
"answer": ("答案",),
"passage": ("段落文章", "文章", "篇章"),
}
def _detect_header_columns(header_row: list[str]) -> dict[str, int] | None:
"""Map semantic field names to column indexes by matching header text.
Returns None unless both a category and a stem column are found — those
are the two fields Format A/B can't locate by fixed position for a sheet
whose columns aren't in the 5/6-col layouts they assume."""
col_map: dict[str, int] = {}
for idx, cell in enumerate(header_row):
cell = cell.strip()
for field, aliases in _HEADER_ALIASES.items():
if field not in col_map and cell in aliases:
col_map[field] = idx
if "category" in col_map and "stem" in col_map:
return col_map
return None
def _extract_labeled(rows: list[list[str]], col_map: dict[str, int]) -> list[dict]:
"""Extract questions from a sheet whose columns were identified by header
text (see _detect_header_columns) rather than fixed position. Category is
taken directly from the sheet when valid, same as Format B."""
stem_col = col_map["stem"]
cat_col = col_map["category"]
opts_col = col_map.get("options")
ans_col = col_map.get("answer")
passage_col = col_map.get("passage")
questions: list[dict] = []
for row_idx, row in enumerate(rows):
if row_idx == 0:
continue
stem = row[stem_col].strip() if len(row) > stem_col else ""
if not stem or len(stem) < 5 or "未提供" in stem:
continue
opts = row[opts_col].strip() if opts_col is not None and len(row) > opts_col else ""
passage = row[passage_col].strip() if passage_col is not None and len(row) > passage_col else ""
question_text = " ".join(p for p in (passage, stem, opts) if p)
if _should_skip(question_text):
continue
ans = row[ans_col].strip().upper() if ans_col is not None and len(row) > ans_col else ""
answer = ans[0] if ans and ans[0] in "ABCD" else ""
cat = row[cat_col].strip() if len(row) > cat_col else ""
if cat not in _VALID_CATS:
cat = ""
has_passage = bool(passage)
if has_passage and cat not in PASSAGE_CATEGORIES:
# A question with its own passage can only be one of the
# passage-type categories (文意推論/篇章大意/篇章細節/篇章結構) —
# a sheet-given category outside that set (e.g. 字詞理解/語法結構,
# or none at all) contradicts the row's own structure, so
# re-classify instead of trusting it.
cat = ""
questions.append({
"question_text": question_text,
"answer": answer,
"main_category": cat,
"tags": [],
"_needs_passage_category": has_passage and not cat,
})
return questions
# ---------------------------------------------------------------------------
# GPT classification (Format A only)
# ---------------------------------------------------------------------------
async def _classify_batch(
client: AsyncOpenAI,
batch: list[dict],
model: str,
) -> list[dict]:
items = "\n".join(
f'{i}. {q["question_text"][:350]}'
for i, q in enumerate(batch)
)
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": CLASSIFY_SYSTEM},
{"role": "user", "content": f"Classify these {len(batch)} questions:\n\n{items}"},
],
temperature=0.0,
max_tokens=2048,
)
content = resp.choices[0].message.content or ""
content = re.sub(r"^```json?\s*|\s*```$", "", content.strip())
results: list[dict] = json.loads(content)
class_map: dict[int, dict] = {}
for r in results:
idx = r.get("idx")
if idx is None:
continue
cat = r.get("main_category", "")
if cat not in _VALID_CATS:
cat = ""
tags = r.get("tags", [])
if not isinstance(tags, list):
tags = []
class_map[idx] = {"main_category": cat, "tags": tags}
classified = []
for i, q in enumerate(batch):
cls = class_map.get(i, {})
classified.append({
"question_text": q["question_text"],
"answer": q["answer"],
"main_category": cls.get("main_category", ""),
"tags": cls.get("tags", []),
})
return classified
_PASSAGE_CLASSIFY_SYSTEM = f"""You are ClassLens, an assistant for Taiwanese 國中 English exam analysis.
Every question below is a reading-passage question — its stem already includes (or is
paired with) a passage, so it can ONLY belong to one of these 4 categories:
{', '.join(sorted(PASSAGE_CATEGORIES))}
Return ONLY a JSON array: [{{"idx": 0, "main_category": "篇章細節"}}, ...]
Rules:
- main_category MUST be one of the 4 categories above — never a vocabulary/grammar
category, and never "" (every passage question fits one of these 4).
- Classify by what the question is actually testing: 文意推論 (inference), 篇章大意
(main idea), 篇章細節 (detail), 篇章結構 (structure/reference/connector)."""
async def _classify_passage_category_batch(
client: AsyncOpenAI,
batch: list[dict],
model: str,
) -> list[dict]:
"""Classify main_category only, constrained to the 4 passage-type
categories — for rows already known (from the sheet's passage column) to
depend on a passage, so a full 6-category classification could otherwise
misfile them under 字詞理解/語法結構. No tags call needed: none of the
passage categories have sub-tags in the taxonomy."""
items = "\n".join(
f'{i}. {q["question_text"][:350]}'
for i, q in enumerate(batch)
)
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _PASSAGE_CLASSIFY_SYSTEM},
{"role": "user", "content": f"Classify these {len(batch)} questions:\n\n{items}"},
],
temperature=0.0,
max_tokens=1024,
)
content = resp.choices[0].message.content or ""
content = re.sub(r"^```json?\s*|\s*```$", "", content.strip())
results: list[dict] = json.loads(content)
class_map: dict[int, str] = {}
for r in results:
idx = r.get("idx")
if idx is None:
continue
cat = r.get("main_category", "")
if cat in PASSAGE_CATEGORIES:
class_map[idx] = cat
classified = []
for i, q in enumerate(batch):
classified.append({
"question_text": q["question_text"],
"answer": q["answer"],
"main_category": class_map.get(i, ""),
"tags": [],
})
return classified
async def _classify_tags_batch(
client: AsyncOpenAI,
batch: list[dict],
model: str,
category: str,
) -> dict[int, list[str]]:
"""Assign sub-tags only, for questions whose main_category is already
known (from the sheet or from a prior full classification pass)."""
valid_tags = set(ALL_TAGS.get(category, []))
if not valid_tags:
return {}
tag_list = ", ".join(sorted(valid_tags))
system = f"""You are ClassLens, an assistant for Taiwanese 國中 English exam analysis.
Every question below is already classified as "{category}". Assign each question the
best-fitting tag(s) from this list only: {tag_list}
Return ONLY a JSON array: [{{"idx": 0, "tags": ["過去簡單式"]}}, ...]
Rules:
- tags must come directly from the list above. Use [] if none clearly fit — never invent a tag.
- Prefer the single best-fit tag; only include more than one if genuinely both apply."""
items = "\n".join(
f'{i}. {q["question_text"][:350]}'
for i, q in enumerate(batch)
)
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Assign tags to these {len(batch)} questions:\n\n{items}"},
],
temperature=0.0,
max_tokens=1024,
)
content = resp.choices[0].message.content or ""
content = re.sub(r"^```json?\s*|\s*```$", "", content.strip())
results: list[dict] = json.loads(content)
tag_map: dict[int, list[str]] = {}
for r in results:
idx = r.get("idx")
if idx is None:
continue
tags = r.get("tags", [])
if not isinstance(tags, list):
tags = []
tag_map[idx] = [t for t in tags if t in valid_tags]
return tag_map
async def _resolve_categories_and_tags(
raw_entries: list[dict],
model: str,
client: AsyncOpenAI | None,
settings,
) -> tuple[list[dict], AsyncOpenAI | None]:
"""Fill in categories/tags for entries extracted with main_category
possibly already set from the sheet (Format B/C):
- Entries with no valid category ("") get a full GPT classification pass.
- Entries whose sheet-given category has sub-tags in the taxonomy get a
cheaper, tags-only GPT pass — their main_category is never re-guessed.
Returns (all_entries, client) so the caller can reuse the lazily-created client.
"""
labelled = [e for e in raw_entries if e["main_category"]]
unlabelled = [e for e in raw_entries if not e["main_category"]]
print(
f" Parsed {len(raw_entries)} questions "
f"({len(labelled)} pre-labelled, {len(unlabelled)} with no valid category in the sheet)."
)
all_entries = list(labelled)
def ensure_client() -> AsyncOpenAI:
nonlocal client
if client is None:
if not settings.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is required to classify these questions.")
client = AsyncOpenAI(api_key=settings.openai_api_key)
return client
# Rows already known (from the sheet's passage column) to depend on a
# passage are classified separately, constrained to just the 4
# passage-type categories — a full 6-category pass could otherwise
# misfile a passage question under 字詞理解/語法結構.
unlabelled_passage = [e for e in unlabelled if e.get("_needs_passage_category")]
unlabelled_other = [e for e in unlabelled if not e.get("_needs_passage_category")]
if unlabelled_passage:
print(f" Classifying {len(unlabelled_passage)} passage question(s) via GPT (passage categories only)...")
c = ensure_client()
for start in range(0, len(unlabelled_passage), BATCH_SIZE):
batch = unlabelled_passage[start : start + BATCH_SIZE]
try:
all_entries.extend(await _classify_passage_category_batch(c, batch, model))
except Exception as exc:
print(f" ERROR classifying passage batch: {exc}", file=sys.stderr)
all_entries.extend({**q, "main_category": "", "tags": []} for q in batch)
if unlabelled_other:
print(f" Classifying {len(unlabelled_other)} unlabelled question(s) via GPT...")
c = ensure_client()
for start in range(0, len(unlabelled_other), BATCH_SIZE):
batch = unlabelled_other[start : start + BATCH_SIZE]
try:
all_entries.extend(await _classify_batch(c, batch, model))
except Exception as exc:
print(f" ERROR classifying unlabelled batch: {exc}", file=sys.stderr)
all_entries.extend({**q, "main_category": "", "tags": []} for q in batch)
# Pre-labelled questions whose category has sub-tags still need a
# (cheaper, tags-only) GPT call — the sheet only gives main_category.
needs_tags = [e for e in labelled if TAXONOMY.get(e["main_category"])]
if needs_tags:
print(f" Assigning tags via GPT for {len(needs_tags)} pre-labelled question(s)...")
c = ensure_client()
by_cat: dict[str, list[dict]] = {}
for e in needs_tags:
by_cat.setdefault(e["main_category"], []).append(e)
for cat, entries in by_cat.items():
for start in range(0, len(entries), BATCH_SIZE):
batch = entries[start : start + BATCH_SIZE]
try:
tag_map = await _classify_tags_batch(c, batch, model, cat)
for i, e in enumerate(batch):
e["tags"] = tag_map.get(i, [])
except Exception as exc:
print(f" ERROR assigning tags for {cat}: {exc}", file=sys.stderr)
return all_entries, client
# ---------------------------------------------------------------------------
# Shared entrypoint — the CLI (seed, below) and the in-app upload endpoint
# (main.py's /api/admin/seed-questions) both call this so format detection
# and classification logic exists in exactly one place. Never duplicate this
# logic at a call site — that's what silently drifted out of sync before.
# ---------------------------------------------------------------------------
async def classify_rows(
rows: list[list[str]],
model: str,
client: AsyncOpenAI | None,
settings,
) -> tuple[list[dict], str, AsyncOpenAI | None]:
"""Detect the sheet's format, extract questions, and classify/tag them.
Returns (entries, format, client) — format is "A"/"B"/"C"/"unknown".
Raises RuntimeError if GPT classification is needed but no API key is
configured (callers decide how to surface that — CLI exits, the web
endpoint turns it into an HTTP error).
"""
fmt = _detect_format(rows)
if fmt == "A":
raw_questions = _extract_format_a(rows)
if not raw_questions:
return [], fmt, client
if client is None:
if not settings.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is required for Format A files.")
client = AsyncOpenAI(api_key=settings.openai_api_key)
all_entries: list[dict] = []
for start in range(0, len(raw_questions), BATCH_SIZE):
batch = raw_questions[start : start + BATCH_SIZE]
try:
all_entries.extend(await _classify_batch(client, batch, model))
except Exception as exc:
print(f" ERROR classifying batch: {exc}", file=sys.stderr)
all_entries.extend({**q, "main_category": "", "tags": []} for q in batch)
return all_entries, fmt, client
if fmt == "B":
raw_entries = _extract_format_b(rows)
if not raw_entries:
return [], fmt, client
all_entries, client = await _resolve_categories_and_tags(raw_entries, model, client, settings)
return all_entries, fmt, client
if fmt == "C":
col_map = _detect_header_columns(rows[0])
assert col_map is not None # guaranteed by _detect_format returning "C"
raw_entries = _extract_labeled(rows, col_map)
if not raw_entries:
return [], fmt, client
all_entries, client = await _resolve_categories_and_tags(raw_entries, model, client, settings)
return all_entries, fmt, client
return [], fmt, client
# ---------------------------------------------------------------------------
# Main seed routine (CLI)
# ---------------------------------------------------------------------------
async def seed(html_paths: list[Path], model: str = "gpt-4o-mini") -> None:
settings = get_settings()
client: AsyncOpenAI | None = None
total = 0
for path in html_paths:
print(f"\n[{path.name}]")
rows = _parse_html(path)
try:
all_entries, fmt, client = await classify_rows(rows, model, client, settings)
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
print(f" Detected format: {fmt}")
if fmt == "unknown":
print(" ERROR: Could not detect table format — skipping.", file=sys.stderr)
continue
if not all_entries:
print(" No questions found.")
continue
print(f" Seeding {len(all_entries)} questions into question_bank...")
try:
result = await save_question_bank_batch(None, None, all_entries) # type: ignore[arg-type]
inserted = result["inserted"]
if result["duplicates"]:
print(f" Skipped {result['duplicates']} duplicates. Inserted {inserted} new.")
except Exception as exc:
print(f" ERROR seeding: {exc}", file=sys.stderr)
continue
from collections import Counter
cats = Counter(e["main_category"] or "uncategorized" for e in all_entries)
for cat, n in sorted(cats.items()):
print(f" {cat}: {n}")
total += inserted
print(f"\nDone. Total seeded: {total} questions.")
def main() -> None:
args = sys.argv[1:]
if not args:
print("Usage: python seed_from_table.py file1.html [file2.html ...]")
sys.exit(1)
paths: list[Path] = []
for a in args:
p = Path(a)
if not p.exists():
print(f"WARNING: {p} not found — skipping.", file=sys.stderr)
elif p.suffix.lower() not in (".html", ".htm"):
print(f"WARNING: {p} not HTML — skipping.", file=sys.stderr)
else:
paths.append(p)
if not paths:
print("No valid HTML files.", file=sys.stderr)
sys.exit(1)
asyncio.run(seed(paths))
if __name__ == "__main__":
main()