"""Tool contracts for the atlas chat: native MiniCPM5 format, chat-specific fallback. The dashboard chat drives the BASE MiniCPM5-1B model through its native tool-calling protocol: tool JSON schemas go in via ``apply_chat_template(..., tools=...)`` and the model answers either with plain prose (no tool needed) or with one XML call of the form:: value That argument encoding (```` children, CDATA for special characters) differs from the advisor's JSON-body format in ``tool_contracts.py``, so it gets its own parser here. Validation reuses ``validate_tool_call`` against the chat tool specs. The degradation ladder is chat-specific: prose with no function call is a deliberate "no tool" outcome, while a malformed call degrades through a keyword intent router and finally to a BM25 search of the raw message — never to the advisor's ``search_projects``/``find_whitespace`` defaults, which assume the advisor's idea-board context. """ from __future__ import annotations from dataclasses import dataclass import re from typing import Any, Literal from xml.etree import ElementTree from hackathon_advisor.tool_contracts import ( ToolCall, ToolContractError, ToolField, ToolSpec, validate_tool_call, ) CHAT_TOOL_SPECS: dict[str, ToolSpec] = { "atlas_overview": ToolSpec( name="atlas_overview", description="Summarize the whole field: project totals, biggest clusters, quest coverage.", fields={}, ), "list_clusters": ToolSpec( name="list_clusters", description="List the project clusters (themes) with sizes and keywords.", fields={}, ), "show_cluster": ToolSpec( name="show_cluster", description="Inspect one cluster by its label and show example projects.", fields={ "label": ToolField("string", "Cluster label, such as Voice / Chatbot.", required=True) }, ), "list_quests": ToolSpec( name="list_quests", description="List the hackathon quests with how many projects completed each.", fields={}, ), "show_quest": ToolSpec( name="show_quest", description="Inspect one quest: its description, coverage, and example projects.", fields={ "quest": ToolField( "string", "Quest name, such as Off the Grid or Tiny Titan.", required=True ) }, ), "show_project": ToolSpec( name="show_project", description="Read one project's README and main app file by project name.", fields={"project": ToolField("string", "Project name, id, or slug.", required=True)}, ), "top_projects_by_quests": ToolSpec( name="top_projects_by_quests", description="Rank projects by how many quests they completed (the quest leaderboard).", fields={}, ), "search_projects": ToolSpec( name="search_projects", description="Full-text search across all projects on the map.", fields={ "query": ToolField("string", "Topic, model, or idea to search for.", required=True) }, ), "recent_activity": ToolSpec( name="recent_activity", description="Show the most recently updated projects.", fields={}, ), } @dataclass(frozen=True) class ChatToolResolution: """Outcome of reading one pass-1 model output. ``none`` means the model deliberately answered without a tool (chit-chat); ``call`` is None only in that case. """ status: Literal["valid", "defaulted", "none"] call: ToolCall | None errors: tuple[str, ...] def to_dict(self) -> dict[str, Any]: return { "status": self.status, "call": self.call.to_dict() if self.call else None, "errors": list(self.errors), } def chat_tool_schemas() -> list[dict[str, Any]]: return [spec.to_schema() for spec in CHAT_TOOL_SPECS.values()] _FUNCTION_BLOCK_RE = re.compile(r"", re.DOTALL) _FUNCTION_OPEN_RE = re.compile(r" ToolCall: """Extract and parse the first native-format function call in ``text``. The native template lets the model wrap a call in prose, so surrounding text is ignored; only the ``...`` block is parsed. """ block = _FUNCTION_BLOCK_RE.search(text or "") if block is None: raise ToolContractError("no call found in model output") try: node = ElementTree.fromstring(block.group(0)) except ElementTree.ParseError as error: raise ToolContractError(f"invalid native tool call XML: {error}") from error name = str(node.attrib.get("name") or "").strip() if not name: raise ToolContractError("function call is missing a name") arguments: dict[str, Any] = {} for child in node: if child.tag != "param": raise ToolContractError(f"unexpected element <{child.tag}> in function call") param_name = str(child.attrib.get("name") or "").strip() if not param_name: raise ToolContractError("param is missing a name") arguments[param_name] = _element_text(child).strip() return ToolCall(name=name, arguments=arguments) def resolve_chat_tool_call(model_output: str, fallback_query: str = "") -> ChatToolResolution: """Validate one pass-1 output, or degrade: intent router, then BM25 search.""" text = str(model_output or "") if _FUNCTION_OPEN_RE.search(text) is None: return ChatToolResolution(status="none", call=None, errors=()) errors: list[str] = [] try: call = validate_tool_call(parse_native_tool_call(text), specs=CHAT_TOOL_SPECS) return ChatToolResolution(status="valid", call=call, errors=()) except ToolContractError as error: errors.append(str(error)) call = heuristic_chat_call(fallback_query) return ChatToolResolution(status="defaulted", call=call, errors=tuple(errors)) def data_intent_call(message: str) -> ToolCall | None: """Map a message with a CLEAR data intent to a tool call; None means no clear intent. Used as the accuracy backstop when the model answers a data-shaped question in plain prose: an explicit intent routes to the matching tool, anything else (greetings, meta questions) stays conversational.""" lower = " ".join(str(message or "").casefold().split()) cleaned = " ".join(str(message or "").split()) detail_intent = _mentions( lower, ("what is in", "what's in", "inside", "show me the", "tell me about", "about the") ) if _mentions( lower, ("leaderboard", "most quest", "who completed", "top project", "top team", "winning") ): return ToolCall("top_projects_by_quests", {}) if _mentions(lower, ("cluster", "theme", "group", "region")): if detail_intent: # cluster_detail() fuzzy-resolves a label embedded in the question. return ToolCall("show_cluster", {"label": cleaned}) return ToolCall("list_clusters", {}) if _mentions(lower, ("quest", "badge", "challenge")): if detail_intent: return ToolCall("show_quest", {"quest": cleaned}) return ToolCall("list_quests", {}) if _mentions(lower, ("recent", "latest", "newest", "just updated", "activity")): return ToolCall("recent_activity", {}) if _mentions( lower, ( "overview", "everyone building", "everyone doing", "whole field", "summary of the", "most liked", "most popular", "coolest", "best project", "favorite project", ), ): return ToolCall("atlas_overview", {}) if ( _mentions( lower, ("readme", "app file", "source code", "how does", "how is", "what does", "built with"), ) or detail_intent ): # project_detail() spots a title embedded in the question; the engine falls # back to BM25 search when no project matches. return ToolCall("show_project", {"project": cleaned}) if _mentions( lower, ( "find ", "search", "looking for", "projects about", "projects on", "show me", "anything about", "who is building", "how many", "number of", "count of", "is there a", "are there any", ), ): return ToolCall("search_projects", {"query": " ".join(str(message).split())}) return None _SMALLTALK_PATTERNS = ( "hi", "hello", "hey", "yo", "thanks", "thank you", "ok", "okay", "cool", "nice", "bye", "goodbye", "why", "really", "are you sure", "who are you", "what are you", "what can you do", "how do you work", "help", ) def smalltalk_intent(message: str) -> bool: """True only for greetings, meta questions, and short follow-ups. The chat is a data-exploration surface, so the safe default for anything substantive is a tool (BM25 search) — letting an unmatched question fall through to ungrounded small talk is how the model ends up inventing facts.""" lower = " ".join(str(message or "").casefold().split()).rstrip(".!?") if not lower: return True # Pattern-table only: an unknown two-word phrase like "knitting helpers" is a # search, and an unmatched search honestly answers "no match" — never invents. return any( lower == pattern or lower.startswith(f"{pattern} ") for pattern in _SMALLTALK_PATTERNS ) def heuristic_chat_call(message: str) -> ToolCall: """Keyword intent router used when the model's tool call cannot be salvaged.""" intent = data_intent_call(message) if intent is not None: return intent cleaned = " ".join(str(message or "").split()) if cleaned: return ToolCall("search_projects", {"query": cleaned}) return ToolCall("atlas_overview", {}) def strip_function_blocks(text: str) -> str: """Remove any stray function-call XML a pass-2 generation might emit.""" return _FUNCTION_BLOCK_RE.sub("", str(text or "")).strip() def _mentions(lower_text: str, phrases: tuple[str, ...]) -> bool: return any(phrase in lower_text for phrase in phrases) def _element_text(node: ElementTree.Element) -> str: return "".join(node.itertext())