File size: 16,842 Bytes
a04f9ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
"""Annotate academy discussion posts into an eval-ready dataset.

Reads ``data/academy_discussion_posts.jsonl`` (scraped course discussion
threads) and emits ``data/academy_discussion_eval.jsonl``, one annotated
record per thread, combining:

- deterministic fields extracted in code: the cleaned question, the earliest
  reply written by course staff (the reference answer), the full thread in
  chronological order, and the corpus source key for the course; and
- LLM annotations (Gemini structured output): category, expected tutor
  behavior, a self-contained rewrite of the question, evergreen key points
  distilled from the staff answer, and an overall eval-quality grade.

The output is the raw material for eval datasets: filter on
``annotation.eval_quality`` / ``annotation.category`` to build the Q&A set
(judge against ``key_points``), the out-of-scope behavior set
(``expected_behavior == "redirect_to_support"``), and retrieval ground truth
(``source_key`` + ``lesson_url``).

Resumable: post_ids already present in the output file are skipped unless
``--force`` is passed.

Usage:
    uv run -m data.scraping_scripts.annotate_discussion_posts [--limit N] [--force]
"""

import argparse
import asyncio
import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal

from dotenv import load_dotenv
from google import genai
from google.genai import types
from google.genai.errors import APIError
from pydantic import BaseModel, Field
from tenacity import (
    retry,
    retry_if_exception,
    stop_after_attempt,
    wait_random_exponential,
)
from tqdm.asyncio import tqdm

load_dotenv(".env")

DEFAULT_INPUT = Path("data/academy_discussion_posts.jsonl")
DEFAULT_OUTPUT = Path("data/academy_discussion_eval.jsonl")
ANNOTATION_MODEL = os.getenv("ANNOTATION_MODEL", "gemini-3.5-flash")
ANNOTATION_CONCURRENCY = int(os.getenv("ANNOTATION_CONCURRENCY", "8"))
RETRYABLE_GENAI_STATUS_CODES = {408, 429, 500, 502, 503, 504}

# People who answer threads in an official capacity. Replies from anyone else
# (students helping each other, the original poster following up) are kept in
# the thread but never used as the reference answer. Derived from responders
# who reply in threads they did not start; edit as the team changes.
STAFF_RESPONDERS = {
    "Jaiganesan N",
    "Louis-François Bouchard",
    "Paul Iusztin",
    "Samridhi",
    "Louie Peters",
    "Omar Solano",
}

# Dataset course_name -> source key in source_registry.SOURCE_CONFIGS.
# Courses absent from the corpus map to None (the tutor has no material for
# them, which the annotator is told about).
COURSE_TO_SOURCE_KEY: dict[str, str | None] = {
    "Full Stack AI Engineering": "full_stack_ai_engineering",
    "Agent Engineering: Building Multi-Agent Systems": "agentic_ai_engineering",
    "Beginner Python for AI Engineering": "beginner_python_for_ai_engineering",
    "Master AI For Work": "master_ai_for_work",
    "8-hour Generative AI Primer": None,
}


class PostAnnotation(BaseModel):
    """LLM-produced annotation for one discussion thread."""

    category: Literal[
        "conceptual", "debugging", "course_feedback", "platform_issue", "other"
    ] = Field(
        description=(
            "conceptual: technical/theory question about course material. "
            "debugging: an error or unexpected result in the student's code, "
            "notebook, or environment. course_feedback: typo/errata reports or "
            "suggestions about course content. platform_issue: academy website, "
            "videos, quizzes, certificates, accounts, billing, community access. "
            "other: greetings, kudos, or unintelligible posts."
        )
    )
    expected_behavior: Literal[
        "answer_from_corpus",
        "answer_general",
        "redirect_to_support",
        "acknowledge_feedback",
    ] = Field(
        description=(
            "What an ideal tutor response does. answer_from_corpus: answer "
            "grounded in course/docs material. answer_general: sound technical "
            "answer that likely goes beyond the corpus. redirect_to_support: "
            "the tutor cannot act on this; it should say so briefly and point "
            "to the support team, without inventing troubleshooting steps. "
            "acknowledge_feedback: thank the student and suggest reporting the "
            "issue to the team."
        )
    )
    self_contained: bool = Field(
        description=(
            "True if the question can be fully understood without seeing the "
            "lesson page, a screenshot, a quiz, or other context the tutor "
            "does not have."
        )
    )
    standalone_question: str | None = Field(
        description=(
            "A minimally rewritten, self-contained version of the question "
            "suitable to send to the tutor: resolve references like 'this "
            "lesson', 'Image 8', or 'the notebook' using the course and "
            "lesson names; keep the student's wording and code verbatim "
            "otherwise. Null if the question cannot be made self-contained."
        )
    )
    key_points: list[str] = Field(
        description=(
            "1-4 evergreen technical claims distilled from STAFF replies, "
            "each independently checkable against a candidate answer (the "
            "diagnosis, the fix, the concept). Exclude insider or time-bound "
            "content: notebook-update promises, platform actions, version "
            "numbers true only at the time. Empty if there is no staff reply "
            "or it contains nothing technical."
        )
    )
    excluded_from_key_points: str | None = Field(
        description=(
            "One short note on staff-reply content deliberately left out of "
            "key_points (e.g. 'promise to update the notebook'). Null if "
            "nothing was excluded."
        )
    )
    time_bound: bool = Field(
        description=(
            "True if the correct answer depends on a point-in-time state "
            "(package version, notebook revision, platform status) that may "
            "have changed since."
        )
    )
    eval_quality: Literal["gold", "usable", "weak", "exclude"] = Field(
        description=(
            "Suitability as an eval case for the eval its expected_behavior "
            "implies. gold: clear self-contained question with a solid staff "
            "answer yielding key points, or an unambiguous redirect case. "
            "usable: good question with a partial/thin reference or needing "
            "the rewrite. weak: marginal; heavily time-bound or barely "
            "intelligible. exclude: no eval value (greetings, kudos, empty)."
        )
    )
    notes: str = Field(description="One-line rationale for the grades above.")


