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.
- agentfox/__init__.py +45 -0
- agentfox/agent_loop.py +222 -0
- agentfox/agent_messaging.py +90 -0
- agentfox/answerability.py +605 -0
- agentfox/arbitration.py +309 -0
- agentfox/attribution.py +538 -0
- agentfox/audit/__init__.py +47 -0
- agentfox/audit/chain.py +420 -0
- agentfox/audit/evidence.py +560 -0
- agentfox/audit/otel.py +160 -0
- agentfox/audit/siem.py +233 -0
- agentfox/audit/trace.py +337 -0
- agentfox/autoguard.py +1261 -0
- agentfox/availability.py +702 -0
- agentfox/business/__init__.py +43 -0
- agentfox/business/catalogue.py +583 -0
- agentfox/business/compile.py +1010 -0
- agentfox/business/graph.py +314 -0
- agentfox/business/ladder.py +396 -0
- agentfox/business/store.py +159 -0
- agentfox/cli/__init__.py +5 -0
- agentfox/cli/_style.py +40 -0
- agentfox/cli/auth_cli.py +222 -0
- agentfox/cli/business_cli.py +403 -0
- agentfox/cli/capability_cli.py +475 -0
- agentfox/cli/controls_cli.py +516 -0
- agentfox/cli/demo.py +662 -0
- agentfox/cli/main.py +2065 -0
- agentfox/cli/mcp_cli.py +39 -0
- agentfox/cli/onboarding.py +682 -0
- agentfox/cli/quickscan.py +187 -0
- agentfox/cli/submit.py +120 -0
- agentfox/commitments.py +496 -0
- agentfox/compliance/__init__.py +43 -0
- agentfox/compliance/catalog.py +250 -0
- agentfox/compliance/risk.py +380 -0
- agentfox/compliance/status.py +524 -0
- agentfox/compliance_data/controls.yaml +944 -0
- agentfox/compliance_data/obligations.yaml +91 -0
- agentfox/config.py +502 -0
- agentfox/context_integrity.py +609 -0
- agentfox/control_flow.py +164 -0
- agentfox/crypto.py +45 -0
- agentfox/data_access.py +384 -0
- agentfox/db.py +233 -0
- agentfox/discovery.py +847 -0
- agentfox/discovery_openapi.py +113 -0
- agentfox/effects.py +461 -0
- agentfox/enforcement.py +3161 -0
- agentfox/entitlement.py +464 -0
- agentfox/escalation.py +826 -0
- agentfox/evaluation/__init__.py +98 -0
- agentfox/evaluation/adapters.py +189 -0
- agentfox/evaluation/adaptive.py +1098 -0
- agentfox/evaluation/drift.py +303 -0
- agentfox/evaluation/gating.py +309 -0
- agentfox/evaluation/model_groundedness.py +127 -0
- agentfox/evaluation/ragas_adapter.py +241 -0
- agentfox/evaluation/redteam.py +1280 -0
- agentfox/evaluation/runner.py +366 -0
- agentfox/evaluation/scorers.py +350 -0
- agentfox/evaluation/silent_failure.py +438 -0
- agentfox/finding.py +40 -0
- agentfox/findings.py +377 -0
- agentfox/gateway/__init__.py +5 -0
- agentfox/gateway/app.py +555 -0
- agentfox/gateway/auth.py +307 -0
- agentfox/gateway/deps.py +126 -0
- agentfox/gateway/playground_sessions.py +414 -0
- agentfox/gateway/routes/__init__.py +1 -0
- agentfox/gateway/routes/answerability.py +165 -0
- agentfox/gateway/routes/discovery.py +99 -0
- agentfox/gateway/routes/entitlement.py +213 -0
- agentfox/gateway/routes/escalation.py +326 -0
- agentfox/gateway/routes/evaluation.py +798 -0
- agentfox/gateway/routes/governance.py +675 -0
- agentfox/gateway/routes/inline.py +964 -0
- agentfox/gateway/routes/integrations.py +608 -0
- agentfox/gateway/routes/jobs.py +145 -0
- agentfox/gateway/routes/memory.py +101 -0
- agentfox/gateway/routes/messaging.py +82 -0
- agentfox/gateway/routes/onboarding.py +293 -0
- agentfox/gateway/routes/playground.py +272 -0
- agentfox/gateway/routes/playground_deps.py +34 -0
- agentfox/gateway/routes/policy.py +512 -0
- agentfox/gateway/routes/proposals.py +158 -0
- agentfox/gateway/routes/provenance.py +345 -0
- agentfox/gateway/routes/registry.py +1034 -0
- agentfox/gateway/routes/tuning.py +263 -0
- agentfox/gateway/routes/waitlist.py +183 -0
- agentfox/gateway/verdicts.py +70 -0
- agentfox/guardrails/__init__.py +107 -0
- agentfox/guardrails/actions.py +865 -0
- agentfox/guardrails/adapters/__init__.py +0 -0
- agentfox/guardrails/adapters/classifiers.py +362 -0
- agentfox/guardrails/adapters/embeddings.py +158 -0
- agentfox/guardrails/adapters/hub.py +356 -0
- agentfox/guardrails/adapters/presidio.py +121 -0
- agentfox/guardrails/adapters/rails.py +119 -0
- agentfox/guardrails/base.py +258 -0
- agentfox/guardrails/composition.py +150 -0
- agentfox/guardrails/data/injection_corpus.json +239 -0
- agentfox/guardrails/detectors/__init__.py +0 -0
- agentfox/guardrails/detectors/injection.py +627 -0
- agentfox/guardrails/detectors/pii.py +162 -0
- agentfox/guardrails/detectors/safety.py +96 -0
- agentfox/guardrails/detectors/schema.py +145 -0
- agentfox/guardrails/detectors/secrets.py +128 -0
- agentfox/guardrails/normalize.py +756 -0
- agentfox/guardrails/pipeline.py +262 -0
- agentfox/guardrails/taint.py +183 -0
- agentfox/guardrails/tuning.py +907 -0
- agentfox/identity/__init__.py +41 -0
- agentfox/identity/service.py +620 -0
- agentfox/ids.py +57 -0
- agentfox/improvement/__init__.py +7 -0
- agentfox/improvement/appliers.py +445 -0
- agentfox/improvement/contract.py +121 -0
- agentfox/improvement/loops.py +235 -0
- agentfox/improvement/proposals.py +781 -0
- agentfox/integrations/__init__.py +39 -0
- agentfox/integrations/correlation.py +360 -0
- agentfox/integrations/fastapi.py +235 -0
- agentfox/integrations/langgraph.py +393 -0
- agentfox/integrations/mcp.py +440 -0
- agentfox/integrations/prometheus.py +226 -0
- agentfox/integrity.py +1024 -0
- agentfox/job_handlers.py +278 -0
- agentfox/jobs.py +178 -0
- agentfox/jobs_db.py +312 -0
- agentfox/mcp_server.py +863 -0
- agentfox/models.py +1802 -0
- agentfox/operator_log.py +235 -0
- agentfox/policies_data/baseline.yaml +165 -0
- agentfox/policies_data/eu-ai-act-high-risk.yaml +113 -0
- agentfox/policies_data/tool-containment.yaml +197 -0
- agentfox/policy/__init__.py +101 -0
- agentfox/policy/canary.py +401 -0
- agentfox/policy/engine.py +315 -0
- agentfox/policy/hierarchy.py +354 -0
- agentfox/policy/model.py +262 -0
- agentfox/policy/opa.py +248 -0
- agentfox/policy/simulate.py +185 -0
- agentfox/policy/store.py +287 -0
- agentfox/provenance.py +560 -0
- agentfox/providers/__init__.py +43 -0
- agentfox/providers/base.py +192 -0
- agentfox/providers/echo.py +146 -0
- agentfox/providers/enterprise.py +366 -0
- agentfox/providers/remote.py +299 -0
- agentfox/register.py +281 -0
- agentfox/registry/__init__.py +43 -0
- agentfox/registry/control.py +161 -0
- agentfox/registry/service.py +838 -0
- agentfox/reliability.py +343 -0
- agentfox/scheduler.py +224 -0
- agentfox/sdk/__init__.py +481 -0
- agentfox/seed.py +660 -0
- agentfox/session_scan.py +124 -0
- agentfox/sycophancy.py +181 -0
- agentfox/system_log.py +132 -0
- agentfox/tenancy.py +298 -0
- agentfox/tool_contract.py +264 -0
- agentfox/trajectory.py +457 -0
- agentfox/webhooks.py +390 -0
- agentfox-0.3.1.dist-info/METADATA +542 -0
- agentfox-0.3.1.dist-info/RECORD +171 -0
- agentfox-0.3.1.dist-info/WHEEL +4 -0
- agentfox-0.3.1.dist-info/entry_points.txt +3 -0
- agentfox-0.3.1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
}
|