build-small-hackathon/hackathon-advisor
16
1"""Build the quest-classification SFT dataset.2 3Two responsibilities:4 1. Turn a crawled corpus record into the README / app-file segments that both the5 teacher labeller and the trained model see (front-loading imports and asset ids6 so the decisive evidence survives the prompt budget).7 2. Emit the chat-JSONL SFT file (manifest row + example rows) consumed by8 scripts/train_minicpm_lora.py and scripts/modal_train_quest_lora.py.9"""10from __future__ import annotations11 12import json13from typing import Any14 15from hackathon_advisor.quest_taxonomy import (16 QUEST_SYSTEM_PROMPT,17 QUESTS,18 build_app_segment,19 build_readme_segment,20 normalize_match,21 render_quest_prompt,22)23from hackathon_advisor._text import utc_now24 25 26LORA_DATASET_SCHEMA_VERSION = 127BASE_MODEL = "openbmb/MiniCPM5-1B"28ADAPTER_TASK = "hackathon_advisor_quest_classification"29 30 31def project_segments(record: dict[str, Any]) -> tuple[str, str]:32 return (33 build_readme_segment(record.get("readme_body", "")),34 build_app_segment(record.get("app_source", ""), record.get("app_signals", "")),35 )36 37 38def render_record_prompt(record: dict[str, Any], readme_segment: str, app_segment: str) -> str:39 return render_quest_prompt(40 title=record.get("title", ""),41 sdk=record.get("sdk", ""),42 declared_models=record.get("models", []),43 tags=record.get("tags", []),44 readme_segment=readme_segment,45 app_file_name=record.get("app_file", ""),46 app_file_segment=app_segment,47 )48 49 50def matches_to_completion(matches: list[dict[str, Any]]) -> str:51 """Render the gold completion exactly as the model must emit it (compact JSON)."""52 clean = [normalize_match(match) for match in matches]53 clean.sort(key=lambda match: match["confidence"], reverse=True)54 return json.dumps({"matches": clean}, ensure_ascii=False, separators=(",", ":"))55 56 57def build_example(prompt: str, matches: list[dict[str, Any]], *, meta: dict[str, Any]) -> dict[str, Any]:58 return {59 "type": "lora_sft_example",60 "schema_version": LORA_DATASET_SCHEMA_VERSION,61 "base_model": BASE_MODEL,62 "adapter_task": ADAPTER_TASK,63 "example_kind": meta.get("kind", "project"),64 "project_id": meta.get("project_id", ""),65 "variant": meta.get("variant", "natural"),66 "match_count": len(matches),67 "quests": sorted({match["quest"] for match in matches}),68 "messages": [69 {"role": "system", "content": QUEST_SYSTEM_PROMPT},70 {"role": "user", "content": prompt},71 {"role": "assistant", "content": matches_to_completion(matches)},72 ],73 }74 75 76def build_dataset_jsonl(examples: list[dict[str, Any]], *, source_note: str = "") -> str:77 quest_counts: dict[str, int] = {quest: 0 for quest in QUESTS}78 variant_counts: dict[str, int] = {}79 empty = 080 for example in examples:81 variant_counts[example["variant"]] = variant_counts.get(example["variant"], 0) + 182 if example["match_count"] == 0:83 empty += 184 for quest in example["quests"]:85 quest_counts[quest] = quest_counts.get(quest, 0) + 186 manifest = {87 "type": "lora_sft_manifest",88 "schema_version": LORA_DATASET_SCHEMA_VERSION,89 "generated_at": utc_now(),90 "app": "hackathon-advisor",91 "base_model": BASE_MODEL,92 "adapter_task": ADAPTER_TASK,93 "format": "chat-jsonl",94 "record_kinds": ["quest_classification"],95 "source": source_note or "build_small_hackathon_real_projects",96 "example_count": len(examples),97 "empty_match_examples": empty,98 "variant_counts": variant_counts,99 "quest_positive_counts": quest_counts,100 "quests": list(QUESTS),101 }102 records = [manifest, *examples]103 return "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + "\n"104 105 106def parse_quest_dataset_jsonl(text: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:107 records = [json.loads(line) for line in text.splitlines() if line.strip()]108 if not records:109 raise ValueError("quest dataset is empty")110 # Tolerate both layouts: a leading manifest row (local training file), or an111 # examples-only file (the Hub dataset, where the manifest lives in a sidecar so112 # the rows stay homogeneous for the dataset viewer). Synthesize a manifest when absent.113 if records[0].get("type") == "lora_sft_manifest":114 manifest, examples = records[0], records[1:]115 else:116 examples = records117 manifest = {118 "type": "lora_sft_manifest",119 "schema_version": LORA_DATASET_SCHEMA_VERSION,120 "base_model": BASE_MODEL,121 "adapter_task": ADAPTER_TASK,122 "format": "chat-jsonl",123 "example_count": len(examples),124 }125 for index, example in enumerate(examples, start=1):126 if example.get("type") != "lora_sft_example":127 raise ValueError(f"record {index} is not a lora_sft_example")128 messages = example.get("messages")129 if not isinstance(messages, list) or len(messages) < 2:130 raise ValueError(f"record {index} has no chat messages")131 assistant = messages[-1]132 if assistant.get("role") != "assistant" or not assistant.get("content"):133 raise ValueError(f"record {index} has no assistant completion")134 payload = json.loads(assistant["content"])135 if not isinstance(payload.get("matches"), list):136 raise ValueError(f"record {index} completion has no matches list")137 for match in payload["matches"]:138 normalize_match(match)139 return manifest, examples140 