ANNOTATION_PROMPT = """\
You are annotating a student discussion thread from an online AI course so it \
can be turned into an evaluation case for an AI tutor chatbot.

About the tutor being evaluated:
- It answers questions grounded in a corpus of course materials and AI library \
docs (Transformers, PEFT, TRL, LlamaIndex, LangChain, LangGraph, OpenAI docs, \
and the courses themselves), and can optionally search the web.
- It CANNOT: access the academy platform (videos, quizzes, assignments, \
certificates, accounts, billing, Slack/Discord), see screenshots or images, \
run or update course notebooks, or change course content.

Thread metadata:
- Course: {course_name} ({corpus_note})
- Lesson: {lesson_name}
- The original post {media_note}.

Thread (chronological; STAFF = official course staff, STUDENT = learner):

{thread_text}

Annotate the thread per the response schema. Additional rules:
- key_points must come only from STAFF replies. If there is no staff reply, \
key_points must be empty and eval_quality is at most "usable".
- standalone_question preserves the student's voice and any code verbatim; it \
only resolves references the tutor could not see. If the post fundamentally \
depends on unseen content (a screenshot, quiz option text, a specific page), \
set self_contained to false and standalone_question to null.
- A clear platform_issue is still a valuable eval case (the tutor should \
redirect gracefully), so grade it on that basis rather than excluding it.
"""

_genai_client: genai.Client | None = None


def get_genai_client() -> genai.Client:
    global _genai_client
    if _genai_client is None:
        api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
        if not api_key:
            raise RuntimeError("GEMINI_API_KEY or GOOGLE_API_KEY must be set.")
        _genai_client = genai.Client(api_key=api_key)
    return _genai_client


def is_retryable_genai_error(exc: BaseException) -> bool:
    return isinstance(exc, APIError) and exc.code in RETRYABLE_GENAI_STATUS_CODES


def parse_post_date(text: str | None) -> str | None:
    if not text:
        return None
    cleaned = " ".join(text.split())
    for fmt in ("%B %d, %Y", "%b %d, %Y"):
        try:
            return datetime.strptime(cleaned, fmt).date().isoformat()
        except ValueError:
            continue
    return None


def parse_byline(byline: str | None) -> tuple[str | None, str | None]:
    """Split 'Posted by NAME on\\nDATE' into (name, ISO date)."""
    if not byline:
        return None, None
    match = re.search(r"Posted by\s+(.+?)\s+on\b\s*(.*)$", byline, re.DOTALL)
    if not match:
        return " ".join(byline.split()) or None, None
    name = " ".join(match.group(1).split())
    return name or None, parse_post_date(match.group(2))


def build_thread(record: dict) -> list[dict]:
    """Replies in chronological order (the source lists newest first)."""
    thread = []
    for resp in reversed(record.get("responses") or []):
        author = (resp.get("responded_by") or "").strip()
        content = (resp.get("content") or "").strip()
        if not content:
            continue
        thread.append(
            {
                "role": "staff" if author in STAFF_RESPONDERS else "student",
                "author": author,
                "date": parse_post_date(resp.get("responded_at")),
                "content": content,
                "links": resp.get("links") or [],
            }
        )
    return thread


def first_staff_reply(thread: list[dict]) -> dict | None:
    return next((r for r in thread if r["role"] == "staff"), None)


def render_thread(
    question: str, asked_by: str | None, asked_at: str | None, thread: list[dict]
) -> str:
    parts = [
        f"[1] STUDENT {asked_by or 'Unknown'} ({asked_at or 'unknown date'}) — original post:\n{question}"
    ]
    for i, reply in enumerate(thread, start=2):
        parts.append(
            f"[{i}] {reply['role'].upper()} {reply['author']} "
            f"({reply['date'] or 'unknown date'}):\n{reply['content']}"
        )
    return "\n\n".join(parts)


