""" Instruction parser: converts human text into a structured origami task. Handles variations like: - "make a paper crane" - "fold me a crane" - "i want to make a paper crane" - "crane please" - "can you fold a crane?" - "pack a 1m x 1m mylar sheet as compact as possible" """ from __future__ import annotations import re from planner.knowledge import ORIGAMI_MODELS, list_known_models # --------------------------------------------------------------------------- # Vocabulary for matching # --------------------------------------------------------------------------- # Model aliases → canonical name _MODEL_ALIASES: dict[str, str] = { # Crane "crane": "crane", "tsuru": "crane", "bird": "crane", "orizuru": "crane", # Boat "boat": "boat", "hat": "boat", "paper boat": "boat", "paper hat": "boat", # Airplane "airplane": "airplane", "aeroplane": "airplane", "plane": "airplane", "paper airplane": "airplane", "paper plane": "airplane", "dart": "airplane", "paper dart": "airplane", # Box "box": "box", "masu": "box", "masu box": "box", "open box": "box", # Fortune teller "fortune teller": "fortune_teller", "fortune-teller": "fortune_teller", "cootie catcher": "fortune_teller", "cootie-catcher": "fortune_teller", "chatterbox": "fortune_teller", # Waterbomb / balloon "waterbomb": "waterbomb", "water bomb": "waterbomb", "balloon": "waterbomb", "paper balloon": "waterbomb", # Jumping frog "jumping frog": "jumping_frog", "frog": "jumping_frog", "leap frog": "jumping_frog", } # Sorted longest-first so multi-word aliases match before single-word ones _ALIAS_KEYS_SORTED = sorted(_MODEL_ALIASES.keys(), key=len, reverse=True) _MATERIALS = { "paper": "paper", "mylar": "mylar", "aluminum": "aluminum", "aluminium": "aluminum", "metal": "metal", "nitinol": "nitinol", "foil": "aluminum", "cardboard": "cardboard", "cardstock": "cardstock", "fabric": "fabric", "cloth": "fabric", } # Intent keywords _FOLD_VERBS = { "make", "fold", "create", "build", "construct", "origami", "craft", "form", "shape", "assemble", } _PACK_VERBS = { "pack", "compress", "compact", "minimize", "reduce", "stow", "shrink", "deploy", "collapse", } _OPTIMIZE_PHRASES = [ "as compact as possible", "minimize volume", "minimize packed volume", "minimize area", "solar panel", "stent", "deployable", "maximize compactness", "flatten", ] # Dimension patterns _DIM_PATTERNS = [ # "10cm x 10cm", "10 cm x 10 cm" re.compile( r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet)", re.IGNORECASE, ), # "10cm square", "1 meter square" re.compile( r"(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches|ft|feet|meter|meters|metre|metres)\s+square", re.IGNORECASE, ), ] _UNIT_TO_M = { "m": 1.0, "meter": 1.0, "meters": 1.0, "metre": 1.0, "metres": 1.0, "cm": 0.01, "mm": 0.001, "in": 0.0254, "inch": 0.0254, "inches": 0.0254, "ft": 0.3048, "feet": 0.3048, } # --------------------------------------------------------------------------- # Parsing helpers # --------------------------------------------------------------------------- def _normalise(text: str) -> str: """Lower-case and strip extra whitespace.""" return " ".join(text.lower().split()) def _detect_model(text: str) -> str | None: """Return canonical model name if one is mentioned, else None.""" norm = _normalise(text) # Strip out constraint phrases that might contain model-name false positives # e.g. "into a 15cm x 15cm x 5cm box" should not match "box" as a model cleaned = re.sub( r"(?:fit\s+)?(?:in(?:to)?|inside)\s+(?:a\s+)?\d.*?box", "", norm, ) for alias in _ALIAS_KEYS_SORTED: # Use word-boundary-aware search to avoid partial matches pattern = r"(?:^|\b)" + re.escape(alias) + r"(?:\b|$)" if re.search(pattern, cleaned): return _MODEL_ALIASES[alias] return None def _detect_material(text: str) -> str: """Return detected material or default 'paper'.""" norm = _normalise(text) # Check multi-word materials first (none currently, but future-proof) for keyword, canonical in sorted(_MATERIALS.items(), key=lambda kv: -len(kv[0])): if keyword in norm: return canonical return "paper" def _detect_dimensions(text: str) -> dict[str, float]: """Parse explicit dimensions. Returns {"width": m, "height": m} or defaults.""" for pat in _DIM_PATTERNS: m = pat.search(text) if m: groups = m.groups() if len(groups) == 4: # WxH pattern w = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0) h = float(groups[2]) * _UNIT_TO_M.get(groups[3].lower(), 1.0) return {"width": round(w, 6), "height": round(h, 6)} elif len(groups) == 2: # "N unit square" side = float(groups[0]) * _UNIT_TO_M.get(groups[1].lower(), 1.0) return {"width": round(side, 6), "height": round(side, 6)} # Default: unit square return {"width": 1.0, "height": 1.0} def _detect_constraints(text: str) -> dict: """Detect any explicit constraints mentioned in the instruction.""" norm = _normalise(text) constraints: dict = {} # Target bounding box: "fit in a 15cm x 15cm x 5cm box" box_pat = re.compile( r"fit\s+(?:in(?:to)?|inside)\s+(?:a\s+)?(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*" r"(\d+(?:\.\d+)?)\s*(cm|mm|m)\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*(cm|mm|m)", re.IGNORECASE, ) bm = box_pat.search(norm) if bm: g = bm.groups() constraints["target_box"] = [ float(g[0]) * _UNIT_TO_M.get(g[1], 1.0), float(g[2]) * _UNIT_TO_M.get(g[3], 1.0), float(g[4]) * _UNIT_TO_M.get(g[5], 1.0), ] # Max folds folds_pat = re.compile(r"(?:max(?:imum)?|at most|no more than)\s+(\d+)\s+fold", re.IGNORECASE) fm = folds_pat.search(norm) if fm: constraints["max_folds"] = int(fm.group(1)) # Compactness emphasis for phrase in _OPTIMIZE_PHRASES: if phrase in norm: constraints["optimize_compactness"] = True break # Must deploy if "deploy" in norm or "unfold" in norm and "clean" in norm: constraints["must_deploy"] = True return constraints def _detect_intent(text: str, model_name: str | None, constraints: dict) -> str: """Determine the high-level intent of the instruction.""" norm = _normalise(text) words = set(norm.split()) # If packing / optimization phrases are present, it's an optimization task if constraints.get("optimize_compactness"): return "optimize_packing" if words & _PACK_VERBS: return "optimize_packing" # If a known model is detected, it's a fold_model task if model_name is not None: return "fold_model" # If fold verbs are present but no model, it's a free fold if words & _FOLD_VERBS: return "free_fold" # Fallback: if there's a pattern keyword pattern_words = {"miura", "tessellation", "pattern", "waterbomb tessellation", "pleat"} if words & pattern_words: return "fold_pattern" return "free_fold" # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def parse_instruction(text: str) -> dict: """ Parse a human origami instruction into a structured task. Args: text: Natural-language instruction, e.g. "make a paper crane" Returns: { "intent": "fold_model" | "fold_pattern" | "optimize_packing" | "free_fold", "model_name": str or None, "material": str, "dimensions": {"width": float, "height": float}, "constraints": {...}, "raw_instruction": str, } """ model_name = _detect_model(text) material = _detect_material(text) dimensions = _detect_dimensions(text) constraints = _detect_constraints(text) intent = _detect_intent(text, model_name, constraints) return { "intent": intent, "model_name": model_name, "material": material, "dimensions": dimensions, "constraints": constraints, "raw_instruction": text, }