from __future__ import annotations from dataclasses import dataclass import os import re from typing import Any, Protocol from hackathon_advisor.tools import idea_from_text from hackathon_advisor.tool_contracts import ToolResolution, resolve_tool_call, tool_schemas DEFAULT_MODEL_ID = "openbmb/MiniCPM5-1B" DEFAULT_BACKEND = "rules" class ToolPlanner(Protocol): backend: str model_id: str def plan(self, message: str, state: dict[str, Any]) -> ToolResolution: ... @dataclass(frozen=True) class RuntimeStatus: backend: str model_id: str loaded: bool tool_count: int def to_dict(self) -> dict[str, Any]: return { "backend": self.backend, "model_id": self.model_id, "loaded": self.loaded, "tool_count": self.tool_count, } class RuleBasedPlanner: backend = "rules" model_id = "deterministic-tool-router" def plan(self, message: str, state: dict[str, Any]) -> ToolResolution: text = " ".join(message.strip().split()) lower = text.lower() project_id = _project_reference_id(text) if not text: output = '{"sort":"likes"}' elif _wants_project_list(lower): output = '{"sort":"likes"}' elif project_id: output = f'{{"id":{_json_string(project_id)}}}' elif any(term in lower for term in ("compare", "choose", "rank")): output = '{}' elif any(term in lower for term in ("plan", "roadmap", "next step", "milestone")): output = '{}' elif any(term in lower for term in ("whitespace", "original", "new", "bolder", "unwritten", "gap")): output = '{}' elif any(term in lower for term in ("search", "similar", "already", "existing", "overlap", "echo")): output = f'{{"query":{_json_string(text)}}}' else: title, pitch = idea_from_text(text) output = ( f'' f'{{"title":{_json_string(title)},"pitch":{_json_string(pitch)}}}' f"" ) return resolve_tool_call(output, fallback_query=text) class MiniCPMTransformersPlanner: backend = "minicpm-transformers" def __init__(self, model_id: str = DEFAULT_MODEL_ID) -> None: self.model_id = model_id self._tokenizer = None self._model = None def plan(self, message: str, state: dict[str, Any]) -> ToolResolution: self._ensure_loaded() prompt = render_context(message, state) output = self._generate_tool_call(prompt) return resolve_tool_call(output, fallback_query=message) def _ensure_loaded(self) -> None: if self._model is not None and self._tokenizer is not None: return try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer except ImportError as error: raise RuntimeError( "ADVISOR_MODEL_BACKEND=minicpm-transformers requires optional model dependencies. " "Install the model extra before enabling it." ) from error self._tokenizer = AutoTokenizer.from_pretrained(self.model_id, trust_remote_code=True) self._model = AutoModelForCausalLM.from_pretrained( self.model_id, torch_dtype="auto", device_map="auto", trust_remote_code=True, ) if hasattr(torch, "inference_mode"): self._inference_mode = torch.inference_mode def _generate_tool_call(self, prompt: str) -> str: assert self._tokenizer is not None assert self._model is not None messages = [ {"role": "system", "content": system_prompt()}, {"role": "user", "content": prompt}, ] inputs = self._tokenizer.apply_chat_template( messages, tools=tool_schemas(), add_generation_prompt=True, enable_thinking=False, tokenize=True, return_dict=True, return_tensors="pt", ).to(self._model.device) generated = self._model.generate( **inputs, max_new_tokens=180, do_sample=False, ) new_tokens = generated[:, inputs["input_ids"].shape[-1] :] return self._tokenizer.decode(new_tokens[0], skip_special_tokens=True).strip() def create_tool_planner() -> ToolPlanner: backend = os.environ.get("ADVISOR_MODEL_BACKEND", DEFAULT_BACKEND).strip().lower() if backend in ("", "rules"): return RuleBasedPlanner() if backend in ("minicpm", "minicpm-transformers"): return MiniCPMTransformersPlanner(os.environ.get("ADVISOR_MODEL_ID", DEFAULT_MODEL_ID)) raise RuntimeError(f"Unsupported ADVISOR_MODEL_BACKEND={backend!r}") def runtime_status(planner: ToolPlanner) -> RuntimeStatus: return RuntimeStatus( backend=planner.backend, model_id=planner.model_id, loaded=not isinstance(planner, MiniCPMTransformersPlanner) or planner._model is not None, tool_count=len(tool_schemas()), ) def render_context(message: str, state: dict[str, Any]) -> str: ideas = state.get("ideas") or [] trace = state.get("trace") or [] idea_lines = [ f"- {idea.get('title', 'Untitled')}: {idea.get('pitch', '')}" for idea in ideas[-3:] ] trace_lines = [ f"- {event.get('input', '')} -> {event.get('verdict', '')} {event.get('overall', '')}" for event in trace[-3:] ] return "\n".join( [ "Choose exactly one tool call for the next advisor action.", "Return only {...json...}.", f"User message: {message}", "Idea board:", *(idea_lines or ["- empty"]), "Recent trace:", *(trace_lines or ["- empty"]), ] ) def system_prompt() -> str: return ( "You are The Unwritten Almanac's originality and build-plan advisor. " "Use tools to inspect existing projects, find whitespace, save ideas, score ideas, and make plans. " "Emit exactly one XML tool call." ) def _json_string(value: str) -> str: import json return json.dumps(value, ensure_ascii=False) def _wants_project_list(lower_text: str) -> bool: exact_phrases = ( "projects", "spaces", "current map", "project map", ) command_prefixes = ( "list projects", "list spaces", "show projects", "show spaces", "show current map", "show project map", "open current map", "browse projects", "browse spaces", ) return lower_text in exact_phrases or any(lower_text.startswith(prefix) for prefix in command_prefixes) def _project_reference_id(text: str) -> str: prefixes = ( "read project ", "open project ", "show project ", "read space ", "open space ", "show space ", ) lower = text.lower() raw = "" for prefix in prefixes: if lower.startswith(prefix): raw = text[len(prefix) :].strip() break if not raw: return "" raw = re.sub(r"^https?://huggingface\.co/spaces/", "", raw, flags=re.IGNORECASE) return raw.split()[0].strip(".,;:!?\"'") def _title(text: str) -> str: title = text[:64].strip(" .") or "Unwritten Page" if any(char.isupper() or char.isdigit() for char in title): return title[0].upper() + title[1:] return title.capitalize()