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 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
+ )