mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 22:14:12 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""Streaming-safe output guarding.
|
|
|
|
Once a token reaches ``_emit`` it is on the wire and journalled — it cannot be
|
|
recalled. So output controls run *before* release, using the cheapest strategy
|
|
that still catches a match straddling a chunk boundary:
|
|
|
|
* Deterministic checks (regex-shaped, local): hold the last ``lookback``
|
|
characters, run detection over ``held + new``, release only the safe prefix.
|
|
Detecting over the whole buffer rather than the about-to-emit prefix is what
|
|
makes this correct — a truncated prefix would fail to match a
|
|
boundary-anchored pattern and the partial hit would leak.
|
|
* Remote checks (LLM judge, vendor API): accumulate to a sentence boundary and
|
|
evaluate whole segments, because per-token calls are unaffordable and
|
|
sentence granularity is the level at which such verdicts are meaningful.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional
|
|
|
|
from docsgpt.guardrails.engine import GuardrailEngine
|
|
from docsgpt.guardrails.guardrail_creator import GuardrailCreator
|
|
from docsgpt.guardrails.types import (
|
|
Action,
|
|
Span,
|
|
Stage,
|
|
StageDecision,
|
|
apply_spans,
|
|
)
|
|
|
|
DEFAULT_LOOKBACK = 128
|
|
DEFAULT_SEGMENT_CHARS = 400
|
|
# Ceiling on the withhold window, and the hard release point that keeps a
|
|
# stream with no sentence boundary from stalling. The ceiling sits well below
|
|
# the stall point so the force path always has something to release.
|
|
MAX_WINDOW_CHARS = 8192
|
|
MAX_HOLD_CHARS = 16000
|
|
|
|
_SENTENCE_END = re.compile(r"[.!?\n](?=\s|$)")
|
|
|
|
|
|
def _last_boundary(text: str) -> int:
|
|
"""Index just past the last sentence terminator, or 0 if there is none."""
|
|
last = 0
|
|
for match in _SENTENCE_END.finditer(text):
|
|
last = match.end()
|
|
return last
|
|
|
|
|
|
@dataclass
|
|
class StreamChunk:
|
|
"""One step of the guarded stream."""
|
|
|
|
emit: str = ""
|
|
blocked: bool = False
|
|
block_message: Optional[str] = None
|
|
decisions: List[StageDecision] = field(default_factory=list)
|
|
|
|
|
|
class StreamingOutputGuard:
|
|
"""Buffers model output so output-stage controls can act before release."""
|
|
|
|
def __init__(
|
|
self,
|
|
engine: GuardrailEngine,
|
|
lookback: Optional[int] = None,
|
|
segment_chars: int = DEFAULT_SEGMENT_CHARS,
|
|
):
|
|
self.engine = engine
|
|
self.segment_chars = max(1, segment_chars)
|
|
self._held = ""
|
|
self._blocked = False
|
|
self._block_message: Optional[str] = None
|
|
self.decisions: List[StageDecision] = []
|
|
|
|
controls = engine.config.controls_for(Stage.OUTPUT)
|
|
self._incremental = [c for c in controls if not self._complete_only(c)]
|
|
self._deferred = [c for c in controls if self._complete_only(c)]
|
|
self._has_remote = any(self._attr(c, "remote", False) for c in self._incremental)
|
|
# monitor_only degrades every action to FLAG, so no verdict reached
|
|
# here can change a byte of the answer. Withholding output to wait for
|
|
# one would buy nothing and cost the user a frozen stream — with a
|
|
# remote control, the whole answer when it is shorter than a segment.
|
|
# Pass the stream through untouched and scan the finished answer once,
|
|
# purely for the audit trail.
|
|
self._monitor_only = engine.config.mode != "scan_all"
|
|
# The window must cover the longest match any active check can report,
|
|
# or that check silently stops working the moment output is streamed.
|
|
self.lookback = (
|
|
max(0, lookback) if lookback is not None else self._required_window()
|
|
)
|
|
# Complete-text checks need the finished answer, not the tail; so does
|
|
# the monitor_only end-of-stream scan.
|
|
self._full = "" if (self._deferred or self._monitor_only) else None
|
|
|
|
@staticmethod
|
|
def _attr(control, name: str, default):
|
|
try:
|
|
return getattr(GuardrailCreator.get(control.check), name, default)
|
|
except Exception:
|
|
return default
|
|
|
|
@classmethod
|
|
def _complete_only(cls, control) -> bool:
|
|
return bool(cls._attr(control, "requires_complete_text", False))
|
|
|
|
def _required_window(self) -> int:
|
|
window = DEFAULT_LOOKBACK
|
|
for control in self._incremental:
|
|
try:
|
|
check_cls = GuardrailCreator.get(control.check)
|
|
window = max(window, check_cls.window_for(control.settings))
|
|
except Exception:
|
|
continue
|
|
return min(window, MAX_WINDOW_CHARS)
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
return self.engine.has_stage(Stage.OUTPUT)
|
|
|
|
@property
|
|
def blocked(self) -> bool:
|
|
return self._blocked
|
|
|
|
@property
|
|
def block_message(self) -> Optional[str]:
|
|
return self._block_message
|
|
|
|
@property
|
|
def pending(self) -> str:
|
|
"""Text buffered but not yet released."""
|
|
return self._held
|
|
|
|
def feed(self, text: str) -> StreamChunk:
|
|
"""Absorb ``text``; return whatever is now safe to emit."""
|
|
if self._blocked:
|
|
return StreamChunk(blocked=True, block_message=self._block_message)
|
|
if not text:
|
|
return StreamChunk()
|
|
if not self.active:
|
|
return StreamChunk(emit=text)
|
|
if self._monitor_only:
|
|
self._full += text
|
|
return StreamChunk(emit=text)
|
|
|
|
combined = self._held + text
|
|
emit_end = self._release_point(combined)
|
|
# Nothing to release yet and the buffer is still small: keep waiting
|
|
# rather than paying for a scan of text that isn't going out.
|
|
if emit_end == 0 and len(combined) <= MAX_HOLD_CHARS:
|
|
self._held = combined
|
|
return StreamChunk()
|
|
|
|
return self._scan_and_split(combined, force=len(combined) > MAX_HOLD_CHARS)
|
|
|
|
def flush(self) -> StreamChunk:
|
|
"""Release the tail at end of stream, after a final scan."""
|
|
if self._blocked:
|
|
return StreamChunk(blocked=True, block_message=self._block_message)
|
|
if not self.active:
|
|
tail, self._held = self._held, ""
|
|
return StreamChunk(emit=tail)
|
|
if self._monitor_only:
|
|
return self._monitor_scan()
|
|
step = (
|
|
self._scan_and_split(self._held, force=True, final=True)
|
|
if self._held
|
|
else StreamChunk()
|
|
)
|
|
if step.blocked:
|
|
return step
|
|
return self._run_deferred(step)
|
|
|
|
def _monitor_scan(self) -> StreamChunk:
|
|
"""Scan the finished answer once, for the journal only.
|
|
|
|
One scan of the whole answer rather than the per-chunk sweep the
|
|
enforcing path runs: the verdict cannot act, so the only thing that
|
|
matters is that it is recorded.
|
|
"""
|
|
controls = self._incremental + self._deferred
|
|
if not controls or not self._full:
|
|
return StreamChunk()
|
|
decision = self.engine.evaluate(self._full, Stage.OUTPUT, controls=controls)
|
|
self.decisions.append(decision)
|
|
return StreamChunk(decisions=[decision])
|
|
|
|
def _run_deferred(self, step: StreamChunk) -> StreamChunk:
|
|
"""Run complete-text checks over the finished answer.
|
|
|
|
These verdicts can only arrive after the answer is fully streamed, so a
|
|
block here is a retraction, not a prevention — the caller emits the
|
|
retract signal and rewrites the persisted message.
|
|
"""
|
|
if not self._deferred or self._full is None:
|
|
return step
|
|
decision = self.engine.evaluate(
|
|
self._full, Stage.OUTPUT, controls=self._deferred
|
|
)
|
|
self.decisions.append(decision)
|
|
step.decisions.append(decision)
|
|
if decision.blocked:
|
|
self._blocked = True
|
|
self._block_message = decision.block_message
|
|
return StreamChunk(
|
|
emit=step.emit,
|
|
blocked=True,
|
|
block_message=decision.block_message,
|
|
decisions=step.decisions,
|
|
)
|
|
return step
|
|
|
|
def _release_point(self, combined: str) -> int:
|
|
"""How much of ``combined`` is eligible for release this step."""
|
|
lookback_point = max(0, len(combined) - self.lookback)
|
|
if not self._has_remote:
|
|
return lookback_point
|
|
boundary = _last_boundary(combined)
|
|
if boundary < self.segment_chars:
|
|
return 0
|
|
# Never release past the lookback point just because a sentence ended:
|
|
# the deterministic checks still need their overlap window, and losing
|
|
# it would let a match straddling two segments through.
|
|
return min(boundary, lookback_point)
|
|
|
|
def _scan_and_split(
|
|
self, combined: str, force: bool = False, final: bool = False
|
|
) -> StreamChunk:
|
|
if not self._incremental:
|
|
# Only complete-text controls are configured; there is nothing to
|
|
# decide per chunk, so don't manufacture an empty decision.
|
|
return self._split(combined, force=force, final=final, decision=None)
|
|
|
|
decision = self.engine.evaluate(
|
|
combined, Stage.OUTPUT, controls=self._incremental
|
|
)
|
|
self.decisions.append(decision)
|
|
|
|
if decision.blocked:
|
|
self._blocked = True
|
|
self._block_message = decision.block_message
|
|
self._held = ""
|
|
return StreamChunk(
|
|
blocked=True,
|
|
block_message=decision.block_message,
|
|
decisions=[decision],
|
|
)
|
|
|
|
return self._split(combined, force=force, final=final, decision=decision)
|
|
|
|
@staticmethod
|
|
def _redact_spans(decision: Optional[StageDecision]) -> List[Span]:
|
|
"""Spans a redact control asked to mask, in scan-buffer coordinates."""
|
|
if decision is None:
|
|
return []
|
|
spans: List[Span] = []
|
|
for verdict in decision.verdicts:
|
|
if verdict.action is Action.REDACT and verdict.outcome.triggered:
|
|
spans.extend(verdict.outcome.spans)
|
|
return spans
|
|
|
|
@staticmethod
|
|
def _safe_release(emit_end: int, spans: List[Span]) -> int:
|
|
"""Pull the release point back so no masked span is cut in half.
|
|
|
|
Emitting the front of a match and holding the rest leaks it: the held
|
|
remainder is a truncated tail that no longer satisfies the pattern, so
|
|
nothing re-matches it and it goes out in the clear.
|
|
"""
|
|
while True:
|
|
crossing = [s.start for s in spans if s.start < emit_end < s.end]
|
|
if not crossing:
|
|
return emit_end
|
|
emit_end = min(crossing)
|
|
|
|
def _split(
|
|
self, raw: str, force: bool, final: bool, decision: Optional[StageDecision]
|
|
) -> StreamChunk:
|
|
spans = self._redact_spans(decision)
|
|
if final:
|
|
emit_end = len(raw)
|
|
else:
|
|
emit_end = self._release_point(raw)
|
|
if force and emit_end == 0:
|
|
# Over the hold ceiling with no boundary in sight: release all
|
|
# but the lookback tail so the stream cannot stall forever.
|
|
emit_end = max(0, len(raw) - self.lookback)
|
|
emit_end = self._safe_release(emit_end, spans)
|
|
|
|
# The tail is held *unredacted*. Masking before holding would freeze a
|
|
# partial match: a detector firing on a prefix of a value still arriving
|
|
# bakes the mask in, and the characters that follow append after it and
|
|
# are never re-scanned. Holding raw is safe because the tail is
|
|
# re-scanned in full on every subsequent round.
|
|
self._held = raw[emit_end:]
|
|
emitted = apply_spans(raw[:emit_end], [s for s in spans if s.end <= emit_end])
|
|
if self._full is not None:
|
|
self._full += emitted
|
|
return StreamChunk(
|
|
emit=emitted, decisions=[decision] if decision is not None else []
|
|
)
|