build-small-hackathon/hackathon-advisor
16
1"""Tool contracts for the atlas chat: native MiniCPM5 format, chat-specific fallback.2 3The dashboard chat drives the BASE MiniCPM5-1B model through its native4tool-calling protocol: tool JSON schemas go in via ``apply_chat_template(...,5tools=...)`` and the model answers either with plain prose (no tool needed) or6with one XML call of the form::7 8 <function name="tool_name"><param name="arg">value</param></function>9 10That argument encoding (``<param>`` children, CDATA for special characters)11differs from the advisor's JSON-body format in ``tool_contracts.py``, so it gets12its own parser here. Validation reuses ``validate_tool_call`` against the chat13tool specs. The degradation ladder is chat-specific: prose with no function call14is a deliberate "no tool" outcome, while a malformed call degrades through a15keyword intent router and finally to a BM25 search of the raw message — never to16the advisor's ``search_projects``/``find_whitespace`` defaults, which assume the17advisor's idea-board context.18"""19 20from __future__ import annotations21 22from dataclasses import dataclass23import re24from typing import Any, Literal25from xml.etree import ElementTree26 27from hackathon_advisor.tool_contracts import (28 ToolCall,29 ToolContractError,30 ToolField,31 ToolSpec,32 validate_tool_call,33)34 35CHAT_TOOL_SPECS: dict[str, ToolSpec] = {36 "atlas_overview": ToolSpec(37 name="atlas_overview",38 description="Summarize the whole field: project totals, biggest clusters, quest coverage.",39 fields={},40 ),41 "list_clusters": ToolSpec(42 name="list_clusters",43 description="List the project clusters (themes) with sizes and keywords.",44 fields={},45 ),46 "show_cluster": ToolSpec(47 name="show_cluster",48 description="Inspect one cluster by its label and show example projects.",49 fields={50 "label": ToolField("string", "Cluster label, such as Voice / Chatbot.", required=True)51 },52 ),53 "list_quests": ToolSpec(54 name="list_quests",55 description="List the hackathon quests with how many projects completed each.",56 fields={},57 ),58 "show_quest": ToolSpec(59 name="show_quest",60 description="Inspect one quest: its description, coverage, and example projects.",61 fields={62 "quest": ToolField(63 "string", "Quest name, such as Off the Grid or Tiny Titan.", required=True64 )65 },66 ),67 "show_project": ToolSpec(68 name="show_project",69 description="Read one project's README and main app file by project name.",70 fields={"project": ToolField("string", "Project name, id, or slug.", required=True)},71 ),72 "top_projects_by_quests": ToolSpec(73 name="top_projects_by_quests",74 description="Rank projects by how many quests they completed (the quest leaderboard).",75 fields={},76 ),77 "search_projects": ToolSpec(78 name="search_projects",79 description="Full-text search across all projects on the map.",80 fields={81 "query": ToolField("string", "Topic, model, or idea to search for.", required=True)82 },83 ),84 "recent_activity": ToolSpec(85 name="recent_activity",86 description="Show the most recently updated projects.",87 fields={},88 ),89}90 91 92@dataclass(frozen=True)93class ChatToolResolution:94 """Outcome of reading one pass-1 model output.95 96 ``none`` means the model deliberately answered without a tool (chit-chat);97 ``call`` is None only in that case.98 """99 100 status: Literal["valid", "defaulted", "none"]101 call: ToolCall | None102 errors: tuple[str, ...]103 104 def to_dict(self) -> dict[str, Any]:105 return {106 "status": self.status,107 "call": self.call.to_dict() if self.call else None,108 "errors": list(self.errors),109 }110 111 112def chat_tool_schemas() -> list[dict[str, Any]]:113 return [spec.to_schema() for spec in CHAT_TOOL_SPECS.values()]114 115 116_FUNCTION_BLOCK_RE = re.compile(r"<function\b.*?</function>", re.DOTALL)117_FUNCTION_OPEN_RE = re.compile(r"<function\b")118 119 120def parse_native_tool_call(text: str) -> ToolCall:121 """Extract and parse the first native-format function call in ``text``.122 123 The native template lets the model wrap a call in prose, so surrounding text124 is ignored; only the ``<function ...>...</function>`` block is parsed.125 """126 block = _FUNCTION_BLOCK_RE.search(text or "")127 if block is None:128 raise ToolContractError("no <function> call found in model output")129 try:130 node = ElementTree.fromstring(block.group(0))131 except ElementTree.ParseError as error:132 raise ToolContractError(f"invalid native tool call XML: {error}") from error133 name = str(node.attrib.get("name") or "").strip()134 if not name:135 raise ToolContractError("function call is missing a name")136 arguments: dict[str, Any] = {}137 for child in node:138 if child.tag != "param":139 raise ToolContractError(f"unexpected element <{child.tag}> in function call")140 param_name = str(child.attrib.get("name") or "").strip()141 if not param_name:142 raise ToolContractError("param is missing a name")143 arguments[param_name] = _element_text(child).strip()144 return ToolCall(name=name, arguments=arguments)145 146 147def resolve_chat_tool_call(model_output: str, fallback_query: str = "") -> ChatToolResolution:148 """Validate one pass-1 output, or degrade: intent router, then BM25 search."""149 text = str(model_output or "")150 if _FUNCTION_OPEN_RE.search(text) is None:151 return ChatToolResolution(status="none", call=None, errors=())152 153 errors: list[str] = []154 try:155 call = validate_tool_call(parse_native_tool_call(text), specs=CHAT_TOOL_SPECS)156 return ChatToolResolution(status="valid", call=call, errors=())157 except ToolContractError as error:158 errors.append(str(error))159 160 call = heuristic_chat_call(fallback_query)161 return ChatToolResolution(status="defaulted", call=call, errors=tuple(errors))162 163 164def data_intent_call(message: str) -> ToolCall | None:165 """Map a message with a CLEAR data intent to a tool call; None means no clear intent.166 167 Used as the accuracy backstop when the model answers a data-shaped question in plain168 prose: an explicit intent routes to the matching tool, anything else (greetings,169 meta questions) stays conversational."""170 lower = " ".join(str(message or "").casefold().split())171 cleaned = " ".join(str(message or "").split())172 detail_intent = _mentions(173 lower, ("what is in", "what's in", "inside", "show me the", "tell me about", "about the")174 )175 if _mentions(176 lower, ("leaderboard", "most quest", "who completed", "top project", "top team", "winning")177 ):178 return ToolCall("top_projects_by_quests", {})179 if _mentions(lower, ("cluster", "theme", "group", "region")):180 if detail_intent:181 # cluster_detail() fuzzy-resolves a label embedded in the question.182 return ToolCall("show_cluster", {"label": cleaned})183 return ToolCall("list_clusters", {})184 if _mentions(lower, ("quest", "badge", "challenge")):185 if detail_intent:186 return ToolCall("show_quest", {"quest": cleaned})187 return ToolCall("list_quests", {})188 if _mentions(lower, ("recent", "latest", "newest", "just updated", "activity")):189 return ToolCall("recent_activity", {})190 if _mentions(191 lower,192 (193 "overview",194 "everyone building",195 "everyone doing",196 "whole field",197 "summary of the",198 "most liked",199 "most popular",200 "coolest",201 "best project",202 "favorite project",203 ),204 ):205 return ToolCall("atlas_overview", {})206 if (207 _mentions(208 lower,209 ("readme", "app file", "source code", "how does", "how is", "what does", "built with"),210 )211 or detail_intent212 ):213 # project_detail() spots a title embedded in the question; the engine falls214 # back to BM25 search when no project matches.215 return ToolCall("show_project", {"project": cleaned})216 if _mentions(217 lower,218 (219 "find ",220 "search",221 "looking for",222 "projects about",223 "projects on",224 "show me",225 "anything about",226 "who is building",227 "how many",228 "number of",229 "count of",230 "is there a",231 "are there any",232 ),233 ):234 return ToolCall("search_projects", {"query": " ".join(str(message).split())})235 return None236 237 238_SMALLTALK_PATTERNS = (239 "hi",240 "hello",241 "hey",242 "yo",243 "thanks",244 "thank you",245 "ok",246 "okay",247 "cool",248 "nice",249 "bye",250 "goodbye",251 "why",252 "really",253 "are you sure",254 "who are you",255 "what are you",256 "what can you do",257 "how do you work",258 "help",259)260 261 262def smalltalk_intent(message: str) -> bool:263 """True only for greetings, meta questions, and short follow-ups.264 265 The chat is a data-exploration surface, so the safe default for anything266 substantive is a tool (BM25 search) — letting an unmatched question fall267 through to ungrounded small talk is how the model ends up inventing facts."""268 lower = " ".join(str(message or "").casefold().split()).rstrip(".!?")269 if not lower:270 return True271 # Pattern-table only: an unknown two-word phrase like "knitting helpers" is a272 # search, and an unmatched search honestly answers "no match" — never invents.273 return any(274 lower == pattern or lower.startswith(f"{pattern} ") for pattern in _SMALLTALK_PATTERNS275 )276 277 278def heuristic_chat_call(message: str) -> ToolCall:279 """Keyword intent router used when the model's tool call cannot be salvaged."""280 intent = data_intent_call(message)281 if intent is not None:282 return intent283 cleaned = " ".join(str(message or "").split())284 if cleaned:285 return ToolCall("search_projects", {"query": cleaned})286 return ToolCall("atlas_overview", {})287 288 289def strip_function_blocks(text: str) -> str:290 """Remove any stray function-call XML a pass-2 generation might emit."""291 return _FUNCTION_BLOCK_RE.sub("", str(text or "")).strip()292 293 294def _mentions(lower_text: str, phrases: tuple[str, ...]) -> bool:295 return any(phrase in lower_text for phrase in phrases)296 297 298def _element_text(node: ElementTree.Element) -> str:299 return "".join(node.itertext())300 