refactor(guardrails): collapse single-field WindowConfig into an overlap param

WindowConfig held only `overlap` after `text_segment_count` was removed, so the
frozen dataclass (and the shared-instance default that existed only to keep a
call out of the argument default) were wrapping one int. Replace it with an
`overlap: int = CHUNK_OVERLAP_CHARS` parameter on `evaluate_segments` and drop
the class and the constant. Callers already relied on the default; the tests now
pass `overlap=` directly. No behavior change.
This commit is contained in:
lior-k 2026-07-28 13:36:12 +03:00
parent 0605a19709
commit 90ea9a7160
No known key found for this signature in database
2 changed files with 10 additions and 29 deletions

View file

@ -38,26 +38,6 @@ class SegmentVerdict:
masked_chunks: list[tuple[str, str]] | None = None
@dataclass(frozen=True)
class WindowConfig:
"""Tuning for the chunk-seam overlap.
``overlap`` sizes the read-only prefix each chunk carries from the previous
owned region (see ``_overlap_chunks``). There are no cross-segment windows:
on the request side message parts are concatenated into one joined document
before scanning (so their junctions are interior chunk seams); on the
response side each segment is an independent choice or tool-call arg that the
model never concatenates.
"""
overlap: int = CHUNK_OVERLAP_CHARS
# Shared default so the ``evaluate_segments`` signature has a plain-name default
# (no call in the argument default); safe to share since ``WindowConfig`` is frozen.
_DEFAULT_WINDOW_CONFIG = WindowConfig()
def _split_text(text: str, max_chars: int) -> list[str]:
"""Split ``text`` into <= ``max_chars`` chunks with ``"".join(chunks) == text``.
@ -154,7 +134,7 @@ async def evaluate_segments(
evaluate: Callable[[str], Awaitable[Any]],
max_chars: int = MAX_PROMPT_CHARS,
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
windows: WindowConfig = _DEFAULT_WINDOW_CONFIG,
overlap: int = CHUNK_OVERLAP_CHARS,
) -> list[SegmentVerdict]:
"""Evaluate every segment (chunked) in parallel; return one verdict per segment.
@ -169,7 +149,9 @@ async def evaluate_segments(
The request side passes a single joined document here (one segment) so the
common case is one call; the response side passes one segment per choice /
tool-call arg. There is no cross-segment window (see ``WindowConfig``).
tool-call arg. There is no cross-segment overlap: message parts are already
joined into one document on the request side, and response choices are
independent.
"""
semaphore = asyncio.Semaphore(max_concurrency)
@ -178,7 +160,7 @@ async def evaluate_segments(
return await evaluate(text)
# Keep each chunk's scan input (prefix + owned) within the prompt limit.
ov = min(windows.overlap, max_chars // 2)
ov = min(overlap, max_chars // 2)
seg_chunks = [_overlap_chunks(s, max_chars, ov) for s in segments]
index: list[tuple[int, int]] = []

View file

@ -8,7 +8,6 @@ import pytest
from litellm.proxy.guardrails.guardrail_hooks.alice_wonderfence.chunked_evaluation import (
MAX_PROMPT_CHARS,
SegmentVerdict,
WindowConfig,
_split_text,
evaluate_segments,
)
@ -99,7 +98,7 @@ async def test_mask_rejoins_masked_owned_regions_into_full_segment():
chunks = _split_text(segment, 20)
assert len(chunks) > 1
verdicts = await evaluate_segments([segment], evaluate, max_chars=20, windows=WindowConfig(overlap=0))
verdicts = await evaluate_segments([segment], evaluate, max_chars=20, overlap=0)
assert verdicts[0].action == "MASK"
assert verdicts[0].masked_text == segment.replace("SECRET", "[X]")
assert verdicts[0].masked_chunks is not None
@ -115,7 +114,7 @@ async def test_unmasked_chunks_keep_original_text_on_rejoin():
async def evaluate(text):
return _result("MASK", action_text=text.replace("w0", "[X]")) if "w0 " in text else _result("")
verdicts = await evaluate_segments([segment], evaluate, max_chars=20, windows=WindowConfig(overlap=0))
verdicts = await evaluate_segments([segment], evaluate, max_chars=20, overlap=0)
assert verdicts[0].action == "MASK"
assert verdicts[0].masked_text == segment.replace("w0", "[X]", 1)
@ -191,7 +190,7 @@ async def test_block_phrase_split_across_chunk_seam_is_detected():
async def evaluate(text):
return _result("BLOCK" if "BLOCK ME" in text else "")
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, windows=WindowConfig(overlap=6))
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, overlap=6)
assert verdicts[0].action == "BLOCK"
@ -204,7 +203,7 @@ async def test_no_overlap_lets_seam_phrase_evade():
async def evaluate(text):
return _result("BLOCK" if "BLOCK ME" in text else "")
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, windows=WindowConfig(overlap=0))
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, overlap=0)
assert verdicts[0].action == ""
@ -235,7 +234,7 @@ async def test_mask_straddling_a_seam_fails_closed_as_block():
# it; the redaction lands in the prefix bytes -> fail closed.
return _result("MASK", action_text=text.replace("SECRET HERE", "[X]")) if "SECRET HERE" in text else _result("")
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, windows=WindowConfig(overlap=6))
verdicts = await evaluate_segments([segment], evaluate, max_chars=12, overlap=6)
assert verdicts[0].action == "BLOCK"