agentfox 0.3.1__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.
Files changed (171) hide show
  1. agentfox/__init__.py +45 -0
  2. agentfox/agent_loop.py +222 -0
  3. agentfox/agent_messaging.py +90 -0
  4. agentfox/answerability.py +605 -0
  5. agentfox/arbitration.py +309 -0
  6. agentfox/attribution.py +538 -0
  7. agentfox/audit/__init__.py +47 -0
  8. agentfox/audit/chain.py +420 -0
  9. agentfox/audit/evidence.py +560 -0
  10. agentfox/audit/otel.py +160 -0
  11. agentfox/audit/siem.py +233 -0
  12. agentfox/audit/trace.py +337 -0
  13. agentfox/autoguard.py +1261 -0
  14. agentfox/availability.py +702 -0
  15. agentfox/business/__init__.py +43 -0
  16. agentfox/business/catalogue.py +583 -0
  17. agentfox/business/compile.py +1010 -0
  18. agentfox/business/graph.py +314 -0
  19. agentfox/business/ladder.py +396 -0
  20. agentfox/business/store.py +159 -0
  21. agentfox/cli/__init__.py +5 -0
  22. agentfox/cli/_style.py +40 -0
  23. agentfox/cli/auth_cli.py +222 -0
  24. agentfox/cli/business_cli.py +403 -0
  25. agentfox/cli/capability_cli.py +475 -0
  26. agentfox/cli/controls_cli.py +516 -0
  27. agentfox/cli/demo.py +662 -0
  28. agentfox/cli/main.py +2065 -0
  29. agentfox/cli/mcp_cli.py +39 -0
  30. agentfox/cli/onboarding.py +682 -0
  31. agentfox/cli/quickscan.py +187 -0
  32. agentfox/cli/submit.py +120 -0
  33. agentfox/commitments.py +496 -0
  34. agentfox/compliance/__init__.py +43 -0
  35. agentfox/compliance/catalog.py +250 -0
  36. agentfox/compliance/risk.py +380 -0
  37. agentfox/compliance/status.py +524 -0
  38. agentfox/compliance_data/controls.yaml +944 -0
  39. agentfox/compliance_data/obligations.yaml +91 -0
  40. agentfox/config.py +502 -0
  41. agentfox/context_integrity.py +609 -0
  42. agentfox/control_flow.py +164 -0
  43. agentfox/crypto.py +45 -0
  44. agentfox/data_access.py +384 -0
  45. agentfox/db.py +233 -0
  46. agentfox/discovery.py +847 -0
  47. agentfox/discovery_openapi.py +113 -0
  48. agentfox/effects.py +461 -0
  49. agentfox/enforcement.py +3161 -0
  50. agentfox/entitlement.py +464 -0
  51. agentfox/escalation.py +826 -0
  52. agentfox/evaluation/__init__.py +98 -0
  53. agentfox/evaluation/adapters.py +189 -0
  54. agentfox/evaluation/adaptive.py +1098 -0
  55. agentfox/evaluation/drift.py +303 -0
  56. agentfox/evaluation/gating.py +309 -0
  57. agentfox/evaluation/model_groundedness.py +127 -0
  58. agentfox/evaluation/ragas_adapter.py +241 -0
  59. agentfox/evaluation/redteam.py +1280 -0
  60. agentfox/evaluation/runner.py +366 -0
  61. agentfox/evaluation/scorers.py +350 -0
  62. agentfox/evaluation/silent_failure.py +438 -0
  63. agentfox/finding.py +40 -0
  64. agentfox/findings.py +377 -0
  65. agentfox/gateway/__init__.py +5 -0
  66. agentfox/gateway/app.py +555 -0
  67. agentfox/gateway/auth.py +307 -0
  68. agentfox/gateway/deps.py +126 -0
  69. agentfox/gateway/playground_sessions.py +414 -0
  70. agentfox/gateway/routes/__init__.py +1 -0
  71. agentfox/gateway/routes/answerability.py +165 -0
  72. agentfox/gateway/routes/discovery.py +99 -0
  73. agentfox/gateway/routes/entitlement.py +213 -0
  74. agentfox/gateway/routes/escalation.py +326 -0
  75. agentfox/gateway/routes/evaluation.py +798 -0
  76. agentfox/gateway/routes/governance.py +675 -0
  77. agentfox/gateway/routes/inline.py +964 -0
  78. agentfox/gateway/routes/integrations.py +608 -0
  79. agentfox/gateway/routes/jobs.py +145 -0
  80. agentfox/gateway/routes/memory.py +101 -0
  81. agentfox/gateway/routes/messaging.py +82 -0
  82. agentfox/gateway/routes/onboarding.py +293 -0
  83. agentfox/gateway/routes/playground.py +272 -0
  84. agentfox/gateway/routes/playground_deps.py +34 -0
  85. agentfox/gateway/routes/policy.py +512 -0
  86. agentfox/gateway/routes/proposals.py +158 -0
  87. agentfox/gateway/routes/provenance.py +345 -0
  88. agentfox/gateway/routes/registry.py +1034 -0
  89. agentfox/gateway/routes/tuning.py +263 -0
  90. agentfox/gateway/routes/waitlist.py +183 -0
  91. agentfox/gateway/verdicts.py +70 -0
  92. agentfox/guardrails/__init__.py +107 -0
  93. agentfox/guardrails/actions.py +865 -0
  94. agentfox/guardrails/adapters/__init__.py +0 -0
  95. agentfox/guardrails/adapters/classifiers.py +362 -0
  96. agentfox/guardrails/adapters/embeddings.py +158 -0
  97. agentfox/guardrails/adapters/hub.py +356 -0
  98. agentfox/guardrails/adapters/presidio.py +121 -0
  99. agentfox/guardrails/adapters/rails.py +119 -0
  100. agentfox/guardrails/base.py +258 -0
  101. agentfox/guardrails/composition.py +150 -0
  102. agentfox/guardrails/data/injection_corpus.json +239 -0
  103. agentfox/guardrails/detectors/__init__.py +0 -0
  104. agentfox/guardrails/detectors/injection.py +627 -0
  105. agentfox/guardrails/detectors/pii.py +162 -0
  106. agentfox/guardrails/detectors/safety.py +96 -0
  107. agentfox/guardrails/detectors/schema.py +145 -0
  108. agentfox/guardrails/detectors/secrets.py +128 -0
  109. agentfox/guardrails/normalize.py +756 -0
  110. agentfox/guardrails/pipeline.py +262 -0
  111. agentfox/guardrails/taint.py +183 -0
  112. agentfox/guardrails/tuning.py +907 -0
  113. agentfox/identity/__init__.py +41 -0
  114. agentfox/identity/service.py +620 -0
  115. agentfox/ids.py +57 -0
  116. agentfox/improvement/__init__.py +7 -0
  117. agentfox/improvement/appliers.py +445 -0
  118. agentfox/improvement/contract.py +121 -0
  119. agentfox/improvement/loops.py +235 -0
  120. agentfox/improvement/proposals.py +781 -0
  121. agentfox/integrations/__init__.py +39 -0
  122. agentfox/integrations/correlation.py +360 -0
  123. agentfox/integrations/fastapi.py +235 -0
  124. agentfox/integrations/langgraph.py +393 -0
  125. agentfox/integrations/mcp.py +440 -0
  126. agentfox/integrations/prometheus.py +226 -0
  127. agentfox/integrity.py +1024 -0
  128. agentfox/job_handlers.py +278 -0
  129. agentfox/jobs.py +178 -0
  130. agentfox/jobs_db.py +312 -0
  131. agentfox/mcp_server.py +863 -0
  132. agentfox/models.py +1802 -0
  133. agentfox/operator_log.py +235 -0
  134. agentfox/policies_data/baseline.yaml +165 -0
  135. agentfox/policies_data/eu-ai-act-high-risk.yaml +113 -0
  136. agentfox/policies_data/tool-containment.yaml +197 -0
  137. agentfox/policy/__init__.py +101 -0
  138. agentfox/policy/canary.py +401 -0
  139. agentfox/policy/engine.py +315 -0
  140. agentfox/policy/hierarchy.py +354 -0
  141. agentfox/policy/model.py +262 -0
  142. agentfox/policy/opa.py +248 -0
  143. agentfox/policy/simulate.py +185 -0
  144. agentfox/policy/store.py +287 -0
  145. agentfox/provenance.py +560 -0
  146. agentfox/providers/__init__.py +43 -0
  147. agentfox/providers/base.py +192 -0
  148. agentfox/providers/echo.py +146 -0
  149. agentfox/providers/enterprise.py +366 -0
  150. agentfox/providers/remote.py +299 -0
  151. agentfox/register.py +281 -0
  152. agentfox/registry/__init__.py +43 -0
  153. agentfox/registry/control.py +161 -0
  154. agentfox/registry/service.py +838 -0
  155. agentfox/reliability.py +343 -0
  156. agentfox/scheduler.py +224 -0
  157. agentfox/sdk/__init__.py +481 -0
  158. agentfox/seed.py +660 -0
  159. agentfox/session_scan.py +124 -0
  160. agentfox/sycophancy.py +181 -0
  161. agentfox/system_log.py +132 -0
  162. agentfox/tenancy.py +298 -0
  163. agentfox/tool_contract.py +264 -0
  164. agentfox/trajectory.py +457 -0
  165. agentfox/webhooks.py +390 -0
  166. agentfox-0.3.1.dist-info/METADATA +542 -0
  167. agentfox-0.3.1.dist-info/RECORD +171 -0
  168. agentfox-0.3.1.dist-info/WHEEL +4 -0
  169. agentfox-0.3.1.dist-info/entry_points.txt +3 -0
  170. agentfox-0.3.1.dist-info/licenses/LICENSE +202 -0
  171. nometria/__init__.py +62 -0
