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/questions.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Backend-neutral question and answer types. The client maps these to SDK types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
JSON = dict[str, Any]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class NoulQ:
|
|
14
|
+
instructions: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ChoiceQ:
|
|
19
|
+
instructions: str
|
|
20
|
+
options: tuple[str, ...]
|
|
21
|
+
descriptions: Mapping[str, str] | None = (
|
|
22
|
+
None # option -> what it means; sent as Choice criteria
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ScoreQ:
|
|
28
|
+
instructions: str
|
|
29
|
+
levels: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Question = NoulQ | ChoiceQ | ScoreQ
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class NoulA:
|
|
37
|
+
probability: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ChoiceA:
|
|
42
|
+
choice: str
|
|
43
|
+
confidence: float
|
|
44
|
+
probabilities: Mapping[str, float]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ScoreA:
|
|
49
|
+
score: float
|
|
50
|
+
confidence: float
|
|
51
|
+
probabilities: Mapping[int, float]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
Answer = NoulA | ChoiceA | ScoreA
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Answers:
|
|
59
|
+
model: str
|
|
60
|
+
answers: Mapping[str, Answer]
|
|
61
|
+
input_tokens: int | None
|
|
62
|
+
|
|
63
|
+
def noul(self, question_id: str) -> float:
|
|
64
|
+
a = self.answers[question_id]
|
|
65
|
+
if not isinstance(a, NoulA):
|
|
66
|
+
raise TypeError(f"{question_id} is {type(a).__name__}, expected NoulA")
|
|
67
|
+
return a.probability
|
|
68
|
+
|
|
69
|
+
def choice(self, question_id: str) -> ChoiceA:
|
|
70
|
+
a = self.answers[question_id]
|
|
71
|
+
if not isinstance(a, ChoiceA):
|
|
72
|
+
raise TypeError(f"{question_id} is {type(a).__name__}, expected ChoiceA")
|
|
73
|
+
return a
|
|
74
|
+
|
|
75
|
+
def score(self, question_id: str) -> ScoreA:
|
|
76
|
+
a = self.answers[question_id]
|
|
77
|
+
if not isinstance(a, ScoreA):
|
|
78
|
+
raise TypeError(f"{question_id} is {type(a).__name__}, expected ScoreA")
|
|
79
|
+
return a
|
hunch/resolution.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""What the engine returns. No free text anywhere; every variant carries the full trace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
|
|
8
|
+
from hunch.model import Entity
|
|
9
|
+
from hunch.questions import JSON, Answer, Answers, ChoiceA, NoulA, ScoreA
|
|
10
|
+
from hunch.vocabulary import Verb
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TraceEntry:
|
|
15
|
+
round: int
|
|
16
|
+
question_id: str
|
|
17
|
+
answer: Answer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ThresholdDecision:
|
|
22
|
+
name: str
|
|
23
|
+
value: float
|
|
24
|
+
threshold: float
|
|
25
|
+
passed: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Trace:
|
|
30
|
+
"""Mutable builder; the only non-frozen type in the package.
|
|
31
|
+
|
|
32
|
+
Frozen when embedded via to_dict.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
entries: list[TraceEntry] = field(default_factory=list)
|
|
36
|
+
decisions: list[ThresholdDecision] = field(default_factory=list)
|
|
37
|
+
models: list[str] = field(default_factory=list)
|
|
38
|
+
notes: list[str] = field(default_factory=list)
|
|
39
|
+
input_tokens: list[int | None] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def record(self, round: int, answers: Answers) -> None:
|
|
42
|
+
self.models.append(answers.model)
|
|
43
|
+
self.input_tokens.append(answers.input_tokens)
|
|
44
|
+
for qid, ans in answers.answers.items():
|
|
45
|
+
self.entries.append(TraceEntry(round, qid, ans))
|
|
46
|
+
|
|
47
|
+
def decide(self, name: str, value: float, threshold: float) -> bool:
|
|
48
|
+
passed = value >= threshold
|
|
49
|
+
self.decisions.append(ThresholdDecision(name, value, threshold, passed))
|
|
50
|
+
return passed
|
|
51
|
+
|
|
52
|
+
def note(self, text: str) -> None:
|
|
53
|
+
self.notes.append(text)
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> JSON:
|
|
56
|
+
return {
|
|
57
|
+
"models": list(self.models),
|
|
58
|
+
"entries": [
|
|
59
|
+
{"round": e.round, "question_id": e.question_id, "answer": _answer_dict(e.answer)}
|
|
60
|
+
for e in self.entries
|
|
61
|
+
],
|
|
62
|
+
"decisions": [asdict(d) for d in self.decisions],
|
|
63
|
+
"notes": list(self.notes),
|
|
64
|
+
"input_tokens": list(self.input_tokens),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _answer_dict(a: Answer) -> JSON:
|
|
69
|
+
if isinstance(a, NoulA):
|
|
70
|
+
return {"type": "noul", "probability": a.probability}
|
|
71
|
+
if isinstance(a, ChoiceA):
|
|
72
|
+
return {
|
|
73
|
+
"type": "choice",
|
|
74
|
+
"choice": a.choice,
|
|
75
|
+
"confidence": a.confidence,
|
|
76
|
+
"probabilities": dict(a.probabilities),
|
|
77
|
+
}
|
|
78
|
+
if isinstance(a, ScoreA):
|
|
79
|
+
return {
|
|
80
|
+
"type": "score",
|
|
81
|
+
"score": a.score,
|
|
82
|
+
"confidence": a.confidence,
|
|
83
|
+
"probabilities": {str(k): v for k, v in a.probabilities.items()},
|
|
84
|
+
}
|
|
85
|
+
raise TypeError(type(a))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Action:
|
|
90
|
+
verb: Verb
|
|
91
|
+
targets: tuple[Entity, ...]
|
|
92
|
+
params: Mapping[str, float | str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Condition:
|
|
97
|
+
subject: Entity
|
|
98
|
+
expected_state: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Resolved:
|
|
103
|
+
actions: tuple[Action, ...]
|
|
104
|
+
condition: Condition | None
|
|
105
|
+
confidence: float
|
|
106
|
+
trace: Trace
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class NeedsConfirmation:
|
|
111
|
+
actions: tuple[Action, ...]
|
|
112
|
+
condition: Condition | None
|
|
113
|
+
reason: str
|
|
114
|
+
trace: Trace
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class NeedsClarification:
|
|
119
|
+
question_key: str # "which_area" | "which_device"
|
|
120
|
+
candidates: tuple[Entity, ...]
|
|
121
|
+
trace: Trace
|
|
122
|
+
# the verb being clarified and the params already resolved for it ({} when the
|
|
123
|
+
# clarification happened before Round 2). Lets a caller act on the user's pick.
|
|
124
|
+
verb: Verb | None = None
|
|
125
|
+
params: Mapping[str, float | str] = field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Escalate:
|
|
130
|
+
# reason: "timing" | "no_intent" | "destructive" | "low_confidence" | "scope"
|
|
131
|
+
# | "round_budget" | "condition" | "exception" | "relative_change" | "query_collective"
|
|
132
|
+
# | "decision_backend_unavailable" | "prompt_invalid"
|
|
133
|
+
reason: str
|
|
134
|
+
partial: tuple[Action, ...]
|
|
135
|
+
trace: Trace
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
Resolution = Resolved | NeedsConfirmation | NeedsClarification | Escalate
|
hunch/resolver.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Turn probabilities into a Resolution. Confidence is the minimum of everything we relied on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from hunch.config import EngineConfig
|
|
8
|
+
from hunch.model import Entity
|
|
9
|
+
from hunch.phrasing import EN, Phrasebook
|
|
10
|
+
from hunch.questions import Answers
|
|
11
|
+
from hunch.resolution import (
|
|
12
|
+
Action,
|
|
13
|
+
Condition,
|
|
14
|
+
Escalate,
|
|
15
|
+
NeedsClarification,
|
|
16
|
+
NeedsConfirmation,
|
|
17
|
+
Resolution,
|
|
18
|
+
Resolved,
|
|
19
|
+
Trace,
|
|
20
|
+
)
|
|
21
|
+
from hunch.round1 import Shape
|
|
22
|
+
from hunch.round2 import NO_MATCH, Round2Plan, parse_number
|
|
23
|
+
from hunch.vocabulary import ChoiceSpec, Risk, ScoreSpec, Verb
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def score_to_value(spec: ScoreSpec, score: float) -> float:
|
|
27
|
+
idx = score # Jev scores are 0-based: a level's position in the criteria list
|
|
28
|
+
idx = max(0.0, min(float(len(spec.values) - 1), idx)) # saturate both ends
|
|
29
|
+
lo = max(0, min(len(spec.values) - 1, int(idx)))
|
|
30
|
+
hi = min(len(spec.values) - 1, lo + 1)
|
|
31
|
+
frac = idx - lo
|
|
32
|
+
return spec.values[lo] + (spec.values[hi] - spec.values[lo]) * frac
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scope_strength(shape: Shape, trace: Trace) -> float:
|
|
36
|
+
"""The strongest Round 1 signal that put areas into scope: an area or a floor probability."""
|
|
37
|
+
best = max((shape.area_probs.get(a, 0.0) for a in shape.scope_areas), default=0.0)
|
|
38
|
+
for d in trace.decisions:
|
|
39
|
+
if d.name.startswith("floor:") and d.passed:
|
|
40
|
+
best = max(best, d.value)
|
|
41
|
+
return best
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_BOUNDS = {"percent": (0.0, 100.0), "fraction": (0.0, 100.0), "degrees": (5.0, 35.0)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _numeric_value(spec: ScoreSpec, number: float, *, inverted: bool) -> float | None:
|
|
48
|
+
"""Code calculates: apply the mode Jev chose and the plausibility bounds of the unit."""
|
|
49
|
+
lo, hi = _BOUNDS.get(spec.kind, (float("-inf"), float("inf")))
|
|
50
|
+
if not lo <= number <= hi:
|
|
51
|
+
return None
|
|
52
|
+
if spec.kind == "fraction":
|
|
53
|
+
number = number / 100.0
|
|
54
|
+
if inverted and spec.kind == "percent":
|
|
55
|
+
number = 100.0 - number
|
|
56
|
+
return number
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _base_label(label: str) -> str:
|
|
60
|
+
"""'Dachterrasse Rollo (Galerie)' -> 'Dachterrasse Rollo'; '#2' suffixes likewise."""
|
|
61
|
+
return re.sub(r" (?:\([^)]*\)|#\d+)$", "", label)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _all_entities(options) -> tuple[Entity, ...]:
|
|
65
|
+
"""Every entity behind a set of options, once — a device option and its per-entity options
|
|
66
|
+
describe the same lamps."""
|
|
67
|
+
seen: dict[str, Entity] = {}
|
|
68
|
+
for o in options:
|
|
69
|
+
for e in o.entities:
|
|
70
|
+
seen.setdefault(e.entity_id, e)
|
|
71
|
+
return tuple(seen.values())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _verb_prob(trace: Trace, verb_name: str) -> float:
|
|
75
|
+
for d in reversed(trace.decisions):
|
|
76
|
+
if d.name == f"verb:{verb_name}":
|
|
77
|
+
return d.value
|
|
78
|
+
# Invariant: Round 1 asks a Noul for every verb in the vocabulary and records the
|
|
79
|
+
# decision, so a fired verb always has one. If it somehow does not, fail closed —
|
|
80
|
+
# an unverified verb must never be treated as certain.
|
|
81
|
+
return 0.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve(
|
|
85
|
+
shape: Shape,
|
|
86
|
+
plan: Round2Plan,
|
|
87
|
+
round2: Answers | None,
|
|
88
|
+
config: EngineConfig,
|
|
89
|
+
trace: Trace,
|
|
90
|
+
pb: Phrasebook = EN,
|
|
91
|
+
) -> Resolution:
|
|
92
|
+
th = config.thresholds
|
|
93
|
+
if round2 is not None:
|
|
94
|
+
trace.record(2, round2)
|
|
95
|
+
|
|
96
|
+
if trace.decide("flag:is_destructive", shape.flag("is_destructive"), th.flag) or any(
|
|
97
|
+
v.risk is Risk.DESTRUCTIVE for v in shape.fired_verbs
|
|
98
|
+
):
|
|
99
|
+
return Escalate("destructive", (), trace)
|
|
100
|
+
|
|
101
|
+
actions: list[Action] = []
|
|
102
|
+
contributions: list[float] = []
|
|
103
|
+
reasons: list[str] = []
|
|
104
|
+
pending_clarify: tuple[Entity, ...] | None = None # only used if no verb yields an action
|
|
105
|
+
pending_clarify_verb: Verb | None = None
|
|
106
|
+
pending_clarify_params: dict[str, float | str] = {}
|
|
107
|
+
exception_unresolved = False
|
|
108
|
+
relative_change = False
|
|
109
|
+
|
|
110
|
+
for verb in shape.fired_verbs:
|
|
111
|
+
# Per-verb contributions stay local until the verb actually yields targets: a verb
|
|
112
|
+
# that resolves to nothing must not drag down the confidence of the ones that did.
|
|
113
|
+
local: list[float] = [_verb_prob(trace, verb.name)]
|
|
114
|
+
targets: tuple[Entity, ...] = ()
|
|
115
|
+
|
|
116
|
+
if verb.name in plan.outside_scope and round2 is not None:
|
|
117
|
+
p_out = round2.noul(f"outside_scope:{verb.name}")
|
|
118
|
+
if not trace.decide(f"outside_scope:{verb.name}", p_out, th.flag):
|
|
119
|
+
trace.note(f"dropped:{verb.name}:outside_scope")
|
|
120
|
+
continue
|
|
121
|
+
local.append(p_out)
|
|
122
|
+
|
|
123
|
+
if verb.name in plan.collective:
|
|
124
|
+
targets = plan.collective[verb.name]
|
|
125
|
+
if verb.name in plan.include and round2 is not None:
|
|
126
|
+
kept_inc: list[Entity] = []
|
|
127
|
+
for e in targets:
|
|
128
|
+
p_in = round2.noul(f"include:{verb.name}:{e.entity_id}")
|
|
129
|
+
if trace.decide(f"include:{verb.name}:{e.entity_id}", p_in, 0.5):
|
|
130
|
+
kept_inc.append(e)
|
|
131
|
+
local.append(p_in)
|
|
132
|
+
else:
|
|
133
|
+
trace.note(f"not_meant:{verb.name}:{e.entity_id}")
|
|
134
|
+
targets = tuple(kept_inc)
|
|
135
|
+
if len(targets) > 1: # a single candidate never relied on the collective flag
|
|
136
|
+
collective = shape.flag("collective")
|
|
137
|
+
in_scope = shape.scope_areas and all(
|
|
138
|
+
e.area_id in shape.scope_areas for e in targets
|
|
139
|
+
)
|
|
140
|
+
if in_scope:
|
|
141
|
+
# The room or floor was said out loud (or a floor fired) and everything we
|
|
142
|
+
# are about to touch is inside it: two signals agree on "all of them".
|
|
143
|
+
named = any(n.startswith("area_match:") for n in trace.notes)
|
|
144
|
+
backing = 1.0 if named else _scope_strength(shape, trace)
|
|
145
|
+
if backing > collective:
|
|
146
|
+
trace.note(f"collective_backed_by_scope:{verb.name}")
|
|
147
|
+
collective = backing
|
|
148
|
+
local.append(collective)
|
|
149
|
+
elif verb.name in plan.exclude and round2 is not None:
|
|
150
|
+
kept: list[Entity] = []
|
|
151
|
+
# The set was justified by the plural OR by the exception ("Licht aus außer …" has
|
|
152
|
+
# no plural); count whichever signal carried it, plus the exception itself.
|
|
153
|
+
local.append(max(shape.flag("collective"), shape.flag("has_exception")))
|
|
154
|
+
local.append(shape.flag("has_exception"))
|
|
155
|
+
for e in plan.exclude[verb.name]:
|
|
156
|
+
p = round2.noul(f"exclude:{verb.name}:{e.entity_id}")
|
|
157
|
+
excluded = trace.decide(f"exclude:{verb.name}:{e.entity_id}", p, 0.5)
|
|
158
|
+
local.append(p if excluded else 1.0 - p)
|
|
159
|
+
if not excluded:
|
|
160
|
+
kept.append(e)
|
|
161
|
+
targets = tuple(kept)
|
|
162
|
+
if len(kept) == len(plan.exclude[verb.name]):
|
|
163
|
+
# 'except X' named something we could not find: never sweep past it silently.
|
|
164
|
+
trace.note(f"exception_unresolved:{verb.name}")
|
|
165
|
+
exception_unresolved = True
|
|
166
|
+
elif verb.name in plan.singular and round2 is not None:
|
|
167
|
+
options = plan.singular[verb.name]
|
|
168
|
+
c = round2.choice(f"target:{verb.name}")
|
|
169
|
+
trace.decide(f"target:{verb.name}", c.confidence, th.target_choice_conf)
|
|
170
|
+
ranked = sorted(
|
|
171
|
+
(o for o in options if o.label != NO_MATCH),
|
|
172
|
+
key=lambda o: -c.probabilities.get(o.label, 0.0),
|
|
173
|
+
)
|
|
174
|
+
p_all = (
|
|
175
|
+
round2.noul(f"all_of:{verb.name}")
|
|
176
|
+
if f"all_of:{verb.name}" in round2.answers
|
|
177
|
+
else 0.0
|
|
178
|
+
)
|
|
179
|
+
if trace.decide(f"all_of:{verb.name}", p_all, th.collective):
|
|
180
|
+
# Jev saw the candidates and says the request means every one of them.
|
|
181
|
+
trace.note(f"all_of:{verb.name}")
|
|
182
|
+
targets = _all_entities(options)
|
|
183
|
+
backing = p_all
|
|
184
|
+
if shape.scope_areas and all(e.area_id in shape.scope_areas for e in targets):
|
|
185
|
+
named = any(n.startswith("area_match:") for n in trace.notes)
|
|
186
|
+
backing = max(p_all, 1.0 if named else _scope_strength(shape, trace))
|
|
187
|
+
if backing > p_all:
|
|
188
|
+
trace.note(f"collective_backed_by_scope:{verb.name}")
|
|
189
|
+
local.append(backing)
|
|
190
|
+
elif c.choice == NO_MATCH:
|
|
191
|
+
trace.note(f"no_match:target:{verb.name}")
|
|
192
|
+
if trace.decide(
|
|
193
|
+
f"collective_fallback:{verb.name}",
|
|
194
|
+
max(shape.flag("collective"), p_all),
|
|
195
|
+
th.collective_fallback,
|
|
196
|
+
):
|
|
197
|
+
# Some plural signal, no single target: all of them — but ask first.
|
|
198
|
+
trace.note(f"collective_fallback:{verb.name}")
|
|
199
|
+
targets = _all_entities(options)
|
|
200
|
+
local.append(max(shape.flag("collective"), p_all))
|
|
201
|
+
reasons.append("collective_fallback")
|
|
202
|
+
elif c.confidence < th.no_match_clarify:
|
|
203
|
+
candidates = tuple(e for o in ranked for e in o.entities)
|
|
204
|
+
trace.note(f"clarify:target:{verb.name}")
|
|
205
|
+
if pending_clarify is None:
|
|
206
|
+
pending_clarify = candidates[: config.clarify_max_candidates]
|
|
207
|
+
pending_clarify_verb = verb
|
|
208
|
+
else:
|
|
209
|
+
local.append(c.confidence)
|
|
210
|
+
else:
|
|
211
|
+
opt = next((o for o in options if o.label == c.choice), None)
|
|
212
|
+
weak = c.confidence < th.confirm_band and len(ranked) > 1
|
|
213
|
+
twins: list = []
|
|
214
|
+
if opt is not None:
|
|
215
|
+
# Same device name in several rooms: unless the scope singles out the picked
|
|
216
|
+
# one's room, nothing in the request can tell them apart, whatever the pick's
|
|
217
|
+
# confidence. A lookup, not a judgement.
|
|
218
|
+
base = _base_label(opt.label)
|
|
219
|
+
same = [o for o in options if o is not opt and _base_label(o.label) == base]
|
|
220
|
+
named = any(n.startswith("area_match:") for n in trace.notes)
|
|
221
|
+
in_scope = [
|
|
222
|
+
o
|
|
223
|
+
for o in same
|
|
224
|
+
if not named
|
|
225
|
+
or not shape.scope_areas
|
|
226
|
+
or o.entities[0].area_id in shape.scope_areas
|
|
227
|
+
]
|
|
228
|
+
twins = in_scope
|
|
229
|
+
if opt is not None and (weak or twins or verb.name in plan.ambiguous_by_area):
|
|
230
|
+
if not weak:
|
|
231
|
+
trace.note(f"clarify:ambiguous_by_area:{verb.name}")
|
|
232
|
+
if twins:
|
|
233
|
+
ranked = [opt, *twins]
|
|
234
|
+
# A weak pick among real alternatives, or same-named devices in different
|
|
235
|
+
# rooms with no room said: asking beats guessing or giving up.
|
|
236
|
+
ordered = [opt, *(o for o in ranked if o is not opt)]
|
|
237
|
+
candidates = tuple(e for o in ordered for e in o.entities)
|
|
238
|
+
trace.note(f"clarify:weak_pick:{verb.name}")
|
|
239
|
+
if pending_clarify is None:
|
|
240
|
+
pending_clarify = candidates[: config.clarify_max_candidates]
|
|
241
|
+
pending_clarify_verb = verb
|
|
242
|
+
else:
|
|
243
|
+
targets = opt.entities if opt else ()
|
|
244
|
+
local.append(c.confidence)
|
|
245
|
+
elif shape.scene is not None and verb.name == "activate":
|
|
246
|
+
targets = (shape.scene,)
|
|
247
|
+
|
|
248
|
+
params: dict[str, float | str] = {}
|
|
249
|
+
spec = verb.param
|
|
250
|
+
if (
|
|
251
|
+
isinstance(spec, ScoreSpec)
|
|
252
|
+
and round2 is not None
|
|
253
|
+
and f"param_value:{verb.name}" in round2.answers
|
|
254
|
+
):
|
|
255
|
+
# Jev judged which number (if any) is the value and how it is meant; code computes.
|
|
256
|
+
picked = round2.choice(f"param_value:{verb.name}")
|
|
257
|
+
number = parse_number(picked.choice) if picked.choice != NO_MATCH else None
|
|
258
|
+
if number is None:
|
|
259
|
+
trace.note(f"param:number_rejected:{verb.name}")
|
|
260
|
+
else:
|
|
261
|
+
p_rel = round2.noul(f"param_relative:{verb.name}")
|
|
262
|
+
relative = trace.decide(f"param_relative:{verb.name}", p_rel, th.flag)
|
|
263
|
+
if relative:
|
|
264
|
+
trace.note(f"param:relative:{verb.name}:{number}")
|
|
265
|
+
relative_change = True
|
|
266
|
+
else:
|
|
267
|
+
inverted = False
|
|
268
|
+
inv_conf = 1.0
|
|
269
|
+
if f"param_inverted:{verb.name}" in round2.answers:
|
|
270
|
+
p_inv = round2.noul(f"param_inverted:{verb.name}")
|
|
271
|
+
inverted = trace.decide(f"param_inverted:{verb.name}", p_inv, 0.5)
|
|
272
|
+
inv_conf = p_inv if inverted else 1.0 - p_inv
|
|
273
|
+
value = _numeric_value(spec, number, inverted=inverted)
|
|
274
|
+
if value is None:
|
|
275
|
+
trace.note(f"param:out_of_bounds:{verb.name}")
|
|
276
|
+
else:
|
|
277
|
+
params[spec.name] = value
|
|
278
|
+
local.extend((picked.confidence, 1.0 - p_rel, inv_conf))
|
|
279
|
+
trace.note(f"param:number:{verb.name}:{value}")
|
|
280
|
+
if not params and (
|
|
281
|
+
spec is not None and round2 is not None and f"param:{verb.name}" in round2.answers
|
|
282
|
+
):
|
|
283
|
+
if isinstance(spec, ScoreSpec):
|
|
284
|
+
sc = round2.score(f"param:{verb.name}")
|
|
285
|
+
params[spec.name] = score_to_value(spec, sc.score)
|
|
286
|
+
local.append(sc.confidence)
|
|
287
|
+
elif isinstance(spec, ChoiceSpec):
|
|
288
|
+
cc = round2.choice(f"param:{verb.name}")
|
|
289
|
+
params[spec.name] = cc.choice
|
|
290
|
+
local.append(cc.confidence)
|
|
291
|
+
|
|
292
|
+
if pending_clarify_verb is verb and not pending_clarify_params:
|
|
293
|
+
pending_clarify_params = dict(params)
|
|
294
|
+
|
|
295
|
+
if not targets:
|
|
296
|
+
trace.note(f"dropped:{verb.name}")
|
|
297
|
+
continue
|
|
298
|
+
contributions.extend(local)
|
|
299
|
+
actions.append(Action(verb, targets, params))
|
|
300
|
+
if verb.risk is Risk.CONFIRM:
|
|
301
|
+
reasons.append("risk:confirm")
|
|
302
|
+
if len(targets) > config.max_silent_targets:
|
|
303
|
+
reasons.append("blast_radius")
|
|
304
|
+
|
|
305
|
+
condition: Condition | None = None
|
|
306
|
+
if plan.condition_candidates and round2 is not None and "cond_subject" in round2.answers:
|
|
307
|
+
subj = round2.choice("cond_subject")
|
|
308
|
+
state = round2.choice("cond_state")
|
|
309
|
+
contributions.extend((subj.confidence, state.confidence))
|
|
310
|
+
if subj.choice == NO_MATCH:
|
|
311
|
+
trace.note("no_match:cond_subject")
|
|
312
|
+
elif state.choice == NO_MATCH:
|
|
313
|
+
trace.note("no_match:cond_state")
|
|
314
|
+
else:
|
|
315
|
+
opt = next((o for o in plan.condition_options if o.label == subj.choice), None)
|
|
316
|
+
if opt:
|
|
317
|
+
condition = Condition(opt.entities[0], state.choice)
|
|
318
|
+
|
|
319
|
+
if exception_unresolved:
|
|
320
|
+
return Escalate("exception", tuple(actions), trace)
|
|
321
|
+
if relative_change:
|
|
322
|
+
# The engine does not know current brightness/position/setpoints; the fallback agent does.
|
|
323
|
+
return Escalate("relative_change", tuple(actions), trace)
|
|
324
|
+
if condition is None and trace.decide(
|
|
325
|
+
"flag:has_condition", shape.flag("has_condition"), th.flag
|
|
326
|
+
):
|
|
327
|
+
# The request depends on something we could not turn into a Condition. Executing it
|
|
328
|
+
# unconditionally would be wrong; the fallback agent can handle the 'if'.
|
|
329
|
+
trace.note("condition_unresolved")
|
|
330
|
+
return Escalate("condition", tuple(actions), trace)
|
|
331
|
+
|
|
332
|
+
if not actions:
|
|
333
|
+
if pending_clarify:
|
|
334
|
+
# Nothing resolved and one target Choice spread its mass over real options: ask.
|
|
335
|
+
return NeedsClarification(
|
|
336
|
+
"which_device", pending_clarify, trace, pending_clarify_verb, pending_clarify_params
|
|
337
|
+
)
|
|
338
|
+
return Escalate("low_confidence", (), trace)
|
|
339
|
+
if pending_clarify:
|
|
340
|
+
trace.note("clarify_suppressed:other_verbs_resolved")
|
|
341
|
+
|
|
342
|
+
confidence = min(contributions) if contributions else 0.0
|
|
343
|
+
trace.decide("confidence", confidence, th.auto_execute)
|
|
344
|
+
|
|
345
|
+
if reasons:
|
|
346
|
+
return NeedsConfirmation(tuple(actions), condition, reasons[0], trace)
|
|
347
|
+
if confidence >= th.auto_execute:
|
|
348
|
+
return Resolved(tuple(actions), condition, confidence, trace)
|
|
349
|
+
if confidence >= th.confirm_band:
|
|
350
|
+
return NeedsConfirmation(tuple(actions), condition, "confidence", trace)
|
|
351
|
+
return Escalate("low_confidence", tuple(actions), trace)
|