def build_record(raw: dict) -> dict:
    """Deterministic part of the output record (everything but `annotation`)."""
    asked_by, asked_at = parse_byline((raw.get("original_post") or {}).get("byline"))
    title = (raw.get("discussion_title") or "").strip()
    body = ((raw.get("original_post") or {}).get("content") or "").strip()
    question = f"{title}\n\n{body}".strip() if title else body
    thread = build_thread(raw)
    reference = first_staff_reply(thread)
    course_name = raw.get("course_name") or ""

    return {
        "post_id": raw.get("post_id"),
        "discussion_url": raw.get("discussion_url"),
        "discussion_title": title,
        "course_name": course_name,
        "source_key": COURSE_TO_SOURCE_KEY.get(course_name),
        "lesson_name": raw.get("lesson_name"),
        "lesson_url": raw.get("lesson_url"),
        "asked_by": asked_by,
        "asked_at": asked_at,
        "question": question,
        "question_has_media": bool((raw.get("original_post") or {}).get("media")),
        "reference_answer": reference["content"] if reference else None,
        "reference_answer_by": reference["author"] if reference else None,
        "reference_answer_at": reference["date"] if reference else None,
        "reference_links": reference["links"] if reference else [],
        "thread": thread,
    }


@retry(
    retry=retry_if_exception(is_retryable_genai_error),
    stop=stop_after_attempt(6),
    wait=wait_random_exponential(multiplier=1, max=60),
    reraise=True,
)
async def annotate_record(record: dict, model: str) -> PostAnnotation:
    source_key = record["source_key"]
    corpus_note = (
        f"in the tutor's corpus as source '{source_key}'"
        if source_key
        else "NOT in the tutor's corpus"
    )
    media_note = (
        "includes an image/screenshot the tutor cannot see"
        if record["question_has_media"]
        else "has no attached media"
    )
    prompt = ANNOTATION_PROMPT.format(
        course_name=record["course_name"],
        corpus_note=corpus_note,
        lesson_name=record["lesson_name"] or "unknown",
        media_note=media_note,
        thread_text=render_thread(
            record["question"],
            record["asked_by"],
            record["asked_at"],
            record["thread"],
        ),
    )
    response = await get_genai_client().aio.models.generate_content(
        model=model,
        contents=prompt,
        config=types.GenerateContentConfig(
            temperature=0.0,
            response_mime_type="application/json",
            response_schema=PostAnnotation,
        ),
    )
    if isinstance(response.parsed, PostAnnotation):
        return response.parsed
    if isinstance(response.parsed, dict):
        return PostAnnotation.model_validate(response.parsed)
    if response.text:
        return PostAnnotation.model_validate_json(response.text)
    raise ValueError(f"Gemini returned no annotation for post {record['post_id']}.")


def load_jsonl(path: Path) -> list[dict]:
    if not path.exists():
        return []
    with path.open() as fh:
        return [json.loads(line) for line in fh if line.strip()]


def write_jsonl(path: Path, records: list[dict]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w") as fh:
        for record in records:
            fh.write(json.dumps(record, ensure_ascii=False) + "\n")


async def annotate_all(records: list[dict], model: str) -> list[dict]:
    semaphore = asyncio.Semaphore(ANNOTATION_CONCURRENCY)
    annotated_at = datetime.now(UTC).isoformat(timespec="seconds")

    async def worker(record: dict) -> dict:
        async with semaphore:
            annotation = await annotate_record(record, model)
        return {
            **record,
            "annotation": annotation.model_dump(),
            "annotation_model": model,
            "annotated_at": annotated_at,
        }

    return await tqdm.gather(*(worker(r) for r in records), desc="Annotating")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
    parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
    parser.add_argument("--model", default=ANNOTATION_MODEL)
    parser.add_argument(
        "--limit", type=int, default=None, help="Annotate at most N pending posts."
    )
    parser.add_argument(
        "--force",
        action="store_true",
        help="Re-annotate posts already present in the output file.",
    )
    args = parser.parse_args()

    raw_records = load_jsonl(args.input)
    if not raw_records:
        raise SystemExit(f"No records found in {args.input}.")

    existing = {r["post_id"]: r for r in load_jsonl(args.output)}
    records = [build_record(raw) for raw in raw_records]
    pending = [r for r in records if args.force or r["post_id"] not in existing]
    if args.limit is not None:
        pending = pending[: args.limit]

    print(
        f"{len(records)} threads in input, {len(existing)} already annotated, "
        f"{len(pending)} to annotate with {args.model}."
    )
    if pending:
        for record in asyncio.run(annotate_all(pending, args.model)):
            existing[record["post_id"]] = record

    merged = sorted(existing.values(), key=lambda r: int(r["post_id"]))
    write_jsonl(args.output, merged)

    counts: dict[str, int] = {}
    for record in merged:
        quality = record["annotation"]["eval_quality"]
        category = record["annotation"]["category"]
        counts[f"quality={quality}"] = counts.get(f"quality={quality}", 0) + 1
        counts[f"category={category}"] = counts.get(f"category={category}", 0) + 1
    print(f"Wrote {len(merged)} annotated threads to {args.output}.")
    for key in sorted(counts):
        print(f"  {key}: {counts[key]}")


if __name__ == "__main__":
    main()