Repository: home-assistant/hassil

Issue:
Intent Parsing Ignoring Required Space
I have an automation for assist that looks like this (simplified for example):

```
triggers:
  - trigger: conversation
    command:
      - play music by {artist_name} [(in|on) [the ]{area_name}]
actions:
  - set_conversation_response: >-
      Playing music by {{ trigger.slots.artist_name }} in the {{ trigger.slots.area_name }}
```

When I give the command "Play music by Jason Mraz" the intent processing is splitting in the middle of the word "Jason" such that it tries to play music by "Jas" ON the area "Mraz." Similarly, "Play music by Justin Timberlake in the Office" returns "Playing music by Just in the Timberlake in the Office"

The returned (simplified) trigger data looks like:
```
trigger:
  platform: conversation
  sentence: Play music by Jason Mraz
  details:
    artist_name:
      name: artist_name
      text: Jas
      value: Jas
    area_name:
      name: area_name
      text: Mraz
      value: Mraz
  slots:
    artist_name: Jas
    area_name: Mraz
  user_input:
    text: Play music by Jason Mraz
    language: en
    agent_id: conversation.home_assistant
```

I cannot find any way to structure the sentence such that it only matches "on" or "in" when preceded by a space and not when "on" or "in" is found at the end of part of the artist's name.

Hints from issue comments:
@tetele: @synesthesiam i don'r have the rights to transfer this issue to hassil@synesthesiam: Transferred 👍 

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f63d9aa..8da7e82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 2.2.1
+
+- Allow list values with "in" but no "out"
+
 ## 2.2.0
 
 - Add "fractions" to number ranges with halves and tenths
diff --git a/hassil/intents.py b/hassil/intents.py
index bdbf96e..d8a76e5 100644
--- a/hassil/intents.py
+++ b/hassil/intents.py
@@ -419,11 +419,15 @@ def _parse_list(
         # Text values
         text_values: List[TextSlotValue] = []
         for value in list_dict["values"]:
+            if isinstance(value, str) and allow_template and is_template(value):
+                # Wrap template
+                value = {"in": value}
+
             if isinstance(value, str):
                 # String value
                 text_values.append(
                     TextSlotValue(
-                        text_in=_maybe_parse_template(value, allow_template),
+                        text_in=_maybe_parse_template(value, allow_template=False),
                         value_out=value,
                     )
                 )
@@ -432,7 +436,7 @@ def _parse_list(
                 text_values.append(
                     TextSlotValue(
                         text_in=_maybe_parse_template(value["in"], allow_template),
-                        value_out=value["out"],
+                        value_out=value.get("out"),
                         context=value.get("context"),
                         metadata=value.get("metadata"),
                     )
diff --git a/hassil/string_matcher.py b/hassil/string_matcher.py
index 78cde13..9511ba3 100644
--- a/hassil/string_matcher.py
+++ b/hassil/string_matcher.py
@@ -512,15 +512,21 @@ def match_expression(
                         else:
                             remaining_text = context.text
 
+                        entity_text = (
+                            remaining_text[: -len(value_context.text)]
+                            if value_context.text
+                            else remaining_text
+                        )
+
                         entities = value_context.entities + [
                             MatchEntity(
                                 name=list_ref.slot_name,
-                                value=slot_value.value_out,
-                                text=(
-                                    remaining_text[: -len(value_context.text)]
-                                    if value_context.text
-                                    else remaining_text
+                                value=(
+                                    entity_text
+                                    if slot_value.value_out is None
+                                    else slot_value.value_out
                                 ),
+                                text=entity_text,
                                 metadata=slot_value.metadata,
                             )
                         ]
diff --git a/hassil/util.py b/hassil/util.py
index 9a753f5..743d9a6 100644
--- a/hassil/util.py
+++ b/hassil/util.py
@@ -208,7 +208,8 @@ def match_first(text: str, prefix: str, start_idx: int = 0) -> int:
     if start_idx > 0:
         text = text[start_idx:]
 
-    match = re.search(rf"{re.escape(prefix)}", text, re.IGNORECASE)
+    # Use word boundary anchor to ensure the prefix only matches the start of a word
+    match = re.search(rf"\b{re.escape(prefix)}", text, re.IGNORECASE)
     if match is None:
         return -1
 
diff --git a/pyproject.toml b/pyproject.toml
index 1e19f94..9701e4b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name        = "hassil"
-version     = "2.2.0"
+version     = "2.2.1"
 license     = {text = "Apache-2.0"}
 description = "The Home Assistant Intent Language parser"
 readme      = "README.md"


Test patch:
diff --git a/tests/test_recognize.py b/tests/test_recognize.py
index ce4dc71..34b37fd 100644
--- a/tests/test_recognize.py
+++ b/tests/test_recognize.py
@@ -1076,6 +1076,16 @@ def test_wildcard() -> None:
         assert result.entities["album"].is_wildcard
         assert result.entities["artist"].is_wildcard
 
+    # Test use of next word at end of word in wildcard
+    sentence = "play Moby by Moby please now"
+    result = recognize(sentence, intents)
+    assert result is not None, f"{sentence} should match"
+    assert set(result.entities.keys()) == {"album", "artist"}
+    assert result.entities["album"].value == "Moby"
+    assert result.entities["album"].is_wildcard
+    assert result.entities["artist"].value == "Moby"
+    assert result.entities["artist"].is_wildcard
+
     # Add "artist" word
     sentence = "begin day by day by artist taken by trees"
     result = recognize(sentence, intents)
@@ -1934,3 +1944,42 @@ def test_range_lists_separated_by_punctuation_with_wildcard() -> None:
     value2 = result.entities.get("value2")
     assert value2 is not None
     assert value2.value == 2
+
+
+def test_list_value_in_no_out() -> None:
+    """Test list values with "in" but no "out"."""
+    yaml_text = """
+    language: "en"
+    intents:
+      TestIntent:
+        data:
+          - sentences:
+              - "test {value1}"
+          - sentences:
+              - "also test {value2}"
+            lists:
+              value2:
+                values:
+                  - g[h]i
+                  - j[k]l
+    lists:
+      value1:
+        values:
+          - a[b]c
+          - in: d[e]f
+    """
+
+    with io.StringIO(yaml_text) as test_file:
+        intents = Intents.from_yaml(test_file)
+
+    for value in ("abc", "ac", "def", "df"):
+        result = recognize(f"test {value}", intents)
+        assert result is not None, value
+        assert "value1" in result.entities
+        assert result.entities["value1"].value == value
+
+    for value in ("ghi", "gi", "jkl", "jl"):
+        result = recognize(f"also test {value}", intents)
+        assert result is not None, value
+        assert "value2" in result.entities
+        assert result.entities["value2"].value == value