adopt-ask 0.4.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.
- adopt_ask/__init__.py +114 -0
- adopt_ask/answer.py +238 -0
- adopt_ask/branch.py +211 -0
- adopt_ask/capture.py +267 -0
- adopt_ask/escalate.py +166 -0
- adopt_ask/py.typed +0 -0
- adopt_ask/questionlog.py +97 -0
- adopt_ask/records.py +107 -0
- adopt_ask/retrieve.py +263 -0
- adopt_ask/serve.py +196 -0
- adopt_ask/synthesis.py +271 -0
- adopt_ask-0.4.0.dist-info/METADATA +23 -0
- adopt_ask-0.4.0.dist-info/RECORD +16 -0
- adopt_ask-0.4.0.dist-info/WHEEL +4 -0
- adopt_ask-0.4.0.dist-info/licenses/LICENSE +201 -0
- adopt_ask-0.4.0.dist-info/licenses/NOTICE +39 -0
adopt_ask/__init__.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""`adopt ask` -- the Private Project Assistant. Build 3.
|
|
2
|
+
|
|
3
|
+
Honest three-way answers over the store: **KNOWN** (the answer, citing the exact
|
|
4
|
+
revisions, the identity URIs they are bound to, and the rule that let them
|
|
5
|
+
serve), **STALE** (the prior answer, served *with* the cause that made it stale),
|
|
6
|
+
**UNKNOWN** (a refusal). Never an unqualified guess.
|
|
7
|
+
|
|
8
|
+
**The invariant this package exists to hold** (v6.1 §4 R6, critical semantic
|
|
9
|
+
invariant #5): *no code path serves an answer without a freshness resolution.*
|
|
10
|
+
`branch.compose` takes `Resolved` values -- a candidate welded to its resolution
|
|
11
|
+
-- so a caller cannot express "serve this passage, I did not check". The rule is
|
|
12
|
+
a type, not a review comment. See `adopt_ask.branch` for why that shape was
|
|
13
|
+
chosen over a required mapping argument.
|
|
14
|
+
|
|
15
|
+
Three further postures, each inherited rather than invented:
|
|
16
|
+
|
|
17
|
+
* **No model call anywhere in this sprint.** The extractive answer *is* the
|
|
18
|
+
complete no-model mode (R3): the store holds prose a human wrote, so answering
|
|
19
|
+
is quotation with attribution rather than generation. Optional grounded
|
|
20
|
+
synthesis arrives in S3.2, behind the existing agent seam, and is discarded
|
|
21
|
+
when it cites nothing (invariant #7).
|
|
22
|
+
* **The index is derived, never canon.** FTS5 lives in the runtime annex,
|
|
23
|
+
rebuilt from the store whenever the two disagree, never exported. One canon
|
|
24
|
+
(R9) survives the existence of a second searchable copy because the second one
|
|
25
|
+
is disposable and is treated as such.
|
|
26
|
+
* **Only verified knowledge serves as KNOWN** (F6). Enforced when the index is
|
|
27
|
+
built *and* again when the branch is decided, against the store rather than
|
|
28
|
+
the index -- the derived copy is the wrong authority on what may be said.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from adopt_ask.answer import (
|
|
32
|
+
ASK_EVENT_TYPE,
|
|
33
|
+
envelope,
|
|
34
|
+
guard,
|
|
35
|
+
json_payload,
|
|
36
|
+
render,
|
|
37
|
+
sendable_payload,
|
|
38
|
+
)
|
|
39
|
+
from adopt_ask.branch import (
|
|
40
|
+
KNOWN,
|
|
41
|
+
STALE,
|
|
42
|
+
UNKNOWN,
|
|
43
|
+
Answer,
|
|
44
|
+
Branch,
|
|
45
|
+
Citation,
|
|
46
|
+
Resolved,
|
|
47
|
+
compose,
|
|
48
|
+
)
|
|
49
|
+
from adopt_ask.capture import (
|
|
50
|
+
CaptureResult,
|
|
51
|
+
CaptureStore,
|
|
52
|
+
capture_answer,
|
|
53
|
+
identities_to_bind,
|
|
54
|
+
)
|
|
55
|
+
from adopt_ask.escalate import (
|
|
56
|
+
ESCALATABLE,
|
|
57
|
+
EscalationWriter,
|
|
58
|
+
consent_prompt,
|
|
59
|
+
consented,
|
|
60
|
+
escalate,
|
|
61
|
+
escalation_branch,
|
|
62
|
+
may_escalate,
|
|
63
|
+
)
|
|
64
|
+
from adopt_ask.questionlog import QuestionLog, QuestionRecord, log_question, should_log
|
|
65
|
+
from adopt_ask.records import Passage, RefreshOutcome, SearchRecords
|
|
66
|
+
from adopt_ask.retrieve import Candidate, CandidateOrigin, retrieve, uris_in
|
|
67
|
+
from adopt_ask.serve import build_server, exposure_warning, is_loopback
|
|
68
|
+
from adopt_ask.synthesis import SYNTHESIS_PROMPT_REF, Synthesis, ground, synthesize
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"ASK_EVENT_TYPE",
|
|
72
|
+
"ESCALATABLE",
|
|
73
|
+
"KNOWN",
|
|
74
|
+
"STALE",
|
|
75
|
+
"SYNTHESIS_PROMPT_REF",
|
|
76
|
+
"UNKNOWN",
|
|
77
|
+
"Answer",
|
|
78
|
+
"Branch",
|
|
79
|
+
"Candidate",
|
|
80
|
+
"CandidateOrigin",
|
|
81
|
+
"CaptureResult",
|
|
82
|
+
"CaptureStore",
|
|
83
|
+
"Citation",
|
|
84
|
+
"EscalationWriter",
|
|
85
|
+
"Passage",
|
|
86
|
+
"QuestionLog",
|
|
87
|
+
"QuestionRecord",
|
|
88
|
+
"RefreshOutcome",
|
|
89
|
+
"Resolved",
|
|
90
|
+
"SearchRecords",
|
|
91
|
+
"Synthesis",
|
|
92
|
+
"build_server",
|
|
93
|
+
"capture_answer",
|
|
94
|
+
"compose",
|
|
95
|
+
"consent_prompt",
|
|
96
|
+
"consented",
|
|
97
|
+
"envelope",
|
|
98
|
+
"escalate",
|
|
99
|
+
"escalation_branch",
|
|
100
|
+
"exposure_warning",
|
|
101
|
+
"ground",
|
|
102
|
+
"guard",
|
|
103
|
+
"identities_to_bind",
|
|
104
|
+
"is_loopback",
|
|
105
|
+
"json_payload",
|
|
106
|
+
"log_question",
|
|
107
|
+
"may_escalate",
|
|
108
|
+
"render",
|
|
109
|
+
"retrieve",
|
|
110
|
+
"sendable_payload",
|
|
111
|
+
"should_log",
|
|
112
|
+
"synthesize",
|
|
113
|
+
"uris_in",
|
|
114
|
+
]
|
adopt_ask/answer.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Rendering an `Answer`, and the boundary that decides whether it may leave.
|
|
2
|
+
|
|
3
|
+
**The extractive answer is the passages themselves.** No summarization, no
|
|
4
|
+
paraphrase, no stitching -- the cited revision bodies verbatim, in retrieval
|
|
5
|
+
order, each with its revision id, the identity URIs it is bound to, and the
|
|
6
|
+
freshness rule that permitted it. That is the complete no-model mode R3
|
|
7
|
+
requires, and it is complete because the store already holds prose a human
|
|
8
|
+
wrote: an assistant that had to generate in order to answer would have nothing
|
|
9
|
+
to say without a model, which is exactly the dependency v6.1 refuses.
|
|
10
|
+
|
|
11
|
+
**The boundary check, and the sentence it implements.** v6.1 §6 Build 3: *the
|
|
12
|
+
assistant never answers outside the declared observability boundary*. Two ways
|
|
13
|
+
that can be false, and both are refusals here:
|
|
14
|
+
|
|
15
|
+
* **No boundary is declared.** Fail closed, on Build 0's egress posture -- an
|
|
16
|
+
undeclared boundary is not an unlimited one. `adopt init` always declares one,
|
|
17
|
+
so a store without one is a store assembled by something else, and that is
|
|
18
|
+
precisely when guessing is worst.
|
|
19
|
+
* **The boundary does not permit what this answer would carry.** The envelope is
|
|
20
|
+
validated by the Build 0 gate rather than by a second rule invented here, so
|
|
21
|
+
the deny-list widens by itself when a `md`/`text` column is added to the
|
|
22
|
+
manifest (`content_fields`).
|
|
23
|
+
|
|
24
|
+
**Why a local answer validates a metadata-only envelope.** `adopt ask` prints
|
|
25
|
+
inside the boundary: the content does not leave, so the envelope that represents
|
|
26
|
+
what *would* leave carries ids, URIs, states and counts -- never the question
|
|
27
|
+
text, the titles or the bodies. Those three are content fields by derivation,
|
|
28
|
+
and building them into a metadata-only payload would make the gate reject every
|
|
29
|
+
answer on every default store, which is how a control ends up switched off. A
|
|
30
|
+
caller with a genuinely outbound destination passes the policy that destination
|
|
31
|
+
uses, and then the boundary is asked the real question. B7's channels are the
|
|
32
|
+
first such caller.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import datetime as _dt
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from typing import Any, Final
|
|
38
|
+
|
|
39
|
+
from adopt_ask.branch import Answer
|
|
40
|
+
from adopt_const import SCHEMA_VERSION
|
|
41
|
+
from adopt_detect import METADATA_ONLY, BoundaryView
|
|
42
|
+
from adopt_obs import AdoptError, ErrorCode
|
|
43
|
+
from adopt_policy import validate_envelope
|
|
44
|
+
from adopt_schema.manifest import Manifest
|
|
45
|
+
from adopt_scope import Scope
|
|
46
|
+
|
|
47
|
+
__all__ = ["ASK_EVENT_TYPE", "envelope", "guard", "render", "sendable_payload"]
|
|
48
|
+
|
|
49
|
+
#: The envelope's `event_type` for an answer. One name, because a boundary
|
|
50
|
+
#: audit that had to recognise three spellings of "we answered a question" is a
|
|
51
|
+
#: boundary audit nobody completes.
|
|
52
|
+
ASK_EVENT_TYPE: Final[str] = "ask_answered"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sendable_payload(answer: Answer, *, include_content: bool) -> dict[str, Any]:
|
|
56
|
+
"""The envelope payload for `answer`.
|
|
57
|
+
|
|
58
|
+
Under `include_content=False` this is deliberately free of every content
|
|
59
|
+
field: no `question`, no `title`, no `body_md`. Those are the names
|
|
60
|
+
`find_content_fields` derives from the manifest and the logger deny-list,
|
|
61
|
+
and omitting them is what makes a metadata-only answer honestly
|
|
62
|
+
metadata-only rather than merely asserted to be.
|
|
63
|
+
"""
|
|
64
|
+
citations: list[dict[str, Any]] = []
|
|
65
|
+
for citation in answer.citations:
|
|
66
|
+
entry: dict[str, Any] = {
|
|
67
|
+
"revision_id": citation.revision_id,
|
|
68
|
+
"item_id": citation.item_id,
|
|
69
|
+
"identity_uris": list(citation.identity_uris),
|
|
70
|
+
"origin": citation.origin,
|
|
71
|
+
"freshness_state": citation.freshness_state,
|
|
72
|
+
"deciding_rule": citation.deciding_rule,
|
|
73
|
+
}
|
|
74
|
+
if include_content:
|
|
75
|
+
entry["title"] = citation.title
|
|
76
|
+
entry["body_md"] = citation.body_md
|
|
77
|
+
citations.append(entry)
|
|
78
|
+
|
|
79
|
+
payload: dict[str, Any] = {
|
|
80
|
+
"branch": answer.branch,
|
|
81
|
+
"citation_count": len(answer.citations),
|
|
82
|
+
"withheld_count": len(answer.withheld),
|
|
83
|
+
"citations": citations,
|
|
84
|
+
}
|
|
85
|
+
if answer.cause is not None:
|
|
86
|
+
payload["cause"] = answer.cause
|
|
87
|
+
if include_content:
|
|
88
|
+
payload["question"] = answer.question
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def envelope(
|
|
93
|
+
answer: Answer,
|
|
94
|
+
*,
|
|
95
|
+
scope: Scope,
|
|
96
|
+
occurred_at: _dt.datetime,
|
|
97
|
+
content_policy: str = METADATA_ONLY,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""A contracts §8 envelope for `answer`, in `scope`.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
AdoptError: ``ASK_OUTSIDE_BOUNDARY`` when the scope is not resolved to an
|
|
103
|
+
environment. Every scope id is required to check an envelope against
|
|
104
|
+
any boundary at all, and a half-resolved scope is a question we
|
|
105
|
+
cannot say whose system it concerns.
|
|
106
|
+
"""
|
|
107
|
+
if scope.engagement is None or scope.system is None or scope.environment is None:
|
|
108
|
+
raise _refuse(
|
|
109
|
+
f"the scope {scope.path()!r} does not resolve to an environment, so no "
|
|
110
|
+
"boundary governs it",
|
|
111
|
+
"Open the store at a full firm/engagement/system/environment scope. An "
|
|
112
|
+
"answer whose system is unknown cannot be checked against any boundary.",
|
|
113
|
+
)
|
|
114
|
+
return {
|
|
115
|
+
"schema_version": SCHEMA_VERSION,
|
|
116
|
+
"firm_id": scope.firm.id,
|
|
117
|
+
"engagement_id": scope.engagement.id,
|
|
118
|
+
"system_id": scope.system.id,
|
|
119
|
+
"environment_id": scope.environment.id,
|
|
120
|
+
"event_type": ASK_EVENT_TYPE,
|
|
121
|
+
"occurred_at": occurred_at.isoformat(),
|
|
122
|
+
"content_policy": content_policy,
|
|
123
|
+
"payload": sendable_payload(answer, include_content=content_policy != METADATA_ONLY),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def guard(
|
|
128
|
+
answer: Answer,
|
|
129
|
+
boundary: BoundaryView | None,
|
|
130
|
+
*,
|
|
131
|
+
scope: Scope,
|
|
132
|
+
occurred_at: _dt.datetime,
|
|
133
|
+
content_policy: str = METADATA_ONLY,
|
|
134
|
+
manifest: Manifest | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Refuse `answer` unless the declared boundary permits it. Raises, or returns None.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
answer: The composed answer. Not modified, and never partially served --
|
|
140
|
+
a refusal here withholds the whole answer, because an answer with
|
|
141
|
+
its citations stripped to fit a boundary is an unattributed claim,
|
|
142
|
+
which is worse than a refusal.
|
|
143
|
+
boundary: The declared boundary, or `None` when the store holds none.
|
|
144
|
+
scope: The full scope the store was opened at.
|
|
145
|
+
occurred_at: Injected clock reading; never `datetime.now()` here.
|
|
146
|
+
content_policy: What the destination carries. The default is the local
|
|
147
|
+
terminal's: metadata only, because the content never leaves.
|
|
148
|
+
manifest: Injected manifest, for tests asserting the derived deny-list.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
AdoptError: ``ASK_OUTSIDE_BOUNDARY`` for every refusal, with the
|
|
152
|
+
underlying Build 0 envelope violation named in the message.
|
|
153
|
+
"""
|
|
154
|
+
if boundary is None:
|
|
155
|
+
raise _refuse(
|
|
156
|
+
"no observability boundary is declared for this scope",
|
|
157
|
+
"Run `adopt init` (or `adopt boundary`) to declare one. An undeclared "
|
|
158
|
+
"boundary is not an unlimited boundary -- with nothing to check against, "
|
|
159
|
+
"the assistant refuses rather than guesses what the client agreed to.",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
validate_envelope(
|
|
164
|
+
envelope(answer, scope=scope, occurred_at=occurred_at, content_policy=content_policy),
|
|
165
|
+
boundary,
|
|
166
|
+
manifest=manifest,
|
|
167
|
+
)
|
|
168
|
+
except AdoptError as violation:
|
|
169
|
+
if violation.code is ErrorCode.ASK_OUTSIDE_BOUNDARY:
|
|
170
|
+
raise
|
|
171
|
+
raise _refuse(
|
|
172
|
+
f"the declared boundary does not permit this answer: {violation.message}",
|
|
173
|
+
"The boundary is the authority, not the question. Widening what may leave "
|
|
174
|
+
"is a contract amendment recorded on the boundary row with "
|
|
175
|
+
"`contractual_approval_ref`.",
|
|
176
|
+
) from violation
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render(answer: Answer) -> str:
|
|
180
|
+
"""The human rendering: the branch, then each passage verbatim with its grounds."""
|
|
181
|
+
if answer.branch == "unknown":
|
|
182
|
+
lines = [f"UNKNOWN: the store holds no confirmed answer to {answer.question!r}."]
|
|
183
|
+
if answer.withheld:
|
|
184
|
+
lines.append(
|
|
185
|
+
f"{len(answer.withheld)} matching revision(s) were withheld as unverified. "
|
|
186
|
+
"Confirm them in `adopt review` and ask again."
|
|
187
|
+
)
|
|
188
|
+
return "\n".join(lines)
|
|
189
|
+
|
|
190
|
+
header = f"{answer.branch.upper()}: {answer.question}"
|
|
191
|
+
if answer.branch == "stale":
|
|
192
|
+
header += f"\nSTALE because {answer.cause} -- the answer below is what was true before."
|
|
193
|
+
|
|
194
|
+
blocks = [header]
|
|
195
|
+
for citation in answer.citations:
|
|
196
|
+
grounds = f" revision {citation.revision_id} ({citation.freshness_state}, "
|
|
197
|
+
grounds += f"matched by {citation.origin}, rule {citation.deciding_rule})"
|
|
198
|
+
if citation.identity_uris:
|
|
199
|
+
grounds += "\n bound to: " + ", ".join(citation.identity_uris)
|
|
200
|
+
blocks.append(f"\n## {citation.title}\n{citation.body_md}\n{grounds}")
|
|
201
|
+
|
|
202
|
+
if answer.withheld:
|
|
203
|
+
blocks.append(f"\n{len(answer.withheld)} further revision(s) withheld as unverified.")
|
|
204
|
+
return "\n".join(blocks)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _refuse(message: str, hint: str) -> AdoptError:
|
|
208
|
+
return AdoptError(ErrorCode.ASK_OUTSIDE_BOUNDARY, message=message, hint=hint)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def json_payload(answer: Answer) -> Mapping[str, Any]:
|
|
212
|
+
"""The `--json` payload: the full answer, content included.
|
|
213
|
+
|
|
214
|
+
Distinct from `sendable_payload` on purpose. This one is for the operator's
|
|
215
|
+
own terminal inside the boundary and carries everything; that one is for an
|
|
216
|
+
envelope that may leave and carries what the boundary permits. Collapsing
|
|
217
|
+
the two would mean either an unreadable local answer or an egress payload
|
|
218
|
+
shaped by what is convenient to print.
|
|
219
|
+
"""
|
|
220
|
+
return {
|
|
221
|
+
"question": answer.question,
|
|
222
|
+
"branch": answer.branch,
|
|
223
|
+
"cause": answer.cause,
|
|
224
|
+
"withheld": list(answer.withheld),
|
|
225
|
+
"citations": [
|
|
226
|
+
{
|
|
227
|
+
"revision_id": citation.revision_id,
|
|
228
|
+
"item_id": citation.item_id,
|
|
229
|
+
"title": citation.title,
|
|
230
|
+
"body_md": citation.body_md,
|
|
231
|
+
"identity_uris": list(citation.identity_uris),
|
|
232
|
+
"origin": citation.origin,
|
|
233
|
+
"freshness_state": citation.freshness_state,
|
|
234
|
+
"deciding_rule": citation.deciding_rule,
|
|
235
|
+
}
|
|
236
|
+
for citation in answer.citations
|
|
237
|
+
],
|
|
238
|
+
}
|
adopt_ask/branch.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The three-way branch: KNOWN, STALE, UNKNOWN -- and never anything else.
|
|
2
|
+
|
|
3
|
+
**This module is where critical semantic invariant #5 stops being a rule and
|
|
4
|
+
becomes a shape.** `compose` does not accept candidates. It accepts `Resolved` --
|
|
5
|
+
a candidate welded to the `FreshnessResolution` for its item -- and `Resolved`
|
|
6
|
+
cannot be constructed without one. So there is no argument a caller can supply
|
|
7
|
+
that represents "a passage I have not resolved freshness for", which means there
|
|
8
|
+
is no code path from retrieval to a served answer that skips the check. Not a
|
|
9
|
+
guard that runs first: a value that cannot exist.
|
|
10
|
+
|
|
11
|
+
That is deliberately stronger than a required `Mapping[item_id, resolution]`
|
|
12
|
+
argument, which was the obvious design. A mapping can be missing a key, so it
|
|
13
|
+
needs a runtime raise for the gap, and a runtime raise is exactly the thing a
|
|
14
|
+
later refactor quietly turns into `.get(..., FRESH)`. Pairing removes the gap
|
|
15
|
+
instead of checking for it.
|
|
16
|
+
|
|
17
|
+
Why any of this is worth the ceremony: "check freshness before serving" is the
|
|
18
|
+
kind of rule that survives its first author and dies in the third refactor, when
|
|
19
|
+
someone adds a fast path for a case that "obviously cannot be stale". The
|
|
20
|
+
failure is silent and lands in a client's face -- a confident, cited answer
|
|
21
|
+
about a system that changed last month.
|
|
22
|
+
|
|
23
|
+
**Two verification filters, deliberately duplicated.** Unverified knowledge never
|
|
24
|
+
serves as KNOWN (F6). The FTS index already refuses to hold it, and this module
|
|
25
|
+
checks again against the store's own answer. Not belt-and-braces: the index is
|
|
26
|
+
derived and can be stale, so it is precisely the wrong authority on what may be
|
|
27
|
+
served. The index makes the common case fast; the store makes the guarantee
|
|
28
|
+
true.
|
|
29
|
+
|
|
30
|
+
**STALE serves rather than refuses.** A stale answer carries the prior content
|
|
31
|
+
*and* the rule that decided staleness (F3, pre-B6: identity death, moves,
|
|
32
|
+
retirement). Refusing would discard knowledge the store genuinely holds; the
|
|
33
|
+
honest form is "here is what we knew, and here is why it may be wrong".
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from collections.abc import Sequence, Set
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Final, Literal
|
|
39
|
+
|
|
40
|
+
from adopt_ask.retrieve import Candidate, CandidateOrigin
|
|
41
|
+
from adopt_freshness import FreshnessResolution
|
|
42
|
+
|
|
43
|
+
__all__ = ["KNOWN", "STALE", "UNKNOWN", "Answer", "Branch", "Citation", "Resolved", "compose"]
|
|
44
|
+
|
|
45
|
+
Branch = Literal["known", "stale", "unknown"]
|
|
46
|
+
|
|
47
|
+
KNOWN: Final[Branch] = "known"
|
|
48
|
+
STALE: Final[Branch] = "stale"
|
|
49
|
+
UNKNOWN: Final[Branch] = "unknown"
|
|
50
|
+
|
|
51
|
+
#: The freshness states that may serve as KNOWN. Everything else -- `stale`,
|
|
52
|
+
#: `observation_stale`, `retired` -- serves as STALE with its cause named.
|
|
53
|
+
#:
|
|
54
|
+
#: **`unverified` is here, and that is the subtle one.** It is
|
|
55
|
+
#: `INITIAL_ITEM_FRESHNESS`: the state every knowledge item is created in,
|
|
56
|
+
#: meaning *no freshness rule has fired either way*. It is not a staleness
|
|
57
|
+
#: signal, and reading it as one would be false staleness manufactured from a
|
|
58
|
+
#: default -- the precise failure v6.1's H5 calls "the exact failure that makes
|
|
59
|
+
#: FDEs stop trusting the queue". It would also make Build 3 undeliverable:
|
|
60
|
+
#: pre-B6 nothing sets an item to `fresh`, so treating `unverified` as STALE
|
|
61
|
+
#: means `adopt ask` could never answer KNOWN until Build 6 shipped.
|
|
62
|
+
#:
|
|
63
|
+
#: v6.1 §6 F3 is the authority and was explicit about the pre-B6 scope: staleness
|
|
64
|
+
#: arose from identity **death and moves** surfaced by map reruns, and from
|
|
65
|
+
#: **retirement**. Those three arrive as `stale` and `retired`, each carrying the
|
|
66
|
+
#: rule that produced it.
|
|
67
|
+
#:
|
|
68
|
+
#: **Build 6 has since shipped, and F3's boundary has moved -- with no change to
|
|
69
|
+
#: this module.** `adopt refresh` adds the fourth source: a changed attribute
|
|
70
|
+
#: digest on a bound referent stales the *binding*, which `resolve_freshness`
|
|
71
|
+
#: already reads as `RULE_BINDING_STALE`. That the sensitivity of STALE could
|
|
72
|
+
#: grow without this file being edited is F3's design working as intended: the
|
|
73
|
+
#: three-way contract was complete at Build 3, and Build 6 fed it rather than
|
|
74
|
+
#: extending it. The one thing that did change is `adopt_freshness`' treatment of
|
|
75
|
+
#: a **superseded** binding, which a rebind now creates -- and that is a rule
|
|
76
|
+
#: about which bindings are consulted, not about which states serve.
|
|
77
|
+
#:
|
|
78
|
+
#: Naming the servable states rather than the unservable ones is deliberate in
|
|
79
|
+
#: the other direction: a `freshness_state` added to the manifest later falls
|
|
80
|
+
#: into STALE, which is the safe way to be wrong about a state this code has
|
|
81
|
+
#: never seen.
|
|
82
|
+
_SERVES_AS_KNOWN: Final[frozenset[str]] = frozenset({"fresh", "unverified"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Resolved:
|
|
87
|
+
"""A retrieval candidate and the freshness resolution for its item.
|
|
88
|
+
|
|
89
|
+
The pairing is the invariant, and `__post_init__` enforces that it is a
|
|
90
|
+
*true* pairing: a resolution computed for some other item is rejected at
|
|
91
|
+
construction. Without that check the type would still admit the failure it
|
|
92
|
+
exists to prevent -- freshness resolved, but not for this passage.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
candidate: Candidate
|
|
96
|
+
freshness: FreshnessResolution
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
if self.freshness.item_id != self.candidate.passage.item_id:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"the freshness resolution is for a different item than the candidate: "
|
|
102
|
+
f"{self.freshness.item_id} != {self.candidate.passage.item_id}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class Citation:
|
|
108
|
+
"""One served passage and the exact grounds for serving it."""
|
|
109
|
+
|
|
110
|
+
revision_id: str
|
|
111
|
+
item_id: str
|
|
112
|
+
title: str
|
|
113
|
+
body_md: str
|
|
114
|
+
identity_uris: tuple[str, ...]
|
|
115
|
+
origin: CandidateOrigin
|
|
116
|
+
freshness_state: str
|
|
117
|
+
deciding_rule: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class Answer:
|
|
122
|
+
"""What `adopt ask` returns. One of exactly three branches, always cited.
|
|
123
|
+
|
|
124
|
+
An UNKNOWN carries no citations by construction; a KNOWN or STALE carries at
|
|
125
|
+
least one. `__post_init__` refuses to build the alternatives rather than
|
|
126
|
+
leaving a caller to notice -- an uncited KNOWN is the unqualified guess this
|
|
127
|
+
whole build exists to make impossible.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
question: str
|
|
131
|
+
branch: Branch
|
|
132
|
+
citations: tuple[Citation, ...]
|
|
133
|
+
#: Present exactly when `branch` is STALE: the rule that decided staleness,
|
|
134
|
+
#: taken verbatim from the resolution rather than re-derived here.
|
|
135
|
+
cause: str | None = None
|
|
136
|
+
#: Revisions retrieved but withheld as unverified. Reported so an UNKNOWN
|
|
137
|
+
#: over a store that *does* hold matching text says which of the two reasons
|
|
138
|
+
#: applied -- nothing matched, or nothing matched that was verified. Those
|
|
139
|
+
#: send an operator to different places: write the answer, or go confirm the
|
|
140
|
+
#: draft that already says it.
|
|
141
|
+
withheld: tuple[str, ...] = ()
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
if self.branch == UNKNOWN and self.citations:
|
|
145
|
+
raise ValueError("an UNKNOWN answer cannot carry citations")
|
|
146
|
+
if self.branch != UNKNOWN and not self.citations:
|
|
147
|
+
raise ValueError(f"a {self.branch.upper()} answer must cite at least one revision")
|
|
148
|
+
if (self.cause is None) == (self.branch == STALE):
|
|
149
|
+
raise ValueError("cause is set exactly when the branch is STALE")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def compose(resolved: Sequence[Resolved], verified_revision_ids: Set[str], question: str) -> Answer:
|
|
153
|
+
"""Decide the branch over already-resolved candidates.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
resolved: Retrieval output paired with freshness, best first. Order is
|
|
157
|
+
preserved into the citations: retrieval already decided what "best"
|
|
158
|
+
means and this function does not second-guess it.
|
|
159
|
+
verified_revision_ids: Revision ids the **store** reports as verified.
|
|
160
|
+
Read from canon rather than from the derived index, because the
|
|
161
|
+
index is exactly the wrong authority on what may be served.
|
|
162
|
+
question: Echoed into the answer so a payload is self-describing.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
KNOWN if any resolved candidate is verified and carries no staleness
|
|
166
|
+
signal; otherwise STALE if any is verified and has gone out of date;
|
|
167
|
+
otherwise UNKNOWN. `_SERVES_AS_KNOWN` records which states are which and
|
|
168
|
+
why `unverified` freshness is not staleness.
|
|
169
|
+
"""
|
|
170
|
+
servable = [
|
|
171
|
+
item for item in resolved if item.candidate.passage.revision_id in verified_revision_ids
|
|
172
|
+
]
|
|
173
|
+
withheld = tuple(
|
|
174
|
+
item.candidate.passage.revision_id
|
|
175
|
+
for item in resolved
|
|
176
|
+
if item.candidate.passage.revision_id not in verified_revision_ids
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
fresh = [item for item in servable if item.freshness.state in _SERVES_AS_KNOWN]
|
|
180
|
+
if fresh:
|
|
181
|
+
return Answer(
|
|
182
|
+
question=question,
|
|
183
|
+
branch=KNOWN,
|
|
184
|
+
citations=tuple(_cite(item) for item in fresh),
|
|
185
|
+
withheld=withheld,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if servable:
|
|
189
|
+
return Answer(
|
|
190
|
+
question=question,
|
|
191
|
+
branch=STALE,
|
|
192
|
+
citations=tuple(_cite(item) for item in servable),
|
|
193
|
+
cause=servable[0].freshness.deciding_rule,
|
|
194
|
+
withheld=withheld,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return Answer(question=question, branch=UNKNOWN, citations=(), withheld=withheld)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cite(item: Resolved) -> Citation:
|
|
201
|
+
passage = item.candidate.passage
|
|
202
|
+
return Citation(
|
|
203
|
+
revision_id=passage.revision_id,
|
|
204
|
+
item_id=passage.item_id,
|
|
205
|
+
title=passage.title,
|
|
206
|
+
body_md=passage.body_md,
|
|
207
|
+
identity_uris=passage.identity_uris,
|
|
208
|
+
origin=item.candidate.origin,
|
|
209
|
+
freshness_state=item.freshness.state,
|
|
210
|
+
deciding_rule=item.freshness.deciding_rule,
|
|
211
|
+
)
|