hunch-engine 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. hunch_engine-0.2.0/.gitignore +10 -0
  2. hunch_engine-0.2.0/PKG-INFO +9 -0
  3. hunch_engine-0.2.0/pyproject.toml +18 -0
  4. hunch_engine-0.2.0/src/hunch/__init__.py +90 -0
  5. hunch_engine-0.2.0/src/hunch/client.py +128 -0
  6. hunch_engine-0.2.0/src/hunch/config.py +57 -0
  7. hunch_engine-0.2.0/src/hunch/engine.py +240 -0
  8. hunch_engine-0.2.0/src/hunch/loaders.py +80 -0
  9. hunch_engine-0.2.0/src/hunch/model.py +67 -0
  10. hunch_engine-0.2.0/src/hunch/phrasing.py +625 -0
  11. hunch_engine-0.2.0/src/hunch/questions.py +79 -0
  12. hunch_engine-0.2.0/src/hunch/resolution.py +138 -0
  13. hunch_engine-0.2.0/src/hunch/resolver.py +351 -0
  14. hunch_engine-0.2.0/src/hunch/round1.py +303 -0
  15. hunch_engine-0.2.0/src/hunch/round2.py +374 -0
  16. hunch_engine-0.2.0/src/hunch/scope.py +181 -0
  17. hunch_engine-0.2.0/src/hunch/vocabulary.py +224 -0
  18. hunch_engine-0.2.0/tests/conftest.py +91 -0
  19. hunch_engine-0.2.0/tests/test_client.py +114 -0
  20. hunch_engine-0.2.0/tests/test_conflicts_and_labels.py +300 -0
  21. hunch_engine-0.2.0/tests/test_engine.py +516 -0
  22. hunch_engine-0.2.0/tests/test_hard_scope.py +196 -0
  23. hunch_engine-0.2.0/tests/test_loaders.py +131 -0
  24. hunch_engine-0.2.0/tests/test_model.py +30 -0
  25. hunch_engine-0.2.0/tests/test_name_scoping.py +111 -0
  26. hunch_engine-0.2.0/tests/test_no_ha_imports.py +8 -0
  27. hunch_engine-0.2.0/tests/test_nomatch_rules.py +155 -0
  28. hunch_engine-0.2.0/tests/test_phrasing.py +34 -0
  29. hunch_engine-0.2.0/tests/test_questions.py +100 -0
  30. hunch_engine-0.2.0/tests/test_real_home_rules.py +392 -0
  31. hunch_engine-0.2.0/tests/test_resolver.py +222 -0
  32. hunch_engine-0.2.0/tests/test_round1.py +170 -0
  33. hunch_engine-0.2.0/tests/test_round2.py +228 -0
  34. hunch_engine-0.2.0/tests/test_scope.py +137 -0
  35. hunch_engine-0.2.0/tests/test_thresholds_2026_09_20.py +52 -0
  36. hunch_engine-0.2.0/tests/test_vocabulary.py +75 -0
