build-small-hackathon/hackathon-advisor
16
1from __future__ import annotations2 3from collections.abc import Mapping, Sequence4from dataclasses import dataclass5from hashlib import sha2566import json7import os8from pathlib import Path9from typing import Any10from uuid import uuid411 12from hackathon_advisor.data import Project13from hackathon_advisor.quest_analysis import (14 METADATA_FIRST_QUEST_ANALYZER_SOURCE,15 MAX_QUEST_TOKENS,16 QuestAnalysisError,17 render_project_inference_prompt,18 resolve_quest_identity,19 validate_matches_by_project,20)21from hackathon_advisor.quest_taxonomy import (22 APP_PROMPT_CHAR_LIMIT,23 QUEST_PROFILES,24 QUEST_SYSTEM_PROMPT,25 README_PROMPT_CHAR_LIMIT,26)27from hackathon_advisor._text import utc_now28 29 30QUEST_CACHE_SCHEMA_VERSION = 131QUEST_CACHE_ROOT = Path("quest-cache") / "v1"32QUEST_PROMPT_VERSION = "quest-prompt-v2-metadata-first"33QUEST_ANALYZER_SOURCE = METADATA_FIRST_QUEST_ANALYZER_SOURCE34QUEST_GENERATION_CONFIG = {35 "enable_thinking": False,36 "temperature": 0.0,37 "do_sample": False,38 "max_new_tokens": MAX_QUEST_TOKENS,39}40 41 42@dataclass(frozen=True)43class QuestCacheIdentity:44 project_id: str45 prompt_hash: str46 taxonomy_hash: str47 analyzer_fingerprint: dict[str, Any]48 cache_key: str49 50 def to_dict(self) -> dict[str, Any]:51 return {52 "project_id": self.project_id,53 "prompt_hash": self.prompt_hash,54 "taxonomy_hash": self.taxonomy_hash,55 "analyzer_fingerprint": self.analyzer_fingerprint,56 "cache_key": self.cache_key,57 }58 59 60@dataclass(frozen=True)61class QuestCacheEntry:62 identity: QuestCacheIdentity63 matches: list[dict[str, Any]]64 source: str65 path: Path66 generated_at: str67 68 69@dataclass(frozen=True)70class QuestCacheLookup:71 identity: QuestCacheIdentity72 entry: QuestCacheEntry | None73 reason: str74 75 76def quest_analyzer_fingerprint_from_env(env: Mapping[str, str] | None = None) -> dict[str, Any]:77 model_id, adapter_id, adapter_revision = resolve_quest_identity(env)78 return {79 "source": QUEST_ANALYZER_SOURCE,80 "model_id": model_id,81 "adapter_id": adapter_id,82 "adapter_revision": adapter_revision,83 "adapter_digest": _local_artifact_digest(adapter_id),84 "prompt_version": QUEST_PROMPT_VERSION,85 "generation": dict(QUEST_GENERATION_CONFIG),86 }87 88 89def quest_taxonomy_hash() -> str:90 payload = {91 "system_prompt": QUEST_SYSTEM_PROMPT,92 "quest_profiles": list(QUEST_PROFILES),93 "readme_prompt_char_limit": README_PROMPT_CHAR_LIMIT,94 "app_prompt_char_limit": APP_PROMPT_CHAR_LIMIT,95 "prompt_version": QUEST_PROMPT_VERSION,96 }97 return sha256(_canonical_json(payload).encode("utf-8")).hexdigest()98 99 100def build_quest_cache_identity(101 project: Project,102 analyzer_fingerprint: Mapping[str, Any],103) -> QuestCacheIdentity:104 prompt_hash = sha256(render_project_inference_prompt(project).encode("utf-8")).hexdigest()105 taxonomy_hash = quest_taxonomy_hash()106 canonical_fingerprint = json.loads(_canonical_json(analyzer_fingerprint))107 key_payload = {108 "schema_version": QUEST_CACHE_SCHEMA_VERSION,109 "project_id": project.id,110 "prompt_hash": prompt_hash,111 "taxonomy_hash": taxonomy_hash,112 "analyzer_fingerprint": canonical_fingerprint,113 }114 cache_key = sha256(_canonical_json(key_payload).encode("utf-8")).hexdigest()115 return QuestCacheIdentity(116 project_id=project.id,117 prompt_hash=prompt_hash,118 taxonomy_hash=taxonomy_hash,119 analyzer_fingerprint=canonical_fingerprint,120 cache_key=cache_key,121 )122 123 124def quest_cache_path(cache_dir: Path, cache_key: str) -> Path:125 return cache_dir / QUEST_CACHE_ROOT / cache_key[:2] / f"{cache_key}.json"126 127 128def read_quest_cache_entry(129 cache_dir: Path,130 project: Project,131 analyzer_fingerprint: Mapping[str, Any],132) -> QuestCacheLookup:133 identity = build_quest_cache_identity(project, analyzer_fingerprint)134 path = quest_cache_path(cache_dir, identity.cache_key)135 if not path.is_file():136 return QuestCacheLookup(identity=identity, entry=None, reason="absent")137 try:138 payload = json.loads(path.read_text(encoding="utf-8"))139 except (OSError, json.JSONDecodeError) as error:140 return QuestCacheLookup(identity=identity, entry=None, reason=f"invalid_json:{error}")141 if not isinstance(payload, dict):142 return QuestCacheLookup(identity=identity, entry=None, reason="invalid_payload")143 try:144 entry = _validate_cache_payload(payload, project, identity, path)145 except QuestAnalysisError as error:146 return QuestCacheLookup(identity=identity, entry=None, reason=f"invalid_schema:{error}")147 return QuestCacheLookup(identity=identity, entry=entry, reason="hit")148 149 150def write_quest_cache_entry(151 cache_dir: Path,152 project: Project,153 analyzer_fingerprint: Mapping[str, Any],154 matches: Sequence[Mapping[str, Any]],155 *,156 source: str,157) -> QuestCacheEntry:158 identity = build_quest_cache_identity(project, analyzer_fingerprint)159 validated = validate_matches_by_project({project.id: list(matches)}, [project], source=source)160 generated_at = utc_now()161 payload = {162 "schema_version": QUEST_CACHE_SCHEMA_VERSION,163 "generated_at": generated_at,164 "source": validated.source,165 **identity.to_dict(),166 "matches": validated.matches_by_project[project.id],167 }168 path = quest_cache_path(cache_dir, identity.cache_key)169 path.parent.mkdir(parents=True, exist_ok=True)170 tmp_path = path.parent / f".{path.name}.{uuid4().hex}.tmp"171 tmp_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")172 os.replace(tmp_path, path)173 return QuestCacheEntry(174 identity=identity,175 matches=validated.matches_by_project[project.id],176 source=validated.source,177 path=path,178 generated_at=generated_at,179 )180 181 182def quest_cache_run_record(183 *,184 project: Project,185 identity: QuestCacheIdentity,186 matches: Sequence[Mapping[str, Any]],187 status: str,188 source: str,189 path: Path | None = None,190) -> dict[str, Any]:191 return {192 "project_id": project.id,193 "cache_key": identity.cache_key,194 "prompt_hash": identity.prompt_hash,195 "taxonomy_hash": identity.taxonomy_hash,196 "status": status,197 "source": source,198 "cache_path": path.as_posix() if path is not None else "",199 "matches": [dict(match) for match in matches],200 }201 202 203def build_quest_analysis_run_payload(204 *,205 run_id: str,206 analyzer_fingerprint: Mapping[str, Any],207 summary: Mapping[str, Any],208 project_records: Sequence[Mapping[str, Any]],209) -> dict[str, Any]:210 return {211 "schema_version": QUEST_CACHE_SCHEMA_VERSION,212 "run_id": run_id,213 "generated_at": utc_now(),214 "source": QUEST_ANALYZER_SOURCE,215 "analyzer_fingerprint": json.loads(_canonical_json(analyzer_fingerprint)),216 "taxonomy_hash": quest_taxonomy_hash(),217 "summary": dict(summary),218 "projects": [dict(record) for record in project_records],219 }220 221 222def _validate_cache_payload(223 payload: Mapping[str, Any],224 project: Project,225 identity: QuestCacheIdentity,226 path: Path,227) -> QuestCacheEntry:228 if payload.get("schema_version") != QUEST_CACHE_SCHEMA_VERSION:229 raise QuestAnalysisError("unsupported quest cache schema")230 for field, expected in identity.to_dict().items():231 if payload.get(field) != expected:232 raise QuestAnalysisError(f"cache {field} mismatch")233 source = str(payload.get("source") or QUEST_ANALYZER_SOURCE)234 validated = validate_matches_by_project({project.id: payload.get("matches") or []}, [project], source=source)235 generated_at = str(payload.get("generated_at") or "")236 return QuestCacheEntry(237 identity=identity,238 matches=validated.matches_by_project[project.id],239 source=validated.source,240 path=path,241 generated_at=generated_at,242 )243 244 245def _local_artifact_digest(raw_path: str) -> str:246 if not raw_path:247 return ""248 path = Path(raw_path).expanduser()249 if not path.is_absolute():250 path = (Path.cwd() / path).resolve()251 if not path.exists():252 return ""253 digest = sha256()254 if path.is_file():255 _hash_file_into(digest, path, path.name)256 return digest.hexdigest()257 for file_path in sorted(item for item in path.rglob("*") if item.is_file()):258 _hash_file_into(digest, file_path, file_path.relative_to(path).as_posix())259 return digest.hexdigest()260 261 262def _hash_file_into(digest: Any, file_path: Path, relative_name: str) -> None:263 digest.update(relative_name.encode("utf-8"))264 digest.update(b"\0")265 with file_path.open("rb") as handle:266 for chunk in iter(lambda: handle.read(1024 * 1024), b""):267 digest.update(chunk)268 digest.update(b"\0")269 270 271def _canonical_json(payload: Any) -> str:272 return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))273 