kiwi/app/services/recipe/time_effort.py
pyr0ball b1e187c779 feat: time & effort signals — active/passive split, effort cards, annotated steps (kiwi#50)
- Add app/services/recipe/time_effort.py: parse_time_effort(), TimeEffortProfile,
  StepAnalysis dataclasses; two-branch regex for time ranges and single values;
  whole-word passive keyword detection; 480 min/step cap; 1825 day global cap
- Add directions to browse_recipes and _browse_by_match SELECT queries in store.py
- Enrich browse and detail endpoints with active_min/passive_min/time_effort fields
- Add StepAnalysis, TimeEffortProfile TS interfaces to api.ts
- RecipeBrowserPanel: split pill badge showing active/passive time
- RecipeDetailPanel: collapsible ingredients summary, effort cards (Active/Hands-off/Total),
  equipment chips, annotated step list with Active/Wait badges and passive hints
- 45 new tests (40 unit + 5 API); 215 total passing
2026-04-24 09:29:54 -07:00

197 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Runtime parser for active/passive time split and equipment detection.
Operates over a list of direction strings. No I/O — pure Python functions.
Sub-millisecond for up to 20 recipes (20 × ~10 steps each = 200 regex calls).
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Final
# ── Passive step keywords (whole-word, case-insensitive) ──────────────────
_PASSIVE_PATTERNS: Final[list[str]] = [
"simmer", "bake", "roast", "broil", "refrigerate", "marinate",
"chill", "cool", "freeze", "rest", "stand", "set", "soak",
"steep", "proof", "rise", "let", "wait", "overnight", "braise",
r"slow\s+cook", r"pressure\s+cook",
]
# Pre-compiled as a single alternation — avoids re-compiling on every call.
_PASSIVE_RE: re.Pattern[str] = re.compile(
r"\b(?:" + "|".join(_PASSIVE_PATTERNS) + r")\b",
re.IGNORECASE,
)
# ── Time extraction regex ─────────────────────────────────────────────────
# Two-branch pattern:
# Branch A (groups 1-3): range "15-20 minutes", "1520 min"
# Branch B (groups 4-5): single "10 minutes", "2 hours", "30 sec"
#
# Separator characters: plain hyphen (-), en-dash (), or literal "-to-"
_TIME_RE: re.Pattern[str] = re.compile(
r"(\d+)\s*(?:[-\u2013]|-to-)\s*(\d+)\s*(hour|hr|minute|min|second|sec)s?"
r"|"
r"(\d+)\s*(hour|hr|minute|min|second|sec)s?",
re.IGNORECASE,
)
_MAX_MINUTES_PER_STEP: Final[int] = 480 # 8 hours sanity cap
# ── Equipment detection (keyword → label, in detection priority order) ────
_EQUIPMENT_RULES: Final[list[tuple[re.Pattern[str], str]]] = [
(re.compile(r"\b(?:chop|dice|mince|slice|julienne)\b", re.IGNORECASE), "Knife"),
(re.compile(r"\b(?:skillet|sauté|saute|fry|sear|pan-fry|pan fry)\b", re.IGNORECASE), "Skillet"),
(re.compile(r"\b(?:wooden spoon|spatula|stir|fold)\b", re.IGNORECASE), "Spoon"),
(re.compile(r"\b(?:pot|boil|simmer|blanch|stock)\b", re.IGNORECASE), "Pot"),
(re.compile(r"\b(?:oven|bake|roast|preheat|broil)\b", re.IGNORECASE), "Oven"),
(re.compile(r"\b(?:blender|blend|purée|puree|food processor)\b", re.IGNORECASE), "Blender"),
(re.compile(r"\b(?:stand mixer|hand mixer|whip|beat)\b", re.IGNORECASE), "Mixer"),
(re.compile(r"\b(?:grill|barbecue|char|griddle)\b", re.IGNORECASE), "Grill"),
(re.compile(r"\b(?:slow cooker|crockpot|low and slow)\b", re.IGNORECASE), "Slow cooker"),
(re.compile(r"\b(?:pressure cooker|instant pot)\b", re.IGNORECASE), "Pressure cooker"),
(re.compile(r"\b(?:drain|strain|colander|rinse pasta)\b", re.IGNORECASE), "Colander"),
]
# ── Dataclasses ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class StepAnalysis:
"""Analysis result for a single direction step."""
is_passive: bool
detected_minutes: int | None # None when no time mention found in text
@dataclass(frozen=True)
class TimeEffortProfile:
"""Aggregated time and effort profile for a full recipe."""
active_min: int # total minutes requiring active attention
passive_min: int # total minutes the cook can step away
total_min: int # active_min + passive_min
step_analyses: list[StepAnalysis] # one entry per direction step
equipment: list[str] # ordered, deduplicated equipment labels
effort_label: str # "quick" | "moderate" | "involved"
# ── Core parsing logic ────────────────────────────────────────────────────
def _extract_minutes(text: str) -> int | None:
"""Return the number of minutes mentioned in text, or None.
Range values (e.g. "15-20 minutes") return the integer midpoint.
Hours are converted to minutes. Seconds are rounded up to 1 minute minimum.
Result is capped at _MAX_MINUTES_PER_STEP.
"""
m = _TIME_RE.search(text)
if m is None:
return None
if m.group(1) is not None:
# Branch A: range match (e.g. "15-20 minutes")
low = int(m.group(1))
high = int(m.group(2))
unit = m.group(3).lower()
raw_value: float = (low + high) / 2
else:
# Branch B: single value match (e.g. "10 minutes")
low = int(m.group(4))
unit = m.group(5).lower()
raw_value = float(low)
if unit in ("hour", "hr"):
minutes: float = raw_value * 60
elif unit in ("second", "sec"):
minutes = max(1.0, math.ceil(raw_value / 60))
else:
minutes = raw_value
return min(int(minutes), _MAX_MINUTES_PER_STEP)
def _classify_passive(text: str) -> bool:
"""Return True if the step text matches any passive keyword (whole-word)."""
return _PASSIVE_RE.search(text) is not None
def _detect_equipment(all_text: str, has_passive: bool) -> list[str]:
"""Return ordered, deduplicated list of equipment labels detected in text.
all_text should be all direction steps joined with spaces.
has_passive controls whether 'Timer' is appended at the end.
"""
seen: set[str] = set()
result: list[str] = []
for pattern, label in _EQUIPMENT_RULES:
if label not in seen and pattern.search(all_text):
seen.add(label)
result.append(label)
if has_passive and "Timer" not in seen:
result.append("Timer")
return result
def _effort_label(step_count: int) -> str:
"""Derive effort label from step count."""
if step_count <= 3:
return "quick"
if step_count <= 7:
return "moderate"
return "involved"
def parse_time_effort(directions: list[str]) -> TimeEffortProfile:
"""Parse a list of direction strings into a TimeEffortProfile.
Returns a zero-value profile with empty lists when directions is empty.
Never raises — all failures silently produce sensible defaults.
"""
if not directions:
return TimeEffortProfile(
active_min=0,
passive_min=0,
total_min=0,
step_analyses=[],
equipment=[],
effort_label="quick",
)
step_analyses: list[StepAnalysis] = []
active_min = 0
passive_min = 0
has_any_passive = False
for step in directions:
is_passive = _classify_passive(step)
detected = _extract_minutes(step)
if is_passive:
has_any_passive = True
if detected is not None:
passive_min += detected
else:
if detected is not None:
active_min += detected
step_analyses.append(StepAnalysis(
is_passive=is_passive,
detected_minutes=detected,
))
combined_text = " ".join(directions)
equipment = _detect_equipment(combined_text, has_any_passive)
return TimeEffortProfile(
active_min=active_min,
passive_min=passive_min,
total_min=active_min + passive_min,
step_analyses=step_analyses,
equipment=equipment,
effort_label=_effort_label(len(directions)),
)