@@ -0,0 +1,10 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .superpowers/
10
+ golden/homes/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: hunch-engine
3
+ Version: 0.2.0
4
+ Summary: Typed-decision fast path for Home Assistant requests, powered by Jev
5
+ Project-URL: Homepage, https://github.com/pujux/hass-hunch
6
+ Project-URL: Repository, https://github.com/pujux/hass-hunch
7
+ Project-URL: Issues, https://github.com/pujux/hass-hunch/issues
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: typesafe-sdk<0.8,>=0.7
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "hunch-engine"
3
+ version = "0.2.0"
4
+ description = "Typed-decision fast path for Home Assistant requests, powered by Jev"
5
+ requires-python = ">=3.13"
6
+ dependencies = ["typesafe-sdk>=0.7,<0.8"]
7
+
8
+ [project.urls]
9
+ Homepage = "https://github.com/pujux/hass-hunch"
10
+ Repository = "https://github.com/pujux/hass-hunch"
11
+ Issues = "https://github.com/pujux/hass-hunch/issues"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/hunch"]
@@ -0,0 +1,90 @@
1
+ """Hunch: code calculates, Jev judges."""
2
+
3
+ from hunch.client import (
4
+ DecisionBackendError,
5
+ DecisionClient,
6
+ FakeDecisionClient,
7
+ TypeSafeDecisionClient,
8
+ )
9
+ from hunch.config import EngineConfig, Thresholds
10
+ from hunch.engine import Engine
11
+ from hunch.loaders import home_from_export
12
+ from hunch.model import Area, Entity, Floor, HomeModel
13
+ from hunch.phrasing import DE, EN, PHRASEBOOKS, Phrasebook
14
+ from hunch.questions import (
15
+ JSON,
16
+ Answer,
17
+ Answers,
18
+ ChoiceA,
19
+ ChoiceQ,
20
+ NoulA,
21
+ NoulQ,
22
+ Question,
23
+ ScoreA,
24
+ ScoreQ,
25
+ )
26
+ from hunch.resolution import (
27
+ Action,
28
+ Condition,
29
+ Escalate,
30
+ NeedsClarification,
31
+ NeedsConfirmation,
32
+ Resolution,
33
+ Resolved,
34
+ Trace,
35
+ )
36
+ from hunch.vocabulary import (
37
+ DEFAULT_VOCABULARY,
38
+ ChoiceSpec,
39
+ Risk,
40
+ ScoreSpec,
41
+ Verb,
42
+ Vocabulary,
43
+ verbs_for_domain,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ "DE",
50
+ "EN",
51
+ "JSON",
52
+ "PHRASEBOOKS",
53
+ "Phrasebook",
54
+ "Action",
55
+ "Answer",
56
+ "Answers",
57
+ "Area",
58
+ "ChoiceA",
59
+ "ChoiceQ",
60
+ "ChoiceSpec",
61
+ "Condition",
62
+ "DEFAULT_VOCABULARY",
63
+ "DecisionBackendError",
64
+ "DecisionClient",
65
+ "Engine",
66
+ "EngineConfig",
67
+ "Entity",
68
+ "Escalate",
69
+ "FakeDecisionClient",
70
+ "Floor",
71
+ "HomeModel",
72
+ "NeedsClarification",
73
+ "NeedsConfirmation",
74
+ "NoulA",
75
+ "NoulQ",
76
+ "Question",
77
+ "Resolution",
78
+ "Resolved",
79
+ "Risk",
80
+ "ScoreA",
81
+ "ScoreQ",
82
+ "ScoreSpec",
83
+ "Thresholds",
84
+ "Trace",
85
+ "TypeSafeDecisionClient",
86
+ "Verb",
87
+ "Vocabulary",
88
+ "home_from_export",
89
+ "verbs_for_domain",
90
+ ]
@@ -0,0 +1,128 @@
1
+ """Backend boundary. Everything network-shaped lives here and nowhere else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any, Protocol
7
+
8
+ from typesafe_sdk import (
9
+ AsyncTypeSafeClient,
10
+ Choice,
11
+ Noul,
12
+ RetryPolicy,
13
+ Score,
14
+ TypeSafeError,
15
+ )
16
+
17
+ from hunch.questions import (
18
+ JSON,
19
+ Answer,
20
+ Answers,
21
+ ChoiceA,
22
+ ChoiceQ,
23
+ NoulA,
24
+ NoulQ,
25
+ Question,
26
+ ScoreA,
27
+ ScoreQ,
28
+ )
29
+
30
+
31
+ class DecisionBackendError(Exception):
32
+ def __init__(self, reason: str, cause: BaseException | None = None) -> None:
33
+ super().__init__(reason)
34
+ self.reason = reason
35
+ self.__cause__ = cause
36
+
37
+
38
+ class DecisionClient(Protocol):
39
+ async def ask(self, state: JSON, questions: Mapping[str, Question]) -> Answers: ...
40
+
41
+
42
+ ScriptFn = Callable[[JSON, Mapping[str, Question]], Mapping[str, Answer]]
43
+
44
+
45
+ class FakeDecisionClient:
46
+ """Scripted answers for tests.
47
+
48
+ Missing ids raise KeyError so unexpected questions fail loudly.
49
+ """
50
+
51
+ def __init__(self, script: Mapping[str, Answer] | ScriptFn, model: str = "fake") -> None:
52
+ self._script = script
53
+ self._model = model
54
+ self.calls: list[tuple[JSON, dict[str, Question]]] = []
55
+
56
+ async def ask(self, state: JSON, questions: Mapping[str, Question]) -> Answers:
57
+ self.calls.append((state, dict(questions)))
58
+ if callable(self._script):
59
+ answers = dict(self._script(state, questions))
60
+ else:
61
+ answers = {qid: self._script[qid] for qid in questions}
62
+ return Answers(model=self._model, answers=answers, input_tokens=None)
63
+
64
+
65
+ def to_sdk_question(q: Question) -> Noul | Choice | Score:
66
+ if isinstance(q, NoulQ):
67
+ return Noul(instructions=q.instructions)
68
+ if isinstance(q, ChoiceQ):
69
+ desc = q.descriptions or {}
70
+ return Choice(instructions=q.instructions, criteria={o: desc.get(o) for o in q.options})
71
+ if isinstance(q, ScoreQ):
72
+ return Score(instructions=q.instructions, criteria=list(q.levels))
73
+ raise TypeError(type(q))
74
+
75
+
76
+ def from_sdk_answer(a: Any) -> Answer:
77
+ kind = getattr(a, "type", None)
78
+ if kind == "noul":
79
+ return NoulA(float(a.noul))
80
+ if kind == "choice":
81
+ return ChoiceA(str(a.choice), float(a.confidence), dict(a.probabilities))
82
+ if kind == "score":
83
+ probabilities = {int(k): float(v) for k, v in a.probabilities.items()}
84
+ return ScoreA(float(a.score), float(a.confidence), probabilities)
85
+ raise DecisionBackendError("malformed_response")
86
+
87
+
88
+ class TypeSafeDecisionClient:
89
+ def __init__(
90
+ self,
91
+ model: str,
92
+ *,
93
+ api_key: str | None = None,
94
+ timeout_ms: int = 1500,
95
+ sdk_client: Any | None = None,
96
+ ) -> None:
97
+ self._model = model
98
+ timeout_s = timeout_ms / 1000
99
+ self._sdk = sdk_client or AsyncTypeSafeClient(
100
+ api_key=api_key,
101
+ model=model,
102
+ timeout=timeout_s,
103
+ retry=RetryPolicy(max_retries=2, timeout=timeout_s),
104
+ )
105
+
106
+ async def ask(self, state: JSON, questions: Mapping[str, Question]) -> Answers:
107
+ sdk_questions = {qid: to_sdk_question(q) for qid, q in questions.items()}
108
+ try:
109
+ resp = await self._sdk.system_one(
110
+ state=state, questions=sdk_questions, model=self._model
111
+ )
112
+ except TypeSafeError as exc:
113
+ raise DecisionBackendError("decision_backend_unavailable", exc) from exc
114
+ if resp.model != self._model:
115
+ raise DecisionBackendError("model_mismatch")
116
+ answers = {qid: from_sdk_answer(a) for qid, a in resp.answers.items()}
117
+ if set(answers) != set(questions):
118
+ # A partial or over-full answer set would surface downstream as a KeyError deep
119
+ # inside interpretation; fail here so the engine can degrade cleanly instead.
120
+ raise DecisionBackendError("malformed_response")
121
+ usage = getattr(resp, "usage", None)
122
+ input_tokens = getattr(usage, "input_tokens", None)
123
+ return Answers(model=resp.model, answers=answers, input_tokens=input_tokens)
124
+
125
+ async def aclose(self) -> None:
126
+ aclose = getattr(self._sdk, "aclose", None)
127
+ if aclose:
128
+ await aclose()
@@ -0,0 +1,57 @@
1
+ """Tunable thresholds and engine settings.
2
+
3
+ Defaults are starting points, tuned from the golden corpus.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Thresholds:
13
+ verb_fire: float = 0.7
14
+ scope_fire: float = 0.7
15
+ place_override: float = 0.9 # the area comparison narrows to ONE place only when this sure
16
+ # a Jev-only area/floor (not named verbatim) needs this to count # floor / area / domain Nouls
17
+ collective: float = 0.5
18
+ target_choice_conf: float = 0.7
19
+ auto_execute: float = 0.70 # 2026-09-20: 0.75 asked on correct sweeps; set on Julian's corpus
20
+ confirm_band: float = 0.5 # [confirm_band, auto_execute) -> NeedsConfirmation
21
+ flag: float = 0.6
22
+ specific_device: float = 0.7 # names_specific flag: plural-looking name of ONE device
23
+ collective_fallback: float = (
24
+ 0.4 # no-match target + collective >= this: all candidates, confirm
25
+ )
26
+ no_match_clarify: float = 0.6 # no-match target below this confidence: clarify, not escalate
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EngineConfig:
31
+ # Pinned Jev model id, e.g. "jev-1.13.0". A response from any other model is rejected.
32
+ model: str
33
+ # Probability/confidence gates; see Thresholds above.
34
+ thresholds: Thresholds = field(default_factory=Thresholds)
35
+ # Budget of backend round trips per request. Round 1 always costs one; a device round
36
+ # and Round 2 cost one each, so device_round requires >= 3. Overrunning it escalates
37
+ # as "round_budget" rather than guessing.
38
+ max_rounds: int = 2
39
+ # A collective action over more entities than this always asks for confirmation.
40
+ max_silent_targets: int = 20
41
+ # Largest candidate set a Round 2 Choice may be built over. Beyond it accuracy decays
42
+ # (context rot), so the request falls through to a device round, clarification or
43
+ # escalation instead.
44
+ scope_cap: int = 60
45
+ # Spend an extra round narrowing an over-cap set by device name before giving up.
46
+ device_round: bool = False
47
+ # Whether the caller can hold the turn open to ask a follow-up question. When false,
48
+ # what would have been a NeedsClarification escalates instead.
49
+ supports_clarification: bool = True
50
+ clarify_max_candidates: int = 5 # candidates offered in a NeedsClarification
51
+ # Prompts longer than this escalate unread as "prompt_invalid".
52
+ max_prompt_chars: int = 500
53
+
54
+ def __post_init__(self) -> None:
55
+ # A device round is a round: Round 1 + device round + Round 2 needs a budget of 3.
56
+ if self.device_round and self.max_rounds < 3:
57
+ raise ValueError("device_round requires max_rounds >= 3")
@@ -0,0 +1,240 @@
1
+ """Orchestrates rounds. Decides; never executes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ from hunch.client import DecisionBackendError, DecisionClient
8
+ from hunch.config import EngineConfig
9
+ from hunch.model import Entity, HomeModel
10
+ from hunch.phrasing import EN, Phrasebook
11
+ from hunch.questions import ChoiceQ, Question
12
+ from hunch.resolution import Escalate, NeedsClarification, Resolution, Trace
13
+ from hunch.resolver import resolve
14
+ from hunch.round1 import build_round1_questions, build_round1_state, interpret_round1
15
+ from hunch.round2 import (
16
+ NO_MATCH,
17
+ build_round2_questions,
18
+ build_round2_state,
19
+ device_options,
20
+ plan_round2,
21
+ )
22
+ from hunch.scope import (
23
+ Candidates,
24
+ Clarify,
25
+ DeviceRound,
26
+ ScopeEscalate,
27
+ scope_candidates,
28
+ verbatim_areas,
29
+ )
30
+ from hunch.vocabulary import Verb, Vocabulary
31
+
32
+
33
+ class Engine:
34
+ def __init__(
35
+ self,
36
+ client: DecisionClient,
37
+ vocabulary: Vocabulary,
38
+ config: EngineConfig,
39
+ phrasebook: Phrasebook = EN,
40
+ ) -> None:
41
+ self._client = client
42
+ self._vocab = vocabulary
43
+ self._config = config
44
+ self._pb = phrasebook
45
+
46
+ async def decide(self, home: HomeModel, prompt: str) -> Resolution:
47
+ """Decide what `prompt` asks of `home`. Never executes, never raises on backend trouble.
48
+
49
+ Returns one of four `Resolution` variants:
50
+
51
+ - `Resolved(actions, condition, confidence, trace)` — execute as-is.
52
+ - `NeedsConfirmation(actions, condition, reason, trace)` — ask the user first.
53
+ `reason` is `"risk:confirm"`, `"blast_radius"` or `"confidence"`.
54
+ - `NeedsClarification(question_key, candidates, trace, verb, params)` — ask which one.
55
+ `question_key` is `"which_area"` or `"which_device"`. `verb` is the verb being
56
+ clarified and `params` are the params already resolved for it in Round 2, or `{}`
57
+ when the clarification happened before Round 2.
58
+ - `Escalate(reason, partial, trace)` — hand the unchanged prompt to the fallback
59
+ agent. `reason` is drawn from a closed set:
60
+
61
+ | reason | meaning |
62
+ |---|---|
63
+ | `prompt_invalid` | empty prompt, or longer than `max_prompt_chars` |
64
+ | `timing` | the request schedules, delays or sequences something |
65
+ | `no_intent` | no verb fired |
66
+ | `destructive` | a `DESTRUCTIVE` verb fired, or `is_destructive` did |
67
+ | `low_confidence` | actions were built but confidence is below `confirm_band` |
68
+ | `scope` | every fired verb resolved to no candidates |
69
+ | `round_budget` | Round 2 was needed but `max_rounds` was already spent |
70
+ | `decision_backend_unavailable` | backend error, timeout, wrong model id, bad answers |
71
+
72
+ Every variant carries the same `Trace`. `Trace` is **mutable and shared** with the
73
+ result: it is the live object the engine wrote to, not a copy, so callers must treat
74
+ it as read-only (or snapshot it with `trace.to_dict()`). `trace.input_tokens` holds
75
+ one entry per round in round order — the backend's reported input-token count, or
76
+ `None` when that response carried no usage information.
77
+ """
78
+ trace = Trace()
79
+ prompt = prompt.strip()
80
+ if not prompt or len(prompt) > self._config.max_prompt_chars:
81
+ return Escalate("prompt_invalid", (), trace)
82
+ try:
83
+ return await self._decide(home, prompt, trace)
84
+ except (DecisionBackendError, KeyError, TypeError) as exc:
85
+ # KeyError/TypeError mean the backend answered with a missing id or the wrong
86
+ # primitive; like an explicit backend error, that degrades, it never raises.
87
+ reason = exc.reason if isinstance(exc, DecisionBackendError) else type(exc).__name__
88
+ trace.note(f"backend_error:{reason}")
89
+ return Escalate("decision_backend_unavailable", (), trace)
90
+
91
+ async def _decide(self, home: HomeModel, prompt: str, trace: Trace) -> Resolution:
92
+ th = self._config.thresholds
93
+ rounds = 0
94
+
95
+ answers = await self._client.ask(
96
+ build_round1_state(home, prompt), build_round1_questions(home, self._vocab, self._pb)
97
+ )
98
+ rounds += 1
99
+ shape = interpret_round1(home, self._vocab, answers, th, trace)
100
+
101
+ if trace.decide("flag:has_timing", shape.flag("has_timing"), th.flag):
102
+ return Escalate("timing", (), trace)
103
+ if shape.flag("has_condition") >= th.flag and trace.decide(
104
+ "flag:condition_numeric", shape.flag("condition_numeric"), th.flag
105
+ ):
106
+ # "wenn es wärmer als 23 Grad ist": a comparison against a number is not a state a
107
+ # Condition can hold, and executing unconditionally would be wrong. Jev judged the
108
+ # shape; code only refuses to pretend.
109
+ trace.note("condition:numeric")
110
+ return Escalate("condition", (), trace)
111
+ if not shape.fired_verbs:
112
+ return Escalate("no_intent", (), trace)
113
+
114
+ # Scope. Jev already compared the places in Round 1 (one room, a floor, several, the
115
+ # whole home, or none). Code adds one lookup: a room or floor whose name or alias is in
116
+ # the prompt is the scope, whatever else half-fired.
117
+ named_areas = verbatim_areas(home, prompt)
118
+ if shape.whole_home and not named_areas:
119
+ kept: tuple[str, ...] = ()
120
+ trace.note("whole_home")
121
+ elif named_areas:
122
+ kept = named_areas
123
+ dropped = tuple(a for a in shape.scope_areas if a not in named_areas)
124
+ if dropped:
125
+ trace.note("areas_dropped:named:" + ",".join(dropped))
126
+ trace.note("area_match:" + ",".join(named_areas))
127
+ else:
128
+ kept = shape.scope_areas
129
+ if kept != shape.scope_areas:
130
+ shape = dataclasses.replace(shape, scope_areas=kept)
131
+
132
+ per_verb: dict[str, tuple[Entity, ...]] = {}
133
+ widened: set[str] = set()
134
+ pending_clarify: Clarify | None = None
135
+ pending_clarify_verb: Verb | None = None
136
+ for verb in shape.fired_verbs:
137
+ if shape.scene is not None and verb.name == "activate":
138
+ per_verb[verb.name] = (shape.scene,)
139
+ continue
140
+ result = scope_candidates(home, verb, shape, self._config, trace, prompt)
141
+ if isinstance(result, Candidates):
142
+ per_verb[verb.name] = result.entities
143
+ if result.widened:
144
+ widened.add(verb.name) # Jev is asked in Round 2 whether these were meant
145
+ elif isinstance(result, Clarify):
146
+ if verb.is_query:
147
+ # "Which windows are open?" over the whole home: summarising state is the
148
+ # fallback agent's strength, and "which area?" is the wrong question.
149
+ trace.note(f"dropped:{verb.name}:query_over_cap")
150
+ continue
151
+ # Ask only if no other verb has anything to act on; a co-firing verb that had
152
+ # to widen past the cap is noise next to one that found its targets in scope.
153
+ if pending_clarify is None:
154
+ pending_clarify_verb = verb
155
+ pending_clarify = pending_clarify or result
156
+ elif isinstance(result, ScopeEscalate):
157
+ # One verb with nothing to apply to does not abort the turn; the others may
158
+ # still resolve. Only an empty result set overall escalates.
159
+ trace.note(f"dropped:{verb.name}:scope")
160
+ elif isinstance(result, DeviceRound):
161
+ if rounds >= self._config.max_rounds:
162
+ return (
163
+ NeedsClarification("which_device", result.entities, trace, verb)
164
+ if self._config.supports_clarification
165
+ else Escalate("scope", (), trace)
166
+ )
167
+ chosen = await self._device_round(home, prompt, result.entities, trace)
168
+ rounds += 1
169
+ if chosen:
170
+ per_verb[verb.name] = chosen
171
+ else:
172
+ trace.note(f"dropped:{verb.name}:scope")
173
+ if not per_verb:
174
+ if pending_clarify is not None:
175
+ return NeedsClarification(
176
+ pending_clarify.question_key,
177
+ pending_clarify.candidates,
178
+ trace,
179
+ pending_clarify_verb,
180
+ )
181
+ return Escalate("scope", (), trace)
182
+ if pending_clarify is not None:
183
+ for v in shape.fired_verbs:
184
+ if v.name not in per_verb and f"dropped:{v.name}:scope" not in trace.notes:
185
+ trace.note(f"dropped:{v.name}:scope")
186
+
187
+ plan = plan_round2(
188
+ home, shape, per_verb, th, self._config.scope_cap, prompt, frozenset(widened)
189
+ )
190
+ collective_queries = [
191
+ v.name
192
+ for v in shape.fired_verbs
193
+ if v.is_query
194
+ and v.name in per_verb
195
+ and shape.flag("collective")
196
+ >= th.collective # Jev's judgment, not the planner's bucket
197
+ ]
198
+ if collective_queries:
199
+ # "Welche Fenster sind offen?", "Wie viele Lichter sind an?": Jev says the question is
200
+ # about a set. Reading and summarising many states is the fallback agent's strength;
201
+ # a device-level answer or a "confirm reading 26 lights?" would be wrong here.
202
+ for name in collective_queries:
203
+ trace.note(f"query_collective:{name}")
204
+ return Escalate("query_collective", (), trace)
205
+ if shape.flag("has_condition") >= th.flag and not plan.condition_candidates:
206
+ # The request carried a condition but nothing in scope can express it.
207
+ trace.note("condition:unresolvable")
208
+ questions: dict[str, Question] = build_round2_questions(shape, plan, home, self._pb)
209
+ round2 = None
210
+ if questions:
211
+ if rounds >= self._config.max_rounds:
212
+ trace.note("max_rounds_reached_before_round2")
213
+ return Escalate("round_budget", (), trace)
214
+ round2 = await self._client.ask(
215
+ build_round2_state(prompt, plan, home, shape), questions
216
+ )
217
+ rounds += 1
218
+ return resolve(shape, plan, round2, self._config, trace, self._pb)
219
+
220
+ async def _device_round(
221
+ self, home: HomeModel, prompt: str, entities: tuple[Entity, ...], trace: Trace
222
+ ) -> tuple[Entity, ...]:
223
+ opts = device_options(entities, {a.area_id: a.name for a in home.areas})
224
+ q = ChoiceQ(
225
+ self._pb.device_question,
226
+ tuple(o.label for o in opts) + (NO_MATCH,),
227
+ )
228
+ answers = await self._client.ask(
229
+ {"request": prompt, "devices": [o.label for o in opts]}, {"device_round": q}
230
+ )
231
+ trace.record(2, answers)
232
+ c = answers.choice("device_round")
233
+ if c.choice == NO_MATCH:
234
+ trace.note("no_match:device_round")
235
+ return ()
236
+ th = self._config.thresholds.target_choice_conf
237
+ if not trace.decide("device_round", c.confidence, th):
238
+ return ()
239
+ opt = next((o for o in opts if o.label == c.choice), None)
240
+ return opt.entities if opt else ()
@@ -0,0 +1,80 @@
1
+ """Build a HomeModel from a registry export (see tools/export_home.py).
2
+
3
+ This is the reference for how the integration's HomeModelBuilder should derive a home:
4
+ only Assist-exposed entities, area inherited from the device when the entity has none,
5
+ user-given names preferred over integration defaults, verbs from the vocabulary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ from hunch.model import Area, Entity, Floor, HomeModel
14
+ from hunch.vocabulary import DEFAULT_VOCABULARY, Vocabulary, verbs_for_domain
15
+
16
+ SCENE_DOMAINS = frozenset({"scene", "script"})
17
+
18
+
19
+ def home_from_export(
20
+ export: Mapping[str, Any], vocabulary: Vocabulary = DEFAULT_VOCABULARY
21
+ ) -> HomeModel:
22
+ areas_raw = export.get("areas", [])
23
+ areas = tuple(
24
+ Area(
25
+ area_id=a["area_id"],
26
+ name=a["name"],
27
+ aliases=tuple(a.get("aliases") or ()),
28
+ floor_id=a.get("floor_id"),
29
+ )
30
+ for a in areas_raw
31
+ )
32
+ floors = tuple(
33
+ Floor(
34
+ floor_id=f["floor_id"],
35
+ name=f["name"],
36
+ area_ids=tuple(a.area_id for a in areas if a.floor_id == f["floor_id"]),
37
+ aliases=tuple(f.get("aliases") or ()),
38
+ )
39
+ for f in export.get("floors", [])
40
+ )
41
+ devices = {d["id"]: d for d in export.get("devices", [])}
42
+ registry = {e["entity_id"]: e for e in export.get("entities", [])}
43
+ states = export.get("states", {})
44
+ exposed = list(export.get("exposed", []))
45
+
46
+ entities: list[Entity] = []
47
+ scenes: list[Entity] = []
48
+ for entity_id in exposed:
49
+ reg = registry.get(entity_id, {})
50
+ st = states.get(entity_id, {})
51
+ device = devices.get(reg.get("device_id") or "")
52
+ domain = entity_id.split(".", 1)[0]
53
+ device_name = None
54
+ if device is not None:
55
+ device_name = device.get("name_by_user") or device.get("name")
56
+ name = (
57
+ reg.get("name")
58
+ or reg.get("original_name")
59
+ or st.get("friendly_name")
60
+ or device_name
61
+ or entity_id
62
+ )
63
+ entity = Entity(
64
+ entity_id=entity_id,
65
+ domain=domain,
66
+ name=name,
67
+ aliases=tuple(reg.get("aliases") or ()),
68
+ area_id=reg.get("area_id") or (device.get("area_id") if device else None),
69
+ device_id=reg.get("device_id"),
70
+ device_name=device_name,
71
+ verbs=(
72
+ frozenset({"activate"})
73
+ if domain in SCENE_DOMAINS
74
+ else verbs_for_domain(domain, vocabulary)
75
+ ),
76
+ state=st.get("state"),
77
+ )
78
+ (scenes if domain in SCENE_DOMAINS else entities).append(entity)
79
+
80
+ return HomeModel(floors=floors, areas=areas, entities=tuple(entities), scenes=tuple(scenes))