build-small-hackathon/hackathon-advisor
16
1from __future__ import annotations2 3from dataclasses import dataclass, field4from typing import Any5import uuid6 7from hackathon_advisor.data import Project, ProjectIndex, WhitespaceItem8from hackathon_advisor.scoring import ScoreCard, score_idea9 10 11GOALS = [12 "Off the Grid",13 "Well-Tuned",14 "Off-Brand",15 "Llama Champion",16 "Sharing is Caring",17 "Field Notes",18]19 20GOAL_PROFILE_BY_ID = {21 "Off the Grid": {22 "label": "Local-first",23 "description": "Favor ideas that work without proprietary inference APIs.",24 },25 "Well-Tuned": {26 "label": "Trainable",27 "description": "Shape good examples into a tiny fine-tune dataset.",28 },29 "Off-Brand": {30 "label": "Distinct voice",31 "description": "Leave room for an interface and tone people remember.",32 },33 "Llama Champion": {34 "label": "llama.cpp path",35 "description": "Prefer small-model choices that can run locally.",36 },37 "Sharing is Caring": {38 "label": "Shareable artifact",39 "description": "Make an output people can save, post, or compare.",40 },41 "Field Notes": {42 "label": "Build notes",43 "description": "Keep decisions easy to write up from the saved session.",44 },45}46 47 48def goal_profiles() -> list[dict[str, str]]:49 return [50 {51 "id": goal,52 "label": GOAL_PROFILE_BY_ID[goal]["label"],53 "description": GOAL_PROFILE_BY_ID[goal]["description"],54 }55 for goal in GOALS56 ]57 58 59def goal_label(goal: str) -> str:60 return GOAL_PROFILE_BY_ID.get(goal, {}).get("label", goal)61 62 63def normalize_goals(raw_goals: Any, default: list[str] | None = None) -> list[str]:64 if raw_goals is None:65 return list(default or [])66 if not isinstance(raw_goals, list):67 return list(default or [])68 69 goals: list[str] = []70 seen: set[str] = set()71 for raw_goal in raw_goals:72 goal = str(raw_goal)73 if goal in GOALS and goal not in seen:74 goals.append(goal)75 seen.add(goal)76 return goals77 78 79def goals_from_state(state: dict[str, Any]) -> list[str]:80 if "goals" not in state:81 return GOALS[:3]82 return normalize_goals(state.get("goals"), default=[])83 84 85@dataclass86class Idea:87 id: str88 title: str89 pitch: str90 goals: list[str] = field(default_factory=lambda: GOALS[:3])91 score: dict | None = None92 artifact: dict[str, Any] | None = None93 94 def to_dict(self) -> dict:95 return {96 "id": self.id,97 "title": self.title,98 "pitch": self.pitch,99 "goals": self.goals,100 "score": self.score,101 "artifact": self.artifact,102 }103 104 105@dataclass(frozen=True)106class ToolEvent:107 name: str108 summary: str109 110 def to_dict(self) -> dict:111 return {"name": self.name, "summary": self.summary}112 113 114class AdvisorTools:115 def __init__(self, index: ProjectIndex) -> None:116 self.index = index117 118 def list_projects(self, limit: int = 8) -> tuple[list[Project], ToolEvent]:119 projects = self.index.top_projects(limit=limit)120 return projects, ToolEvent("list_projects", f"Read {len(projects)} prominent Space cards.")121 122 def search_projects(self, query: str, limit: int = 5) -> tuple[list[Project], ToolEvent]:123 hits = self.index.search(query, limit=limit)124 projects = [hit.project for hit in hits]125 return projects, ToolEvent("search_projects", f"Found {len(projects)} nearby Space echoes.")126 127 def find_whitespace(self, limit: int = 5) -> tuple[list[WhitespaceItem], ToolEvent]:128 items = self.index.find_whitespace(limit=limit)129 return items, ToolEvent("find_whitespace", f"Ranked {len(items)} under-explored regions.")130 131 def save_idea(self, state: dict[str, Any], title: str, pitch: str) -> tuple[Idea, ToolEvent]:132 ideas = [Idea(**item) for item in state.get("ideas", [])]133 current_id = state.get("current_idea_id")134 goals = goals_from_state(state)135 idea = next((item for item in ideas if item.id == current_id), None)136 if idea is None or _is_new_idea(idea, title, pitch):137 idea = Idea(id=uuid.uuid4().hex[:8], title=title, pitch=pitch, goals=goals)138 ideas.append(idea)139 else:140 idea.title = title141 idea.pitch = pitch142 idea.goals = goals143 state["ideas"] = [item.to_dict() for item in ideas]144 state["current_idea_id"] = idea.id145 return idea, ToolEvent("save_idea", f"Wrote idea page '{idea.title}'.")146 147 def score_idea(self, idea: Idea) -> tuple[ScoreCard, ToolEvent]:148 score = score_idea(self.index, idea.title, idea.pitch, idea.goals)149 idea.score = score.to_dict()150 return score, ToolEvent("score_idea", f"Pressed a five-quadrant seal: {score.overall}/10.")151 152 def make_plan(self, idea: Idea, profile: dict[str, Any] | None = None) -> tuple[list[str], ToolEvent]:153 plan = [154 "Lock a one-sentence promise and one test input that proves what is different.",155 "Compare against the nearest echoes, then sharpen the part only this idea can own.",156 "Build the smallest happy path: input, nearby project citations, score, and one shareable output.",157 "Add one selected-goal feature only after the core loop is smooth enough to explain without narration.",158 "Write build notes from the exact decisions, screenshots, and outputs.",159 ]160 profile_steps = profile_plan_steps(profile)161 if profile_steps:162 plan[1:1] = profile_steps163 if any("Well" in goal for goal in idea.goals):164 plan.insert(165 max(0, len(plan) - 1),166 "Collect successful advisor examples before training a tiny LoRA.",167 )168 return plan, ToolEvent("make_plan", f"Drafted {len(plan)} build steps.")169 170 171def idea_from_text(text: str) -> tuple[str, str]:172 cleaned = " ".join(text.strip().split())173 if not cleaned:174 return "Blank Page", "A project direction waiting for one concrete user and one concrete tension."175 title = cleaned176 for prefix in ("i want to build", "build", "make", "my idea is", "idea:"):177 if cleaned.lower().startswith(prefix):178 title = cleaned[len(prefix) :].strip(" :-")179 break180 pitch = cleaned181 explicit_pitch = False182 if " -- " in title:183 title, pitch = (part.strip() for part in title.split(" -- ", 1))184 explicit_pitch = True185 raw_title = title186 title = raw_title[:64].strip(" .") or "Unwritten Page"187 if len(raw_title) > 64 or (not explicit_pitch and len(title) < len(cleaned)):188 title = f"{title[:58].strip()}..."189 return _display_title(title), pitch190 191 192def _is_new_idea(current: Idea, title: str, pitch: str) -> bool:193 return current.title.strip().casefold() != title.strip().casefold() or current.pitch.strip() != pitch.strip()194 195 196def profile_plan_steps(profile: dict[str, Any] | None) -> list[str]:197 if not isinstance(profile, dict):198 return []199 steps: list[str] = []200 time = _short_profile_value(profile.get("time"))201 skills = _short_profile_value(profile.get("skills"))202 constraints = _short_profile_value(profile.get("constraints"))203 preferences = _short_profile_value(profile.get("preferences"))204 if time:205 steps.append(f"Scope the first prototype to {time}; cut anything that cannot fit that window.")206 if skills:207 steps.append(f"Use your {skills} strength for the first working surface before adding new tooling.")208 if constraints:209 steps.append(f"Test the constraint early: {constraints}. Do this before polishing the artifact.")210 if preferences:211 steps.append(f"Shape the demo around {preferences} so the result feels intentional, not generic.")212 return steps213 214 215def _short_profile_value(value: Any, limit: int = 84) -> str:216 text = " ".join(str(value or "").split())217 if len(text) <= limit:218 return text219 return text[: limit - 3].rstrip(" ,.;:") + "..."220 221 222def _display_title(title: str) -> str:223 if not title:224 return "Unwritten Page"225 if any(char.isupper() or char.isdigit() for char in title):226 return title[0].upper() + title[1:]227 return title.capitalize()228 