CoolFace
Apppublic

build-small-hackathon/hackathon-advisor

sourceHugging Facemitupdated 3mo agoView on Hugging Face
16likes
quest_analysis.py502 linesDownload Raw Back to hackathon_advisor
1from __future__ import annotations2 3from collections.abc import Mapping, Sequence4from contextlib import nullcontext5from dataclasses import dataclass6import json7import os8from typing import Any, Protocol9 10from hackathon_advisor.config import first_nonempty_env11from hackathon_advisor.data import Project, normalize_project_tags12from hackathon_advisor.model_runtime import (13    DEFAULT_MODEL_ID,14    _minicpm_generation_kwargs,15    _load_minicpm_causal_lm,16    _minicpm_chat_inputs,17    _resolve_torch_device,18)19from hackathon_advisor.quest_taxonomy import (20    QUEST_SYSTEM_PROMPT,21    QUESTS,22    build_app_segment,23    build_readme_segment,24    canonical_quest_ids,25    declared_quest_matches_from_tags,26    metadata_suppressed_quests_from_tags,27    normalize_match,28    render_quest_prompt,29)30 31 32MAX_QUEST_TOKENS = 102433DEFAULT_QUEST_ADAPTER_ID = "build-small-hackathon/hackathon-advisor-quest-minicpm5-lora"34DEFAULT_QUEST_ADAPTER_REVISION = ""35METADATA_FIRST_QUEST_ANALYZER_SOURCE = "metadata-first-minicpm-json-quest-analyzer"36 37 38class QuestAnalysisError(RuntimeError):39    pass40 41 42class QuestAnalyzer(Protocol):43    source: str44 45    def analyze(self, projects: Sequence[Project]) -> dict[str, list[dict[str, Any]]]:46        ...47 48 49@dataclass(frozen=True)50class ValidatedQuestAnalysis:51    matches_by_project: dict[str, list[dict[str, Any]]]52    source: str53 54 55class MiniCPMQuestAnalyzer:56    source = METADATA_FIRST_QUEST_ANALYZER_SOURCE57 58    def __init__(59        self,60        model_id: str = DEFAULT_MODEL_ID,61        *,62        device: str = "auto",63        adapter_id: str = DEFAULT_QUEST_ADAPTER_ID,64        adapter_revision: str = DEFAULT_QUEST_ADAPTER_REVISION,65    ) -> None:66        self.model_id = model_id.strip() or DEFAULT_MODEL_ID67        self.device = (device or "auto").strip().lower() or "auto"68        self.adapter_id = adapter_id.strip()69        self.adapter_revision = adapter_revision.strip()70        self.resolved_device = ""71        self._tokenizer = None72        self._model = None73 74    def analyze(self, projects: Sequence[Project]) -> dict[str, list[dict[str, Any]]]:75        matches: dict[str, list[dict[str, Any]]] = {}76        for project in projects:77            declared_matches = declared_project_quest_matches(project)78            remaining_quests = remaining_project_quest_ids(project)79            if not remaining_quests:80                matches[project.id] = declared_matches81                continue82            self._ensure_loaded()83            try:84                raw = self._generate_json(85                    render_project_quest_prompt(project, quest_ids=remaining_quests)86                )87                validated = self._validate_or_repair_project(project, raw).matches_by_project88                matches[project.id] = merge_declared_and_inferred_matches(89                    declared_matches,90                    validated.get(project.id, []),91                )92            except QuestAnalysisError as error:93                # Tolerate a single unparseable project: record empty matches and continue, so one94                # malformed model output never aborts a whole-org refresh.95                print(f"[quest-analysis] skipped {project.id}: {error}", flush=True)96                matches[project.id] = declared_matches97        return matches98 99    def _validate_or_repair_project(self, project: Project, raw: Mapping[str, Any]) -> ValidatedQuestAnalysis:100        try:101            return _validate_single_project_payload(project, raw)102        except QuestAnalysisError as error:103            repaired = self._repair_schema_json(raw, str(error))104            try:105                return _validate_single_project_payload(project, repaired)106            except QuestAnalysisError as repair_error:107                raise QuestAnalysisError(f"{error}; MiniCPM schema repair failed: {repair_error}") from repair_error108 109    def _ensure_loaded(self) -> None:110        if self._model is not None and self._tokenizer is not None:111            return112        try:113            import torch114            from transformers import AutoModelForCausalLM, AutoTokenizer115            if self.adapter_id:116                from peft import PeftConfig, PeftModel117        except ImportError as error:118            raise QuestAnalysisError(119                "MiniCPM quest analysis requires torch and transformers (and peft when "120                "ADVISOR_QUEST_ADAPTER_ID is set). Install runtime requirements before enabling dashboard refresh."121            ) from error122 123        base_model_id = self.model_id124        tokenizer_id = self.adapter_id or base_model_id125        adapter_kwargs = {"revision": self.adapter_revision} if self.adapter_revision else {}126        if self.adapter_id:127            adapter_config = PeftConfig.from_pretrained(self.adapter_id, **adapter_kwargs)128            base_model_id = str(adapter_config.base_model_name_or_path or base_model_id)129 130        target = _resolve_torch_device(self.device, torch)131        self.resolved_device = target132        self._tokenizer = AutoTokenizer.from_pretrained(133            tokenizer_id,134            trust_remote_code=True,135            **(adapter_kwargs if self.adapter_id else {}),136        )137        model = _load_minicpm_causal_lm(AutoModelForCausalLM, base_model_id, target, torch)138        if self.adapter_id:139            model = PeftModel.from_pretrained(model, self.adapter_id, **adapter_kwargs)140            if target not in ("auto", "cpu"):141                model = model.to(target)142        model.eval()143        self._model = model144 145    def _generate_json(self, prompt: str) -> dict[str, Any]:146        text = self._generate_text(QUEST_SYSTEM_PROMPT, prompt)147        try:148            parsed = _extract_json_object(text)149        except QuestAnalysisError as error:150            try:151                # Deterministic repair first: escape unescaped double quotes inside string values152                # (the model copies snippets like class="x" verbatim). Avoids an LLM round-trip and153                # preserves the evidence text exactly.154                parsed = _extract_json_object(_escape_unescaped_quotes(text))155            except QuestAnalysisError:156                repaired = self._repair_invalid_json(text)157                try:158                    parsed = _extract_json_object(repaired)159                except QuestAnalysisError as repair_error:160                    preview = " ".join(text.split())[:280]161                    repair_preview = " ".join(repaired.split())[:280]162                    raise QuestAnalysisError(163                        f"{error}: {preview}; MiniCPM JSON repair failed: {repair_error}: {repair_preview}"164                    ) from repair_error165        if not isinstance(parsed, dict):166            raise QuestAnalysisError("quest analyzer did not return a JSON object")167        return parsed168 169    def _generate_text(self, system_prompt: str, user_prompt: str, *, disable_adapter: bool = False) -> str:170        import torch171 172        assert self._tokenizer is not None173        assert self._model is not None174        messages = [175            {"role": "system", "content": system_prompt},176            {"role": "user", "content": user_prompt},177        ]178        inputs = _minicpm_chat_inputs(179            self._tokenizer,180            messages,181            enable_thinking=False,182            device=next(self._model.parameters()).device,183        )184        generation_kwargs = _minicpm_generation_kwargs(185            inputs,186            max_new_tokens=MAX_QUEST_TOKENS,187            temperature=0.0,  # strict JSON wants deterministic greedy decoding188        )189        generation_kwargs["eos_token_id"] = self._chat_eos_token_id()190        adapter_context = _disabled_adapter(self._model) if disable_adapter else nullcontext()191        with adapter_context, torch.inference_mode():192            output = self._model.generate(**generation_kwargs)193        generated = output[:, inputs["input_ids"].shape[-1] :]194        return self._tokenizer.decode(generated[0], skip_special_tokens=True).strip()195 196    def _repair_invalid_json(self, invalid_output: str) -> str:197        repair_system = "You repair JSON. Return exactly one valid JSON object and nothing else."198        repair_prompt = "\n".join(199            [200                "Rewrite this invalid JSON as valid compact JSON.",201                "Every match object must contain exactly these keys: quest, confidence, evidence, source.",202                "Keep the same matches, quest names, confidence values, and source values.",203                "If an evidence value contains unescaped double quote characters, escape them or paraphrase the evidence.",204                "Never omit source. If a source key was damaged by malformed JSON, infer readme or app_file from the damaged object.",205                "Drop any match whose quest is not valid, or whose source cannot be inferred as readme or app_file.",206                f"Valid quests: {', '.join(QUESTS)}.",207                'Valid sources: readme, app_file.',208                "Do not copy any text from these repair instructions into evidence.",209                "",210                "Invalid JSON:",211                invalid_output,212            ]213        )214        return self._generate_text(repair_system, repair_prompt, disable_adapter=True)215 216    def _repair_schema_json(self, parsed_output: Mapping[str, Any], validation_error: str) -> dict[str, Any]:217        repair_system = "You repair JSON schemas. Return exactly one valid JSON object and nothing else."218        repair_prompt = "\n".join(219            [220                "The following quest-classification JSON parsed, but failed schema validation.",221                f"Validation error: {validation_error}",222                "",223                "Rewrite it to satisfy this schema exactly:",224                '{"matches":[{"quest":"...","confidence":0.1,"evidence":"...","source":"readme"}]}',225                "",226                "Rules:",227                f"- quest must be one of: {', '.join(QUESTS)}.",228                "- source must be readme or app_file.",229                "- Every match object must include source; never omit it.",230                "- If source is missing but evidence clearly came from code, use app_file; if it clearly came from prose, use readme.",231                "- confidence must be greater than 0 and no more than 1.",232                "- Keep at most one match per quest; keep the strongest and clearest evidence.",233                "- Remove matches with empty evidence or evidence copied from the quest instructions.",234                "- Do not copy any text from these repair instructions into evidence.",235                "- Do not add new quests that are not already intended by the input.",236                "",237                "Input JSON:",238                json.dumps(parsed_output, ensure_ascii=False, separators=(",", ":")),239            ]240        )241        repaired = self._generate_text(repair_system, repair_prompt, disable_adapter=True)242        parsed = _extract_json_object(repaired)243        if not isinstance(parsed, dict):244            raise QuestAnalysisError("MiniCPM schema repair did not return a JSON object")245        return parsed246 247    def _chat_eos_token_id(self) -> int:248        assert self._tokenizer is not None249        token_id = self._tokenizer.convert_tokens_to_ids("<|im_end|>")250        if not isinstance(token_id, int) or token_id < 0:251            raise QuestAnalysisError("MiniCPM tokenizer is missing the <|im_end|> chat terminator")252        return token_id253 254 255def resolve_quest_identity(env: Mapping[str, str] | None = None) -> tuple[str, str, str]:256    """Resolve ``(model_id, adapter_id, adapter_revision)`` for the quest analyzer.257 258    Shared by ``create_quest_analyzer`` (the live load) and the quest-cache fingerprint so259    the serving runtime and the cache key resolve identically (e.g. on whitespace-padded env).260    """261    model_id = first_nonempty_env(262        "ADVISOR_QUEST_MODEL_ID", "ADVISOR_MODEL_ID", default=DEFAULT_MODEL_ID, env=env263    )264    adapter_id = first_nonempty_env("ADVISOR_QUEST_ADAPTER_ID", default=DEFAULT_QUEST_ADAPTER_ID, env=env)265    adapter_revision = first_nonempty_env(266        "ADVISOR_QUEST_ADAPTER_REVISION", default=DEFAULT_QUEST_ADAPTER_REVISION, env=env267    )268    return model_id, adapter_id, adapter_revision269 270 271def create_quest_analyzer(device: str = "auto") -> QuestAnalyzer:272    backend = os.environ.get("ADVISOR_QUEST_ANALYZER_BACKEND", "").strip().lower()273    if not backend:274        backend = os.environ.get("ADVISOR_MODEL_BACKEND", "").strip().lower()275    if backend in {"minicpm", "minicpm-transformers"}:276        model_id, adapter_id, adapter_revision = resolve_quest_identity()277        return MiniCPMQuestAnalyzer(278            model_id,279            device=device,280            adapter_id=adapter_id,281            adapter_revision=adapter_revision,282        )283    raise QuestAnalysisError(284        "Dashboard refresh requires ADVISOR_QUEST_ANALYZER_BACKEND=minicpm-transformers. "285        f"Got {backend or 'unset'}."286    )287 288 289def validate_quest_analysis_payload(290    payload: Mapping[str, Any],291    projects: Sequence[Project],292    *,293    source: str = "validated-json",294) -> ValidatedQuestAnalysis:295    rows = payload.get("projects")296    if not isinstance(rows, list):297        raise QuestAnalysisError("quest analysis JSON must contain a projects list")298    expected_ids = [project.id for project in projects]299    expected = set(expected_ids)300    seen: set[str] = set()301    matches_by_project: dict[str, list[dict[str, Any]]] = {}302    for row in rows:303        if not isinstance(row, dict):304            raise QuestAnalysisError("quest project rows must be objects")305        project_id = str(row.get("project_id") or "")306        if project_id not in expected:307            raise QuestAnalysisError(f"quest analysis returned an unknown project id: {project_id}")308        if project_id in seen:309            raise QuestAnalysisError(f"quest analysis returned a duplicate project id: {project_id}")310        seen.add(project_id)311        matches_by_project[project_id] = _validate_project_matches(row.get("matches"), project_id)312    missing = [project_id for project_id in expected_ids if project_id not in seen]313    if missing:314        raise QuestAnalysisError(f"quest analysis missed {len(missing)} projects")315    return ValidatedQuestAnalysis(matches_by_project=matches_by_project, source=source)316 317 318def validate_matches_by_project(319    matches_by_project: Mapping[str, Sequence[Mapping[str, Any]]],320    projects: Sequence[Project],321    *,322    source: str,323) -> ValidatedQuestAnalysis:324    payload = {325        "projects": [326            {327                "project_id": project.id,328                "matches": list(matches_by_project.get(project.id, [])),329            }330            for project in projects331        ]332    }333    return validate_quest_analysis_payload(payload, projects, source=source)334 335 336def _validate_single_project_payload(project: Project, raw: Mapping[str, Any]) -> ValidatedQuestAnalysis:337    return validate_quest_analysis_payload(338        {339            "projects": [340                {341                    "project_id": project.id,342                    "matches": raw.get("matches"),343                }344            ]345        },346        [project],347    )348 349 350def render_project_quest_prompt(project: Project, quest_ids: Sequence[str] | None = None) -> str:351    """Render the strict two-segment quest prompt from a snapshot Project.352 353    Refresh snapshots carry the same raw README body and main app source segments354    used for quest LoRA training.355    """356    return render_quest_prompt(357        title=project.title,358        sdk=project.sdk,359        declared_models=project.models,360        tags=normalize_project_tags(project.tags),361        readme_segment=build_readme_segment(project.readme_body),362        app_file_name=project.app_file,363        app_file_segment=build_app_segment(project.app_file_source, project.app_file_embedding_text),364        quest_ids=quest_ids,365    )366 367 368def render_project_inference_prompt(project: Project) -> str:369    """Render the exact prompt used for model inference after metadata claims."""370    return render_project_quest_prompt(project, quest_ids=remaining_project_quest_ids(project))371 372 373def declared_project_quest_matches(project: Project) -> list[dict[str, Any]]:374    """Return quest matches declared by official Build Small Space metadata."""375    return declared_quest_matches_from_tags(normalize_project_tags(project.tags))376 377 378def remaining_project_quest_ids(project: Project) -> tuple[str, ...]:379    tags = normalize_project_tags(project.tags)380    declared = {match["quest"] for match in declared_quest_matches_from_tags(tags)}381    suppressed = metadata_suppressed_quests_from_tags(tags)382    return tuple(quest for quest in QUESTS if quest not in declared and quest not in suppressed)383 384 385def merge_declared_and_inferred_matches(386    declared_matches: Sequence[Mapping[str, Any]],387    inferred_matches: Sequence[Mapping[str, Any]],388) -> list[dict[str, Any]]:389    """Merge metadata and model matches, keeping metadata as the authority."""390    merged: list[dict[str, Any]] = []391    seen: set[str] = set()392    for raw_match in (*declared_matches, *inferred_matches):393        match = normalize_match(raw_match)394        if match["quest"] in seen:395            continue396        seen.add(match["quest"])397        merged.append(match)398    return merged399 400 401def _validate_project_matches(raw_matches: Any, project_id: str) -> list[dict[str, Any]]:402    if not isinstance(raw_matches, list):403        raise QuestAnalysisError(f"quest matches for {project_id} must be a list")404    matches: list[dict[str, Any]] = []405    seen: set[str] = set()406    for raw_match in raw_matches:407        if not isinstance(raw_match, dict):408            raise QuestAnalysisError(f"quest matches for {project_id} must be objects")409        try:410            quest_ids = canonical_quest_ids(raw_match.get("quest"))411        except ValueError as error:412            raise QuestAnalysisError(f"quest match for {project_id}: {error}") from error413        for quest_id in quest_ids:414            try:415                match = normalize_match({**raw_match, "quest": quest_id})416            except ValueError as error:417                raise QuestAnalysisError(f"quest match for {project_id}: {error}") from error418            if match["quest"] in seen:419                raise QuestAnalysisError(f"duplicate quest for {project_id}: {match['quest']}")420            seen.add(match["quest"])421            matches.append(match)422    return matches423 424 425def _escape_unescaped_quotes(text: str) -> str:426    """Escape double quotes inside JSON string values that are not string terminators.427 428    The quest model sometimes copies code verbatim into a free-text field, e.g.429    ``"evidence":"class="x" ..."``. A quote closes a string only when the next430    non-whitespace character is a JSON structural token (``: , } ]``) or end of input;431    any other in-string quote is escaped so ``json.loads`` can parse the value.432    """433    out: list[str] = []434    in_string = False435    i = 0436    length = len(text)437    while i < length:438        char = text[i]439        if not in_string:440            out.append(char)441            if char == '"':442                in_string = True443            i += 1444            continue445        if char == "\\":446            out.append(char)447            if i + 1 < length:448                out.append(text[i + 1])449                i += 2450            else:451                i += 1452            continue453        if char == '"':454            nxt = i + 1455            while nxt < length and text[nxt] in " \t\r\n":456                nxt += 1457            if nxt >= length or text[nxt] in ":,}]":458                out.append(char)459                in_string = False460            else:461                out.append('\\"')462            i += 1463            continue464        out.append(char)465        i += 1466    return "".join(out)467 468 469def _extract_json_object(text: str) -> Any:470    text = _strip_json_fence(text.strip())471    decoder = json.JSONDecoder()472    for index, char in enumerate(text):473        if char != "{":474            continue475        try:476            value, offset = decoder.raw_decode(text[index:])477        except json.JSONDecodeError:478            continue479        if text[index + offset :].strip():480            continue481        return value482    raise QuestAnalysisError("quest analyzer returned invalid JSON")483 484 485def _disabled_adapter(model: Any) -> Any:486    disable_adapter = getattr(model, "disable_adapter", None)487    if callable(disable_adapter):488        return disable_adapter()489    return nullcontext()490 491 492def _strip_json_fence(text: str) -> str:493    if not text.startswith("```"):494        return text495    lines = text.splitlines()496    if len(lines) < 3 or not lines[-1].strip().startswith("```"):497        return text498    opener = lines[0].strip().lower()499    if opener not in {"```", "```json"}:500        return text501    return "\n".join(lines[1:-1]).strip()502