hunch-engine 0.2.0__py3-none-any.whl
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.
- hunch/__init__.py +90 -0
- hunch/client.py +128 -0
- hunch/config.py +57 -0
- hunch/engine.py +240 -0
- hunch/loaders.py +80 -0
- hunch/model.py +67 -0
- hunch/phrasing.py +625 -0
- hunch/questions.py +79 -0
- hunch/resolution.py +138 -0
- hunch/resolver.py +351 -0
- hunch/round1.py +303 -0
- hunch/round2.py +374 -0
- hunch/scope.py +181 -0
- hunch/vocabulary.py +224 -0
- hunch_engine-0.2.0.dist-info/METADATA +9 -0
- hunch_engine-0.2.0.dist-info/RECORD +17 -0
- hunch_engine-0.2.0.dist-info/WHEEL +4 -0
hunch/__init__.py
ADDED
|
@@ -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
|
+
]
|
hunch/client.py
ADDED
|
@@ -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()
|
hunch/config.py
ADDED
|
@@ -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")
|
hunch/engine.py
ADDED
|
@@ -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 ()
|
hunch/loaders.py
ADDED
|
@@ -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))
|
hunch/model.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Immutable per-request snapshot of a home. Built by the integration; hand-built in tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Floor:
|
|
11
|
+
floor_id: str
|
|
12
|
+
name: str
|
|
13
|
+
area_ids: tuple[str, ...]
|
|
14
|
+
aliases: tuple[str, ...] = ()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Area:
|
|
19
|
+
area_id: str
|
|
20
|
+
name: str
|
|
21
|
+
aliases: tuple[str, ...]
|
|
22
|
+
floor_id: str | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Entity:
|
|
27
|
+
entity_id: str
|
|
28
|
+
domain: str
|
|
29
|
+
name: str
|
|
30
|
+
aliases: tuple[str, ...]
|
|
31
|
+
area_id: str | None
|
|
32
|
+
device_id: str | None
|
|
33
|
+
device_name: str | None
|
|
34
|
+
verbs: frozenset[str]
|
|
35
|
+
state: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class HomeModel:
|
|
40
|
+
floors: tuple[Floor, ...]
|
|
41
|
+
areas: tuple[Area, ...]
|
|
42
|
+
entities: tuple[Entity, ...]
|
|
43
|
+
scenes: tuple[Entity, ...]
|
|
44
|
+
|
|
45
|
+
def area_by_id(self, area_id: str) -> Area | None:
|
|
46
|
+
return self._areas.get(area_id)
|
|
47
|
+
|
|
48
|
+
def areas_for_floor(self, floor_id: str) -> tuple[str, ...]:
|
|
49
|
+
for floor in self.floors:
|
|
50
|
+
if floor.floor_id == floor_id:
|
|
51
|
+
return floor.area_ids
|
|
52
|
+
return ()
|
|
53
|
+
|
|
54
|
+
def entity_by_id(self, entity_id: str) -> Entity | None:
|
|
55
|
+
return self._entities.get(entity_id)
|
|
56
|
+
|
|
57
|
+
@cached_property
|
|
58
|
+
def domains(self) -> tuple[str, ...]:
|
|
59
|
+
return tuple(sorted({e.domain for e in self.entities}))
|
|
60
|
+
|
|
61
|
+
@cached_property
|
|
62
|
+
def _areas(self) -> dict[str, Area]:
|
|
63
|
+
return {a.area_id: a for a in self.areas}
|
|
64
|
+
|
|
65
|
+
@cached_property
|
|
66
|
+
def _entities(self) -> dict[str, Entity]:
|
|
67
|
+
return {e.entity_id: e for e in (*self.entities, *self.scenes)}
|