Skip to content

Commit 27d6ff6

Browse files
committed
feat(guardrails): label-anchored PHI redaction, custom patterns, strict mode, phi preset
1 parent 41d0e2d commit 27d6ff6

8 files changed

Lines changed: 620 additions & 43 deletions

File tree

effgen/core/agent.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ class AgentConfig:
164164
system_prompt: str = "You are a helpful AI assistant."
165165
max_iterations: int = 10
166166
temperature: float = 0.7
167+
# Default output-token budget for every run(). None lets the model pick a
168+
# size-aware default; run(max_tokens=...) overrides it for a single call.
169+
max_tokens: int | None = None
167170
enable_sub_agents: bool = True
168171
enable_memory: bool = True
169172
enable_streaming: bool = False
@@ -915,6 +918,13 @@ def run(self,
915918
output_model: Pydantic BaseModel class — when provided, output is
916919
validated and the parsed instance is stored in
917920
``response.metadata["parsed"]``.
921+
922+
Reasoning models and deeply nested schemas need a generous output
923+
budget: the model spends tokens on internal reasoning and on the
924+
JSON structure before it fills any values. Set ``max_tokens``
925+
accordingly (``run(..., max_tokens=8192)`` for a reasoning model).
926+
When an extraction validates but every field is empty,
927+
``response.metadata["structured_output_empty"]`` is set to True.
918928
inputs: Optional list of multimodal content parts created by
919929
``image_from``, ``audio_from``, or ``video_from``. When present,
920930
the agent sends a structured Message directly to the model.
@@ -963,6 +973,10 @@ def run(self,
963973
# every run reports its own cost_usd + token counts (see _finalize_cost).
964974
self._run_cost_accum = {}
965975
debug = kwargs.pop("debug", False)
976+
# A max_tokens set on the config is the default output budget for every
977+
# run(); an explicit run(max_tokens=...) still overrides it per call.
978+
if "max_tokens" not in kwargs and self.config.max_tokens is not None:
979+
kwargs["max_tokens"] = self.config.max_tokens
966980
run_id = generate_run_id()
967981
# Capture checkpoint args here so the outer run() can use
968982
# them for the final-checkpoint write even after _run_single_agent
@@ -975,6 +989,7 @@ def run(self,
975989
prom_metrics.active_agents.inc(labels=labels)
976990

977991
# Pre-run input guardrail check
992+
input_redaction: dict[str, Any] | None = None
978993
if self._guardrail_chain is not None:
979994
from ..guardrails.base import GuardrailPosition
980995
gr = self._guardrail_chain.check(task, position=GuardrailPosition.INPUT)
@@ -988,6 +1003,13 @@ def run(self,
9881003
)
9891004
if gr.modified_content is not None:
9901005
task = gr.modified_content
1006+
# Record what the input redaction removed so a run is auditable
1007+
# from its response (e.g. a note de-identified before a cloud call).
1008+
pii_types = gr.metadata.get("pii_types")
1009+
if pii_types:
1010+
input_redaction = {"types": pii_types}
1011+
if gr.metadata.get("pii_counts"):
1012+
input_redaction["counts"] = gr.metadata["pii_counts"]
9911013

9921014
# Resolve structured output schema. Accept either a JSON-Schema dict or
9931015
# a Pydantic model class for `output_schema` (and the config default),
@@ -1080,6 +1102,8 @@ def run(self,
10801102
response.execution_trace = self.execution_tracker.get_trace()
10811103
response.execution_tree = self.execution_tracker.generate_execution_tree()
10821104
response.metadata["run_id"] = run_id
1105+
if input_redaction is not None:
1106+
response.metadata["input_redaction"] = input_redaction
10831107
# Surface this run's cost + token usage on the result so callers
10841108
# can budget per call without a side channel.
10851109
self._finalize_cost_metadata(response)

effgen/core/agent_generation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def _apply_structured_output(
9797
)
9898
else:
9999
response.metadata["parsed"] = parsed
100+
if self._structured_all_empty(parsed):
101+
response.metadata["structured_output_empty"] = True
100102
return response
101103
except (json.JSONDecodeError, TypeError):
102104
pass
@@ -137,6 +139,8 @@ def _apply_structured_output(
137139
)
138140
else:
139141
response.metadata["parsed"] = outcome.parsed
142+
if self._structured_all_empty(outcome.parsed):
143+
response.metadata["structured_output_empty"] = True
140144
else:
141145
# Keep the raw answer, mark the run unsuccessful, and explain why —
142146
# consistent with the framework's no-silent-failure rule.
@@ -157,6 +161,31 @@ def _apply_structured_output(
157161

158162
return response
159163

164+
@staticmethod
165+
def _structured_all_empty(data: Any) -> bool:
166+
"""Return True when a schema-valid object carries no extracted values.
167+
168+
A reasoning model or a nested schema under a tight ``max_tokens`` can
169+
return an object that validates but has every field empty
170+
(``{"diagnosis": "", "medications": []}``). Flagging it lets a caller
171+
distinguish "nothing to extract" from "budget ran out before the model
172+
filled the fields" — a larger ``max_tokens`` usually resolves the latter.
173+
"""
174+
def _empty(v: Any) -> bool:
175+
if v is None or v == "" or v == [] or v == {}:
176+
return True
177+
if isinstance(v, dict):
178+
return all(_empty(x) for x in v.values())
179+
if isinstance(v, list | tuple):
180+
return all(_empty(x) for x in v)
181+
return False
182+
183+
if isinstance(data, dict):
184+
return len(data) > 0 and all(_empty(v) for v in data.values())
185+
if isinstance(data, list | tuple):
186+
return len(data) == 0
187+
return False
188+
160189
@staticmethod
161190
def _parse_with_pydantic(model_class: Any, data: Any) -> Any:
162191
"""Parse data into a Pydantic model instance.

effgen/domains/presets.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,16 @@ def FinanceDomain(**overrides) -> Domain: # noqa: N802
144144

145145

146146
def HealthDomain(**overrides) -> Domain: # noqa: N802
147-
"""Medical, wellness, and nutrition domain."""
147+
"""Medical, wellness, and nutrition domain.
148+
149+
This is a consumer health-information assistant. It uses the ``standard``
150+
guardrails, which redact well-formed and labeled identifiers but do not
151+
guarantee removal of every free-text identifier. It is not a de-identification
152+
tool. To strip personal identifiers before sending records to a model, pass
153+
``guardrails="phi"`` (or ``guardrails=phi_guardrails(strict=True)`` to fail
154+
closed), and extend ``PIIGuardrail`` with ``custom_patterns``/``custom_terms``
155+
for site-specific identifiers.
156+
"""
148157
defaults = {
149158
"name": "health",
150159
"description": "Medical science, wellness, nutrition, and public health.",

effgen/guardrails/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
from .presets import (
1414
MINIMAL,
1515
NONE,
16+
PHI,
1617
STANDARD,
1718
STRICT,
1819
get_guardrail_preset,
1920
minimal_guardrails,
2021
no_guardrails,
22+
phi_guardrails,
2123
standard_guardrails,
2224
strict_guardrails,
2325
)
@@ -43,11 +45,13 @@
4345
# Presets
4446
"STRICT",
4547
"STANDARD",
48+
"PHI",
4649
"MINIMAL",
4750
"NONE",
4851
"get_guardrail_preset",
4952
"strict_guardrails",
5053
"standard_guardrails",
54+
"phi_guardrails",
5155
"minimal_guardrails",
5256
"no_guardrails",
5357
]

effgen/guardrails/base.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ def check(
119119
start = time.monotonic()
120120
current_content = content
121121
results: list[GuardrailResult] = []
122+
# Aggregate redaction detail from any modifying guardrail (e.g.
123+
# PIIGuardrail in redact mode) so a caller can audit what a chain removed
124+
# without reaching into each individual result.
125+
pii_types: list[str] = []
126+
pii_counts: dict[str, int] = {}
122127

123128
for guardrail in self.guardrails:
124129
if not guardrail.enabled:
@@ -140,18 +145,29 @@ def check(
140145
result.metadata["chain_elapsed_ms"] = elapsed * 1000
141146
return result
142147

148+
for t in result.metadata.get("pii_types", []):
149+
if t not in pii_types:
150+
pii_types.append(t)
151+
for t, n in (result.metadata.get("pii_counts") or {}).items():
152+
pii_counts[t] = pii_counts.get(t, 0) + n
153+
143154
# If guardrail modified content, pass modified version to next
144155
if result.modified_content is not None:
145156
current_content = result.modified_content
146157

147158
elapsed = time.monotonic() - start
159+
metadata: dict[str, Any] = {
160+
"checks_run": len(results),
161+
"chain_elapsed_ms": elapsed * 1000,
162+
}
163+
if pii_types:
164+
metadata["pii_types"] = pii_types
165+
if pii_counts:
166+
metadata["pii_counts"] = pii_counts
148167
return GuardrailResult(
149168
passed=True,
150169
reason="",
151170
modified_content=current_content if current_content != content else None,
152171
guardrail_name="GuardrailChain",
153-
metadata={
154-
"checks_run": len(results),
155-
"chain_elapsed_ms": elapsed * 1000,
156-
},
172+
metadata=metadata,
157173
)

0 commit comments

Comments
 (0)