@@ -0,0 +1,605 @@
1
+ """P7 — answerability and abstention. Genuinely unclaimed across OSS and commercial.
2
+
3
+ *"If someone asks for future sales, the answer should be 'data not available', not a
4
+ generated one."*
5
+
6
+ Everything in the competitive set scores an answer **after** it exists: Cleanlab TLM,
7
+ Vectara HHEM, RAGAS, Galileo, Patronus all take a generated response and judge it.
8
+ That is a fundamentally different control from refusing to generate one, and it cannot
9
+ address F1 — by the time you are scoring, the number has been invented, and a
10
+ confident wrong number scored at 0.4 is still a confident wrong number in front of a
11
+ user.
12
+
13
+ **Nobody refuses to generate based on a declared coverage boundary.** That is the
14
+ claim, and it turns on the one thing a model cannot know about itself: what the index
15
+ behind it actually contains. AbstentionBench (20 datasets, 35k+ unanswerable queries)
16
+ found that reasoning fine-tuning frequently *degrades* abstention — the more capable
17
+ model is worse at saying "I don't know" — which is exactly what you would expect if
18
+ abstention is a property of the *system's* knowledge boundary rather than of the
19
+ model's reasoning.
20
+
21
+ So the boundary is declared, not inferred: which systems of record the agent can
22
+ reach, what time range they cover, which entity scopes exist, and which question
23
+ *types* are answerable at all. Four deterministic classifiers run pre-flight, and an
24
+ unanswerable question is answered from a template without a model call.
25
+
26
+ **The counter-metric is the point of the design.** Over-refusal (F1.5) kills adoption
27
+ faster than hallucination does, and the AAAI 2026 work on retrieval-augmented refusal
28
+ shows retrieval noise pushing models to refuse questions they could answer. So: every
29
+ abstention is recorded, over-refusal is detected and raises a finding, and the whole
30
+ pillar ships observe-first. Refusing to answer is a product decision with a cost, and
31
+ pretending otherwise produces an agent nobody uses.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import datetime as dt
37
+ import logging
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from typing import Any
41
+
42
+ from sqlalchemy import select
43
+ from sqlalchemy.orm import Session
44
+
45
+ from .findings import raise_finding as _raise_finding
46
+ from .models import Agent, Finding, KnowledgeBoundary, utcnow
47
+
48
+ log = logging.getLogger(__name__)
49
+
50
+ # Question types an agent may be declared able to answer.
51
+ FACT = "fact"
52
+ AGGREGATE = "aggregate"
53
+ PREDICTION = "prediction"
54
+ OPINION = "opinion"
55
+ PROCEDURE = "procedure"
56
+ QUESTION_TYPES = (FACT, AGGREGATE, PREDICTION, OPINION, PROCEDURE)
57
+
58
+ # Why an answer was refused. Each maps to a distinct template — "I don't know" is a
59
+ # worse answer than "I hold 24 months and you asked about 2019".
60
+ OUT_OF_COVERAGE = "out_of_coverage"
61
+ OUT_OF_SCOPE_ENTITY = "out_of_scope_entity"
62
+ UNSUPPORTED_TYPE = "unsupported_question_type"
63
+ OUT_OF_DOMAIN = "out_of_domain"
64
+ UNKNOWABLE = "unknowable"
65
+
66
+ # --- Question-type markers -------------------------------------------------
67
+
68
+ #: F1.1/F1.4 — the future tense is the single highest-signal marker there is. A
69
+ #: question about what *will* happen cannot be answered from a system of record,
70
+ #: whatever the model's confidence.
71
+ PREDICTION_MARKERS = [
72
+ r"\bwill\s+(?:\w+\s+){0,3}(?:be|become|reach|grow|fall|rise|drop|increase|decrease)\b",
73
+ r"\b(?:forecast|predict|projection|projected|expected to|anticipate)\w*\b",
74
+ r"\bhow much .{0,30}(?:next|coming|future|upcoming)\b",
75
+ r"\b(?:next|coming|following|upcoming)\s+(?:year|quarter|month|week)\b",
76
+ r"\b(?:q[1-4]\s*)?20[3-9]\d\b", # a year far enough out to be a forecast
77
+ r"\bgoing to\s+(?:be|reach|hit|grow)\b",
78
+ # Structural future-question shapes that don't depend on a fixed verb list —
79
+ # benchmarked on KUQ (benchmarks/answerability/README.md): these four raise
80
+ # future-tense recall 39.0% -> 69.0% for 2 new false positives out of 3,826
81
+ # benign rows tested, both defensible edge cases. Deliberately structural
82
+ # (question-initial "will", explicit relative-future phrasing) rather than a
83
+ # wider verb whitelist, which benchmarked far noisier for the same recall.
84
+ r"^\s*will\s+\w", # inverted yes/no future question: "Will X ever Y?"
85
+ r"\bwhen\s+will\b",
86
+ r"\b\d+\s+years?\s+from\s+now\b",
87
+ r"\bin\s+(?:the\s+next\s+)?\d+\s+years?\b",
88
+ r"\b(?:fifty|forty|thirty|twenty|ten|hundred)\s+years?\s+from\s+now\b",
89
+ # Hypothetical/conditional forecast shapes — the same structural-not-vocabulary
90
+ # discipline as the four above. Candidates tested against `known` + CoCoNot
91
+ # before shipping; see benchmarks/answerability/README.md.
92
+ r"\bwhat if\b.{0,80}\b(?:would|might|could)\b",
93
+ r"\bhow might\b.{0,40}\b(?:impact|shape|change|affect|influence)\b",
94
+ ]
95
+
96
+ _OPINION_MARKERS = [
97
+ r"\b(?:should i|do you (?:think|believe)|what do you (?:think|reckon)|in your opinion)\b",
98
+ r"\b(?:is it (?:a good|worth)|would you recommend)\b",
99
+ r"\bbest\s+(?:choice|option|approach)\s+for me\b",
100
+ # Third-person subjective/debatable framing — benchmarked on KUQ's
101
+ # "controversial" category (benchmarks/answerability/README.md): raises
102
+ # recall 0.74% -> 5.62% for 1 new false positive out of 3,447 benign rows.
103
+ # A real, disclosed ceiling remains: most debatable questions ("does
104
+ # pineapple belong on pizza") carry no syntactic marker at all — that gap
105
+ # needs semantic understanding, not more patterns, and is left open.
106
+ r"\b(?:is|are|was|were)\s+(?:\w+\s+){0,4}(?:better|worse|superior|inferior)\s+than\b",
107
+ r"\bdeserves?\s+(?:to|the)\b",
108
+ r"\bbelongs?\s+on\b",
109
+ r"\bis\s+it\s+(?:ok(?:ay)?|right|wrong|fair|moral|ethical|acceptable)\s+(?:to|for|that)\b",
110
+ r"\bshould\s+(?:\w+\s+){0,3}(?:be allowed|have|get|receive|deserve|be able)\b",
111
+ ]
112
+
113
+ _AGGREGATE_MARKERS = [
114
+ r"\b(?:total|sum|average|mean|median|count|how many|how much)\b",
115
+ r"\b(?:across all|in aggregate|overall)\b",
116
+ ]
117
+
118
+ _PROCEDURE_MARKERS = [
119
+ r"\bhow (?:do|can) i\b",
120
+ r"\bwhat (?:are the )?steps\b",
121
+ r"\bhow to\b",
122
+ ]
123
+
124
+ #: F1.6 — the agent narrating that it has part of the picture.
125
+ _COMPLETENESS_MARKERS = [
126
+ r"\b(?:based on|from) the (?:\d+|few|some) (?:documents|records|results)\b",
127
+ r"\bi found (\d+)\b",
128
+ ]
129
+
130
+ #: F1.5 — refusal language, used to detect refusals we did *not* ask for.
131
+ _REFUSAL_MARKERS = [
132
+ r"\bi (?:can'?t|cannot|am unable to|won'?t) (?:help|assist|answer|provide|share)\b",
133
+ r"\bi (?:don'?t|do not) have (?:access|that|the) \w*",
134
+ r"\b(?:that|this) (?:is |falls )?(?:outside|beyond) (?:my|the) (?:scope|remit|knowledge)\b",
135
+ r"\bdata not available\b",
136
+ r"\bi'?m (?:not able|unable) to\b",
137
+ ]
138
+
139
+ _PREDICTION_RE = [re.compile(p, re.I) for p in PREDICTION_MARKERS]
140
+ _OPINION_RE = [re.compile(p, re.I) for p in _OPINION_MARKERS]
141
+ _AGGREGATE_RE = [re.compile(p, re.I) for p in _AGGREGATE_MARKERS]
142
+ _PROCEDURE_RE = [re.compile(p, re.I) for p in _PROCEDURE_MARKERS]
143
+ _COMPLETENESS_RE = [re.compile(p, re.I) for p in _COMPLETENESS_MARKERS]
144
+ _REFUSAL_RE = [re.compile(p, re.I) for p in _REFUSAL_MARKERS]
145
+
146
+ #: Years and explicit dates, for the coverage-window check.
147
+ _YEAR_RE = re.compile(r"\b(19\d\d|20\d\d)\b")
148
+ _RELATIVE_PAST = re.compile(
149
+ r"\b(?:(\d+)|a|one|two|three|four|five|six|seven|eight|nine|ten)\s+"
150
+ r"(year|month|week|day)s?\s+ago\b",
151
+ re.I,
152
+ )
153
+ _WORD_NUMBERS = {
154
+ "a": 1,
155
+ "one": 1,
156
+ "two": 2,
157
+ "three": 3,
158
+ "four": 4,
159
+ "five": 5,
160
+ "six": 6,
161
+ "seven": 7,
162
+ "eight": 8,
163
+ "nine": 9,
164
+ "ten": 10,
165
+ }
166
+ _UNIT_DAYS = {"year": 365, "month": 30, "week": 7, "day": 1}
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # The declared boundary
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def declare_boundary(
175
+ session: Session,
176
+ *,
177
+ agent_id: str | None,
178
+ systems_of_record: list[str] | None = None,
179
+ coverage_months: int | None = None,
180
+ coverage_start: dt.date | None = None,
181
+ entity_types: list[str] | None = None,
182
+ answerable_types: list[str] | None = None,
183
+ out_of_scope_topics: list[str] | None = None,
184
+ freshness_hours: int | None = None,
185
+ mode: str = "observe",
186
+ ) -> KnowledgeBoundary:
187
+ """P7-1 — declare what this agent can actually answer from.
188
+
189
+ Declared rather than inferred, because the fact that decides the question — what
190
+ the index behind the agent contains — is not visible to the model and is not
191
+ derivable from the corpus without the operator saying so.
192
+ """
193
+ boundary = session.scalar(
194
+ select(KnowledgeBoundary).where(KnowledgeBoundary.agent_id == agent_id)
195
+ )
196
+ if boundary is None:
197
+ boundary = KnowledgeBoundary(agent_id=agent_id)
198
+ session.add(boundary)
199
+ boundary.systems_of_record = systems_of_record or []
200
+ boundary.coverage_months = coverage_months
201
+ boundary.coverage_start = coverage_start
202
+ boundary.entity_types = entity_types or []
203
+ boundary.answerable_types = answerable_types or [FACT, AGGREGATE, PROCEDURE]
204
+ boundary.out_of_scope_topics = out_of_scope_topics or []
205
+ boundary.freshness_hours = freshness_hours
206
+ boundary.mode = mode
207
+ session.flush()
208
+ return boundary
209
+
210
+
211
+ def get_boundary(session: Session, agent_id: str | None) -> KnowledgeBoundary | None:
212
+ if not agent_id:
213
+ return None
214
+ return session.scalar(select(KnowledgeBoundary).where(KnowledgeBoundary.agent_id == agent_id))
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Classification (P7-2)
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def _asks_about_a_resolved_prediction(text: str, now: dt.date) -> bool:
223
+ """A forecast-shaped question that names only years already past isn't soliciting
224
+ a new prediction — it's asking to recall a documented one ("what disease was
225
+ projected to be eradicated by 2018" — 2018 has happened; there's a factual answer
226
+ on record). No mentioned year at all leaves the question genuinely open-ended, so
227
+ this only fires when every year named is already behind `now`. A mix that includes
228
+ any future year (e.g. "in 2020, experts predicted X by 2050") stays a real,
229
+ unresolved prediction — deliberately, since one still-open year is enough to make
230
+ the question a forecast again.
231
+ """
232
+ years = [int(y) for y in _YEAR_RE.findall(text)]
233
+ return bool(years) and all(y <= now.year for y in years)
234
+
235
+
236
+ def question_type(text: str, now: dt.date | None = None) -> str:
237
+ """Classify the question. Deterministic and ordered by consequence.
238
+
239
+ Prediction is checked first: a question that is *both* an aggregate and a forecast
240
+ ("what will total revenue be next year") is a forecast, and treating it as an
241
+ aggregate is precisely the F1.4 failure — a projection returned in the same
242
+ register as a record.
243
+ """
244
+ if any(p.search(text) for p in _PREDICTION_RE):
245
+ if _asks_about_a_resolved_prediction(text, now or dt.date.today()):
246
+ return FACT
247
+ return PREDICTION
248
+ if any(p.search(text) for p in _OPINION_RE):
249
+ return OPINION
250
+ if any(p.search(text) for p in _AGGREGATE_RE):
251
+ return AGGREGATE
252
+ if any(p.search(text) for p in _PROCEDURE_RE):
253
+ return PROCEDURE
254
+ return FACT
255
+
256
+
257
+ def _temporal_scope(
258
+ text: str, boundary: KnowledgeBoundary | None, now: dt.date | None = None
259
+ ) -> dict[str, Any]:
260
+ """F1.2 — does the question reach past what the index holds?
261
+
262
+ Returns `in_scope: True` when no date is mentioned at all. Refusing every undated
263
+ question would be the over-refusal failure in its purest form.
264
+ """
265
+ now = now or dt.date.today()
266
+ if boundary is None:
267
+ return {"in_scope": True, "reason": "no boundary declared"}
268
+
269
+ earliest = None
270
+ if boundary.coverage_start:
271
+ earliest = boundary.coverage_start
272
+ elif boundary.coverage_months:
273
+ earliest = now - dt.timedelta(days=30 * boundary.coverage_months)
274
+ if earliest is None:
275
+ return {"in_scope": True, "reason": "no coverage window declared"}
276
+
277
+ for match in _YEAR_RE.finditer(text):
278
+ year = int(match.group(1))
279
+ if year < earliest.year:
280
+ return {
281
+ "in_scope": False,
282
+ "asked_about": str(year),
283
+ "earliest": earliest.isoformat(),
284
+ "reason": f"asked about {year}; coverage starts {earliest.isoformat()}",
285
+ }
286
+
287
+ match = _RELATIVE_PAST.search(text)
288
+ if match:
289
+ raw = match.group(1)
290
+ count = int(raw) if raw else _WORD_NUMBERS.get(match.group(0).split()[0].lower(), 1)
291
+ days = count * _UNIT_DAYS[match.group(2).lower()]
292
+ asked = now - dt.timedelta(days=days)
293
+ if asked < earliest:
294
+ return {
295
+ "in_scope": False,
296
+ "asked_about": asked.isoformat(),
297
+ "earliest": earliest.isoformat(),
298
+ "reason": (
299
+ f"asked about {asked.isoformat()}; coverage starts {earliest.isoformat()}"
300
+ ),
301
+ }
302
+ return {"in_scope": True, "earliest": earliest.isoformat()}
303
+
304
+
305
+ def _entity_scope(
306
+ text: str, boundary: KnowledgeBoundary | None, known_entities: list[str] | None = None
307
+ ) -> dict[str, Any]:
308
+ """F1.3 — the customer who is not in the CRM.
309
+
310
+ Only decides when the caller supplies the entities that *are* in scope. Guessing
311
+ at entity membership from the prompt would produce refusals on typos and nicknames,
312
+ which is the adoption-killing failure again.
313
+ """
314
+ if boundary is None or known_entities is None:
315
+ return {"in_scope": True, "reason": "no entity scope supplied"}
316
+ lowered = text.lower()
317
+ referenced = [e for e in known_entities if e and e.lower() in lowered]
318
+ # An identifier-shaped token that matches nothing we hold is the signal.
319
+ candidates = re.findall(r"\b(?:[A-Z]{2,}-?\d{2,}|\d{5,})\b", text)
320
+ unknown = [c for c in candidates if not any(c.lower() == e.lower() for e in known_entities)]
321
+ if unknown and not referenced:
322
+ return {
323
+ "in_scope": False,
324
+ "unknown": unknown,
325
+ "reason": (
326
+ f"identifier(s) {', '.join(unknown)} are not in any declared system of record"
327
+ ),
328
+ }
329
+ return {"in_scope": True, "referenced": referenced}
330
+
331
+
332
+ def _topic_scope(text: str, boundary: KnowledgeBoundary | None) -> dict[str, Any]:
333
+ if boundary is None or not boundary.out_of_scope_topics:
334
+ return {"in_scope": True}
335
+ lowered = text.lower()
336
+ hit = [t for t in boundary.out_of_scope_topics if t.lower() in lowered]
337
+ if hit:
338
+ return {
339
+ "in_scope": False,
340
+ "topics": hit,
341
+ "reason": f"declared out of scope: {', '.join(hit)}",
342
+ }
343
+ return {"in_scope": True}
344
+
345
+
346
+ @dataclass
347
+ class AnswerabilityVerdict:
348
+ """Whether to answer, and if not, what to say instead."""
349
+
350
+ answerable: bool
351
+ question_type: str = FACT
352
+ reasons: list[dict[str, Any]] = field(default_factory=list)
353
+ abstention_kind: str | None = None
354
+ response: str = ""
355
+ mode: str = "observe"
356
+
357
+ @property
358
+ def should_abstain(self) -> bool:
359
+ """Enforced only in enforce mode. Observe-first is not a nicety here — an
360
+ over-refusing agent is uninstalled faster than a hallucinating one."""
361
+ return not self.answerable and self.mode == "enforce"
362
+
363
+ def to_json(self) -> dict[str, Any]:
364
+ return {
365
+ "answerable": self.answerable,
366
+ "question_type": self.question_type,
367
+ "abstention_kind": self.abstention_kind,
368
+ "reasons": self.reasons,
369
+ "response": self.response,
370
+ "mode": self.mode,
371
+ "should_abstain": self.should_abstain,
372
+ }
373
+
374
+
375
+ #: P7-3 — templated abstentions. Each says *what* is missing, because "I don't know"
376
+ #: sends the user away while "I hold 24 months and you asked about 2019" sends them to
377
+ #: the right system.
378
+ _TEMPLATES = {
379
+ UNKNOWABLE: (
380
+ "That asks for a projection rather than a recorded fact. I can only report what "
381
+ "is in {systems}, so I don't have an answer for it."
382
+ ),
383
+ OUT_OF_COVERAGE: (
384
+ "I hold data from {earliest} onwards, and that question is about {asked_about}. "
385
+ "Data not available for that period."
386
+ ),
387
+ OUT_OF_SCOPE_ENTITY: (
388
+ "I can't find {unknown} in {systems}. Rather than guess, I'd rather tell you it "
389
+ "isn't there."
390
+ ),
391
+ UNSUPPORTED_TYPE: (
392
+ "That's a {question_type} question, and this agent is set up to answer "
393
+ "{answerable} questions from {systems}."
394
+ ),
395
+ OUT_OF_DOMAIN: "That topic is outside what this agent is set up to cover ({topics}).",
396
+ }
397
+
398
+
399
+ def classify_answerability(
400
+ text: str,
401
+ boundary: KnowledgeBoundary | None,
402
+ *,
403
+ known_entities: list[str] | None = None,
404
+ now: dt.date | None = None,
405
+ ) -> AnswerabilityVerdict:
406
+ """P7-2/P7-3 — decide before generation whether the question can be answered.
407
+
408
+ Four checks, cheapest and most decisive first. All deterministic: an answerability
409
+ check that itself calls a model inherits the failure it is meant to prevent.
410
+ """
411
+ qtype = question_type(text, now)
412
+ systems = ", ".join((boundary.systems_of_record if boundary else []) or ["its sources"])
413
+ mode = boundary.mode if boundary else "observe"
414
+ reasons: list[dict[str, Any]] = []
415
+
416
+ answerable_types = (boundary.answerable_types if boundary else None) or list(QUESTION_TYPES)
417
+ if qtype not in answerable_types:
418
+ kind = UNKNOWABLE if qtype == PREDICTION else UNSUPPORTED_TYPE
419
+ reasons.append({"check": "question_type", "type": qtype, "allowed": answerable_types})
420
+ return AnswerabilityVerdict(
421
+ answerable=False,
422
+ question_type=qtype,
423
+ reasons=reasons,
424
+ abstention_kind=kind,
425
+ mode=mode,
426
+ response=_TEMPLATES[kind].format(
427
+ systems=systems, question_type=qtype, answerable=" or ".join(answerable_types)
428
+ ),
429
+ )
430
+
431
+ temporal = _temporal_scope(text, boundary, now)
432
+ if not temporal["in_scope"]:
433
+ reasons.append({"check": "temporal", **temporal})
434
+ return AnswerabilityVerdict(
435
+ answerable=False,
436
+ question_type=qtype,
437
+ reasons=reasons,
438
+ abstention_kind=OUT_OF_COVERAGE,
439
+ mode=mode,
440
+ response=_TEMPLATES[OUT_OF_COVERAGE].format(**temporal),
441
+ )
442
+
443
+ topic = _topic_scope(text, boundary)
444
+ if not topic["in_scope"]:
445
+ reasons.append({"check": "topic", **topic})
446
+ return AnswerabilityVerdict(
447
+ answerable=False,
448
+ question_type=qtype,
449
+ reasons=reasons,
450
+ abstention_kind=OUT_OF_DOMAIN,
451
+ mode=mode,
452
+ response=_TEMPLATES[OUT_OF_DOMAIN].format(topics=", ".join(topic["topics"])),
453
+ )
454
+
455
+ entity = _entity_scope(text, boundary, known_entities)
456
+ if not entity["in_scope"]:
457
+ reasons.append({"check": "entity", **entity})
458
+ return AnswerabilityVerdict(
459
+ answerable=False,
460
+ question_type=qtype,
461
+ reasons=reasons,
462
+ abstention_kind=OUT_OF_SCOPE_ENTITY,
463
+ mode=mode,
464
+ response=_TEMPLATES[OUT_OF_SCOPE_ENTITY].format(
465
+ unknown=", ".join(entity["unknown"]), systems=systems
466
+ ),
467
+ )
468
+
469
+ return AnswerabilityVerdict(answerable=True, question_type=qtype, mode=mode)
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # Post-flight (P7-4, P7-5, P7-7)
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def verify_boundary(
478
+ answer: str, verdict: AnswerabilityVerdict, boundary: KnowledgeBoundary | None
479
+ ) -> list[dict[str, Any]]:
480
+ """P7-4 — did the answer exceed the boundary the question passed?
481
+
482
+ A question can be in scope and the answer still stray, most commonly by drifting
483
+ from record into projection halfway through.
484
+ """
485
+ breaches: list[dict[str, Any]] = []
486
+ allowed = (boundary.answerable_types if boundary else None) or list(QUESTION_TYPES)
487
+ if PREDICTION not in allowed:
488
+ hits = [p.pattern for p in _PREDICTION_RE if p.search(answer)]
489
+ if hits:
490
+ breaches.append(
491
+ {
492
+ "breach": "prediction_in_answer",
493
+ "detail": "the answer contains forecast language for an agent declared "
494
+ "record-only",
495
+ "patterns": hits[:3],
496
+ }
497
+ )
498
+ if boundary and boundary.coverage_start:
499
+ for match in _YEAR_RE.finditer(answer):
500
+ if int(match.group(1)) < boundary.coverage_start.year:
501
+ breaches.append(
502
+ {
503
+ "breach": "out_of_coverage_in_answer",
504
+ "detail": f"the answer cites {match.group(1)}, before coverage starts "
505
+ f"{boundary.coverage_start.isoformat()}",
506
+ }
507
+ )
508
+ break
509
+ return breaches
510
+
511
+
512
+ def completeness_signal(
513
+ answer: str, *, retrieved: int | None = None, available: int | None = None
514
+ ) -> dict[str, Any]:
515
+ """F1.6 — retrieved 3 of 50 and answered as though exhaustive.
516
+
517
+ The signal is the *absence* of a caveat when the counts say one is warranted, not
518
+ the presence of hedging.
519
+ """
520
+ declares = bool(any(p.search(answer) for p in _COMPLETENESS_RE))
521
+ if retrieved is None or available is None or available <= 0:
522
+ return {"declared": declares, "known": False}
523
+ ratio = retrieved / available
524
+ partial = ratio < 0.8
525
+ return {
526
+ "declared": declares,
527
+ "known": True,
528
+ "retrieved": retrieved,
529
+ "available": available,
530
+ "ratio": round(ratio, 3),
531
+ "partial": partial,
532
+ # The failure is answering as if exhaustive, so it fires only when the answer
533
+ # is partial *and* silent about it.
534
+ "misleading": partial and not declares,
535
+ "suggested_caveat": (
536
+ f"Based on {retrieved} of {available} matching records." if partial else ""
537
+ ),
538
+ }
539
+
540
+
541
+ def is_refusal(answer: str) -> bool:
542
+ return any(p.search(answer or "") for p in _REFUSAL_RE)
543
+
544
+
545
+ def detect_over_refusal(
546
+ session: Session,
547
+ *,
548
+ answer: str,
549
+ verdict: AnswerabilityVerdict,
550
+ agent_id: str | None = None,
551
+ trace_id: str | None = None,
552
+ raise_finding: bool = True,
553
+ ) -> dict[str, Any] | None:
554
+ """**P7-6 — the counter-metric, and the reason this pillar is safe to ship.**
555
+
556
+ An agent that refuses what it could have answered is uninstalled faster than one
557
+ that occasionally invents. So a refusal on a question our own boundary says was
558
+ answerable is a *finding against us*, never a block.
559
+ """
560
+ if not verdict.answerable or not is_refusal(answer):
561
+ return None
562
+ record = {
563
+ "reason": "the agent refused a question its declared boundary says is answerable",
564
+ "question_type": verdict.question_type,
565
+ "trace_id": trace_id,
566
+ "excerpt": answer[:200],
567
+ }
568
+ if raise_finding:
569
+ # One finding per (agent, question type): refusing answerable questions of a
570
+ # kind is one behaviour with a count, and the evidence keeps the latest case.
571
+ _raise_finding(
572
+ session,
573
+ type="over_refusal",
574
+ severity="medium",
575
+ title="Agent refused an answerable question",
576
+ subject_type="agent",
577
+ subject_id=agent_id,
578
+ evidence=record,
579
+ control_keys=["NOM-RTG-11"],
580
+ fingerprint_parts=(verdict.question_type,),
581
+ )
582
+ return record
583
+
584
+
585
+ def abstention_report(session: Session, *, days: int = 7) -> dict[str, Any]:
586
+ """Both rates side by side. Reporting abstention without over-refusal would let a
587
+ team optimise one into the other and call it progress."""
588
+ since = utcnow() - dt.timedelta(days=days)
589
+ over = list(
590
+ session.scalars(
591
+ select(Finding).where(Finding.type == "over_refusal", Finding.created_at >= since)
592
+ )
593
+ )
594
+ boundaries = list(session.scalars(select(KnowledgeBoundary)))
595
+ return {
596
+ "window_days": days,
597
+ "boundaries_declared": len(boundaries),
598
+ "enforcing": sum(1 for b in boundaries if b.mode == "enforce"),
599
+ "over_refusals": len(over),
600
+ "agents_without_boundary": [
601
+ a.slug
602
+ for a in session.scalars(select(Agent))
603
+ if not any(b.agent_id == a.id for b in boundaries)
604
+ ],
605
+ }