def parse_smart_intent(command: str) -> SmartIntent:
"""Parse a spoken/typed command into a SmartIntent (best-effort)."""
raw = (command or "").strip()
if not raw:
return SmartIntent(kind=KIND_NONE)
low = raw.lower().strip()
# A target that already IS a URL must pass through untouched. Before
# this check, an LLM-built https://www.youtube.com/results?search_query=…
# entered the YouTube block ("youtube" in low), matched none of the
# phrase regexes, and was silently replaced by the YouTube home page.
if re.match(r"^(?:https?://|www\.)\S+$", raw, re.IGNORECASE):
return SmartIntent(
kind=KIND_URL,
url=raw if raw.lower().startswith("http") else f"https://{raw}",
confidence=0.98,
reasoning="Direct URL",
)
# --- YouTube ---
if "youtube" in low:
# Spoken phrasing is full of harmless padding that used to defeat
# every pattern and fall through to the catch-all, which then
# SEARCHED the padding itself: « Ouvre-moi YouTube sur mon
# navigateur et joue-moi la chanson X » became a search for
# "sur mon navigateur et joue-moi la chanson x". Strip the padding
# first; the patterns then see the sentence the user meant.
# « depuis mon navigateur » avait échappé à la liste : la requête
# devenait littéralement « depuis mon navigateur » (23 août 2026).
yt = re.sub(
r"\b(?:dans|sur|depuis|via|avec)\s+(?:mon|ton|le|un)\s+navigateur\b",
" ",
low,
)
# « joue-moi » → « joue », mais seulement après un verbe de commande :
# un titre comme « Laisse-moi » doit garder son -moi.
yt = re.sub(
r"\b(ouvre|joue|mets|lance|écoute|ecoute|regarde|montre|cherche)"
r"[-\s]+(?:moi|nous)\b",
r"\1",
yt,
)
yt = re.sub(r"\bs[’']il\s+(?:te|vous)\s+pla[iî]t\b|\bstp\b", " ", yt)
yt = re.sub(r"\s+", " ", yt).strip()
for pat, action in (
(
r"(?:ouvre|open)\s+youtube\s+(?:et|and)\s+(?:cherche|search(?:\s+for)?)\s+(.+)",
"search",
),
(r"youtube\s+(?:cherche|search(?:\s+for)?)\s+(.+)", "search"),
(r"(?:cherche|search(?:\s+for)?)\s+(.+?)\s+(?:sur|on)\s+youtube", "search"),
(
r"(?:joue|play|regarde|watch|écoute|ecoute|mets|lance)"
r"\s+(.+?)\s+(?:sur|on)\s+youtube",
"play",
),
# Play verb AFTER the youtube mention: « ouvre youtube et joue X ».
# The sentence names youtube, so the trailing request is for it.
(
r"youtube\b.*?\b(?:joue|play|regarde|watch|écoute|ecoute|mets|lance)\s+(.+)$",
"play",
),
(r"youtube\s+(.+)", "search"),
):
m = re.search(pat, yt, re.IGNORECASE)
if m:
q = m.group(1).strip()
q = _clean_query(
q,
"for",
"the",
"le",
"la",
"les",
"des",
"un",
"une",
"de",
"d",
"vidéo",
"video",
"clip",
"chanson",
"musique",
"music",
"song",
)
# « joue de la musique sur youtube » : le sujet ENTIER est
# fait de mots vides (« de la musique ») et la requête
# nettoyée est vide — mais l'intention, elle, est limpide :
# QUE ÇA JOUE, n'importe quelle musique. L'action play_mix
# se résout en radio YouTube (watch + list=RD…) qui démarre
# et s'enchaîne ; l'url ici n'est que le repli si la
# résolution échoue.
if not q and action == "play" and re.search(r"\bmusi(?:que|c)\b", yt):
return SmartIntent(
kind=KIND_YOUTUBE,
action="play_mix",
query="musique",
url="https://music.youtube.com",
confidence=0.9,
reasoning="De la musique, sans titre : radio YouTube",
)
# « mets la vidéo en pause sur youtube » must not PLAY a
# video titled "pause" — control words are not queries.
if q and (action != "play" or q not in _CONTROL_WORDS):
return SmartIntent(
kind=KIND_YOUTUBE,
action=action,
query=q,
url=(
"https://www.youtube.com/results?search_query="
+ quote_plus(q)
),
confidence=0.92,
reasoning=f"YouTube {action}",
)
return SmartIntent(
kind=KIND_URL,
url="https://www.youtube.com",
confidence=0.9,
reasoning="YouTube home",
)
# --- Spotify ---
if (
"spotify" in low
or re.search(
r"\b(?:écoute|ecoute|listen(?:\s+to)?|joue|play|mets?|lance)\b.+"
r"\b(?:musique|music|song|chanson)\b",
low,
)
) and not re.search(
# « mets la musique en pause » est un ordre de contrôle, pas une
# envie de musique : il ne doit ni chercher « en pause » ni ouvrir
# l'application.
r"\b(?:pause|arr[êe]te|arrete|stop|coupe|[ée]teins)\b",
low,
):
# Search patterns come FIRST and each pattern carries its own
# action: sniffing play-verbs anywhere in the phrase turned
# « cherche listen de beyoncé sur spotify » into an autoplay.
for pat, action in (
(
r"(?:ouvre|open)\s+spotify\s+(?:et|and)\s+"
r"(?:cherche|search(?:\s+for)?)\s+(.+)",
"search",
),
(r"(?:cherche|search(?:\s+for)?)\s+(.+?)\s+(?:sur|on)\s+spotify", "search"),
(r"spotify\s+(?:cherche|search(?:\s+for)?)\s+(.+)", "search"),
(
r"(?:ouvre|open)\s+spotify\s+(?:et|and)\s+(?:joue|play)\s+(.+)",
"play",
),
(
r"(?:joue|play|écoute|ecoute|listen(?:\s+to)?)\s+(.+?)\s+(?:sur|on)\s+spotify",
"play",
),
(r"spotify\s+(?:joue|play)\s+(.+)", "play"),
(r"spotify\s+(.+)", "search"),
(r"(?:joue|play|écoute|ecoute|mets?|lance)\s+(.+)", "play"),
):
m = re.search(pat, low, re.IGNORECASE)
if m:
q = _clean_query(
m.group(1),
"for",
"the",
"le",
"la",
"de",
"du",
"music",
"musique",
"song",
"chanson",
)
if q and q not in {"spotify", "app"}:
return SmartIntent(
kind=KIND_SPOTIFY,
action=action,
query=q,
url=f"spotify:search:{quote(q)}",
confidence=0.9,
reasoning="Spotify search/play",
)
if "spotify" in low:
return SmartIntent(
kind=KIND_APP,
app="Spotify",
confidence=0.88,
reasoning="Open Spotify app",
)
# « Mets de la musique » tout court : pas de titre, pas de service —
# la requête nettoyée est vide mais l'envie est claire. On ouvre le
# juke-box plutôt que de répondre qu'on ne peut pas (23 août 2026,
# le chat répondait un refus à cette phrase).
return SmartIntent(
kind=KIND_APP,
app="Spotify",
confidence=0.85,
reasoning="De la musique, sans titre : on ouvre Spotify",
)
# --- Amazon ---
if "amazon" in low:
for pat in (
r"(?:cherche|search(?:\s+for)?|achète|achete|buy|shop(?:\s+for)?)\s+(.+?)\s+(?:sur|on)\s+amazon",
r"amazon\s+(?:cherche|search(?:\s+for)?)\s+(.+)",
r"amazon\s+(.+)",
):
m = re.search(pat, low, re.IGNORECASE)
if m:
q = _clean_query(m.group(1), "for", "the", "le", "la", "un", "une")
if q and q != "amazon":
return SmartIntent(
kind=KIND_AMAZON,
query=q,
url=("https://www.amazon.com/s?k=" + quote_plus(q)),
confidence=0.9,
reasoning="Amazon search",
)
return SmartIntent(
kind=KIND_URL,
url="https://www.amazon.com",
confidence=0.85,
reasoning="Amazon home",
)
# --- Netflix ---
if "netflix" in low:
m = re.search(
r"(?:cherche|search(?:\s+for)?|joue|play|regarde|watch)\s+(.+?)\s+(?:sur|on)\s+netflix|"
r"netflix\s+(.+)",
low,
re.IGNORECASE,
)
if m:
q = _clean_query((m.group(1) or m.group(2) or ""), "for", "the")
if q and q != "netflix":
return SmartIntent(
kind=KIND_NETFLIX,
query=q,
url="https://www.netflix.com/search?q=" + quote_plus(q),
confidence=0.88,
reasoning="Netflix search",
)
return SmartIntent(
kind=KIND_URL,
url="https://www.netflix.com",
confidence=0.9,
reasoning="Netflix home",
)
# --- Mail / Messages compose (drafts only; before open-Gmail) ---
mail_m = re.search(
r"(?:écris|ecris|rédige|redige|compose|write|envoie|envoyer|send)\s+"
r"(?:un\s+|an?\s+)?(?:mail|e-?mail|courriel)\s+"
r"(?:à|a|to)\s+(?P<to>\S+)"
r"(?:\s+(?:au sujet de|sujet|about|re|subject)\s+(?P<subject>.+?))?"
r"(?:\s+(?:disant|saying|pour dire|body|:)\s+(?P<body>.+))?$",
low,
re.IGNORECASE,
)
if not mail_m:
mail_m = re.search(
r"(?:email|e-mail)\s+(?P<to>\S+)\s+(?:about|re|sujet)\s+(?P<subject>.+)$",
low,
re.IGNORECASE,
)
if mail_m:
to = (mail_m.groupdict().get("to") or "").strip(" .,!?;:")
subject = (mail_m.groupdict().get("subject") or "").strip(" .,!?;:")
body = (mail_m.groupdict().get("body") or "").strip()
if to:
return SmartIntent(
kind=KIND_MAIL_COMPOSE,
to=to,
subject=subject,
body=body or subject,
confidence=0.9,
reasoning="Mail compose draft",
)
msg_m = re.search(
r"(?:envoie|envoyer|send)\s+(?:un\s+|an?\s+)?(?:message|sms|imessage|texto)\s+"
r"(?:à|a|to)\s+(?P<to>.+?)"
r"(?:\s+(?:disant|saying|pour dire|:)\s+(?P<body>.+))?$",
low,
re.IGNORECASE,
)
if not msg_m:
msg_m = re.search(
r"(?:message|text|texte|imessage)\s+(?:à|a|to\s+)?(?P<to>\S+)"
r"(?:\s+(?:disant|saying|pour dire|:)\s+(?P<body>.+))?$",
low,
re.IGNORECASE,
)
if msg_m:
to = (msg_m.group("to") or "").strip(" .,!?;:")
body = (msg_m.groupdict().get("body") or "").strip()
# Don't steal bare "messages" / "message" app opens
if to and to not in {"messages", "message", "sms"}:
return SmartIntent(
kind=KIND_MESSAGES_COMPOSE,
to=to,
body=body,
confidence=0.88,
reasoning="Messages compose draft",
)
# --- Gmail / email web ---
# « gmail » doit être DIT pour aller au web. La forme française « ouvre
# (mes) mails » déclenchait cette règle et envoyait mail.google.com dans
# un navigateur alors que Mail.app est installée — pour l'utilisateur,
# « Mail ne s'ouvre pas ». L'application installée gagne ; le site n'est
# que le repli de qui le nomme.
if "gmail" in low or re.search(
r"\b(?:check(?:\s+my)?\s+email|check\s+mail)\b",
low,
):
if "apple mail" not in low:
return SmartIntent(
kind=KIND_GMAIL,
url="https://mail.google.com",
confidence=0.9,
reasoning="Gmail",
)
# --- WhatsApp Web ---
if "whatsapp" in low and any(
w in low for w in ("web", "chrome", "browser", "navigateur")
):
return SmartIntent(
kind=KIND_URL,
url="https://web.whatsapp.com",
confidence=0.95,
reasoning="WhatsApp Web",
)
# --- Social homes ---
for name, url in (
("facebook", "https://www.facebook.com"),
("instagram", "https://www.instagram.com"),
("linkedin", "https://www.linkedin.com"),
("reddit", "https://www.reddit.com"),
("twitter", "https://x.com"),
):
if re.search(
rf"\b(?:ouvre|open|go to|va sur|check|vérifie)\s+{name}\b|\b{name}\b$",
low,
):
# Don't steal "open twitter and search …" if we add later
if "search" in low or "cherche" in low:
continue
return SmartIntent(
kind=KIND_URL, url=url, confidence=0.9, reasoning=f"{name} home"
)
# --- Explicit website map / open X.com ---
m = re.search(
r"(?:ouvre|open|go to|va sur)\s+"
r"(?:le site |the (?:site|page) )?"
r"(?P<site>[\w.-]+\.(?:com|org|net|io|ai|co|fr|dev))\b",
low,
)
if m:
host = m.group("site")
return SmartIntent(
kind=KIND_URL,
url=f"https://{host}",
confidence=0.95,
reasoning="Navigate domain",
)
# --- Open native app / known website alias ---
m = re.search(
r"^\s*(?:please\s+)?(?:ouvre|ouvrir|open|lance|lancer|launch|start|démarre|"
r"show|montre)\s+"
r"(?:l['’]|le |la |les |the |app |application )?"
r"(?P<target>.+?)\s*$",
low,
re.IGNORECASE,
)
if m:
target = m.group("target").strip().strip(".!?")
# "youtube and search for cats" already handled above if youtube in string
target = re.sub(r"^(?:de\s+|d['’]\s*|du\s+)", "", target)
if target in _NATIVE_APPS:
return SmartIntent(
kind=KIND_APP,
app=_NATIVE_APPS[target],
confidence=0.95,
reasoning="Native app",
)
# L'application INSTALLÉE gagne sur le raccourci web : qui a
# l'application ChatGPT ne veut pas chatgpt.com dans un onglet.
# L'index couvre tout le disque — chaque application du Mac devient
# ouvrable à la voix sans figurer dans une table écrite à la main.
from diapason.desktop.app_index import APP_INDEX
installee = APP_INDEX.lookup(target)
if installee is not None:
return SmartIntent(
kind=KIND_APP,
app=installee,
confidence=0.94,
reasoning="Installed app",
)
if target in _WEBSITES:
return SmartIntent(
kind=KIND_URL,
url=_WEBSITES[target],
confidence=0.92,
reasoning="Website shortcut",
)
# "spotify app"
if target.endswith(" app") and target[:-4] in _NATIVE_APPS:
return SmartIntent(
kind=KIND_APP,
app=_NATIVE_APPS[target[:-4]],
confidence=0.93,
reasoning="Native app suffix",
)
# Bare website alias
if low in _WEBSITES:
return SmartIntent(
kind=KIND_URL, url=_WEBSITES[low], confidence=0.9, reasoning="Bare site"
)
if low in _NATIVE_APPS:
return SmartIntent(
kind=KIND_APP, app=_NATIVE_APPS[low], confidence=0.9, reasoning="Bare app"
)
return SmartIntent(kind=KIND_NONE, reasoning="No smart match")