Skip to content

Commit 08bfdeb

Browse files
feat: a-priori sign convention + operator-side withheld-narration logging (#81)
* feat(explain): add an a-priori sign convention to the narration prompt Signed numeric fields (deltas, CI bounds) must be written WITH their sign; a bare magnitude is disallowed even when a verb carries the direction. Stated once in SYSTEM_PROMPT, before any generation -- defence in depth mirroring the guardrail's sign-aware token matching. An honest magnitude ("0.3391 만큼 감소") and a genuine sign error ("델타는 0.3391", which inverts the leakage conclusion) emit a byte-identical token, so the gate cannot tell them apart; this steers the model away from that ambiguity at the source. This is NOT a retry after a violation: the verifier never becomes a training signal, and no retry logic is added. The gate, the allowed-set derivation, and _NUM_TOKEN are untouched. The instruction carries no numeric literal or worked example (cf. b24737a: literals injected into prompt text came back in narrations and tripped the number gate); a test pins that it tokenizes to [] under the guardrail's own extractor and that it is present in the standing prompt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ui): log withheld-narration prose server-side for operator adjudication A withheld narration's GuardrailViolation still holds the full offending prose after the `--- offending output ---` marker; narrate_diagnosis split it off and discarded it with no side effect, so a withheld run could never be adjudicated afterwards. Emit a logging.WARNING on the "permea_ui.narration" logger carrying the violation headline, the full offending prose, and the report sha256, before discarding it. The browser redaction is unchanged: narrate_diagnosis still returns Narration(status="withheld", detail=headline), app.py serialization is untouched, and the offending prose never reaches the HTTP response. A caplog test asserts both that the log record contains the offending text and that the returned Narration carries no prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5adf68 commit 08bfdeb

4 files changed

Lines changed: 80 additions & 3 deletions

File tree

permea_explain/prompt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
없습니다. 신뢰수준(예: "95% 신뢰구간"의 95)처럼 숫자 필드가 아니라 설명 문자열
3838
안에만 있는 값은 숫자로 다시 쓰지 마십시오. 아래의 레지스트리 설명(경고 코드의
3939
의미)도 숫자의 출처가 아닙니다. 그 안에 숫자·버전·논문 참조가 보이더라도 출력에
40-
옮기지 마십시오.
40+
옮기지 마십시오. 부호가 있는 숫자 필드(델타, 신뢰구간 경계 등)는 항상 그 부호를
41+
붙여 그대로 쓰십시오. 방향을 동사로 나타내더라도 부호를 뗀 크기(절댓값)만 적는
42+
것은 허용되지 않습니다. 음수 필드는 반드시 음의 부호와 함께 기재하십시오.
4143
4244
2. fired 목록에 있는 경고만 서술하십시오. fired 에 없는 경고 코드(PERMEA-W###)를
4345
지어내지 마십시오.

permea_ui/runner.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
from __future__ import annotations
1717

18+
import hashlib
1819
import json
20+
import logging
1921
import os
2022
import tempfile
2123
from dataclasses import dataclass, field
@@ -26,6 +28,11 @@
2628
from permea_core.diagnose.rules import DiagnosePolicy
2729
from permea_core.eval.run import run_from_dataset
2830

31+
#: Operator-only sink for withheld narrations. The offending prose is logged HERE (server
32+
#: side) so a withheld run can be adjudicated after the fact; it is never returned to the
33+
#: browser -- the HTTP response still carries only the headline. See narrate_diagnosis.
34+
_log = logging.getLogger("permea_ui.narration")
35+
2936
#: Demo-scale evaluation settings. The CLI defaults (seeds=5, k=5, n_boot=2000) take ~53s
3037
#: and saturate every core; these take ~9s on the example fixture and are the settings the
3138
#: W501 -> W101 flip was verified at. A UI knob may raise them -- it must not silently
@@ -201,7 +208,19 @@ def narrate_diagnosis(diagnosis: dict[str, Any], *, provider: Any = None) -> Nar
201208
except GuardrailViolation as exc:
202209
# Show that a check failed and how many -- never the offending text, which the
203210
# exception appends after a `--- offending output ---` marker.
204-
headline = str(exc).split("\n--- offending output ---", 1)[0]
211+
message = str(exc)
212+
headline = message.split("\n--- offending output ---", 1)[0]
213+
# OPERATOR-ONLY observability: the full exception still holds the offending prose
214+
# after the marker. Log it here (server side) with the report hash so a withheld
215+
# narration can be adjudicated later. This never reaches the browser -- the
216+
# returned Narration below carries only the headline, and app.py serializes no
217+
# more than that. `message` is logged whole: headline + the offending prose.
218+
source_report_sha256 = hashlib.sha256(report_path.read_bytes()).hexdigest()
219+
_log.warning(
220+
"narration withheld [source_report_sha256=%s]: %s",
221+
source_report_sha256,
222+
message,
223+
)
205224
return Narration(status="withheld", detail=headline)
206225
except Exception as exc: # transport, timeout, malformed response
207226
return Narration(status="error", detail=f"{type(exc).__name__}: {exc}")

tests/test_explain_narrate.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from permea_core.diagnose import diagnose
1414
from permea_core.eval.run import EvalRun, MetricRow
15-
from permea_explain import GuardrailViolation, guardrails, narrate, source
15+
from permea_explain import GuardrailViolation, guardrails, narrate, prompt, source
1616
from permea_explain.extract import extract
1717
from permea_explain.providers.base import Provider, ProviderResponse
1818
from permea_explain.violations import NUMERIC_UNTRACED
@@ -373,6 +373,38 @@ def test_pure_digit_and_decimal_tokenization_unchanged():
373373
assert guardrails.numbers_in_narrative("PERMEA-W101 이 발화, 정확도 0.87") == ["0.87"]
374374

375375

376+
# --------------------------------------------------------------------------------------
377+
# A-priori sign convention (#0034)
378+
# --------------------------------------------------------------------------------------
379+
# SYSTEM_PROMPT carries a standing instruction to always write signed fields (deltas, CI
380+
# bounds) WITH their sign -- stated ONCE, before any generation, as defence in depth mirroring
381+
# the guardrail's sign-aware token matching. It is NOT a retry after a violation (the verifier
382+
# must never become a training signal). The instruction itself must carry no numeric literal:
383+
# commit b24737a exists because literals injected into prompt-visible text came back in
384+
# narrations and tripped the number gate, so the added sentence must tokenize to [] under the
385+
# SAME extractor the gate uses at runtime.
386+
_SIGN_CONVENTION = (
387+
"부호가 있는 숫자 필드(델타, 신뢰구간 경계 등)는 항상 그 부호를 "
388+
"붙여 그대로 쓰십시오. 방향을 동사로 나타내더라도 부호를 뗀 크기(절댓값)만 적는 "
389+
"것은 허용되지 않습니다. 음수 필드는 반드시 음의 부호와 함께 기재하십시오."
390+
)
391+
392+
393+
def test_sign_convention_present_in_system_prompt():
394+
"""The convention is a-priori: it lives in the standing prompt, sent before generation.
395+
396+
Matched whitespace-insensitively because SYSTEM_PROMPT hard-wraps its rules across
397+
indented lines, so the sentence spans several ``\\n `` breaks in the source.
398+
"""
399+
collapse = lambda s: re.sub(r"\s+", " ", s).strip()
400+
assert collapse(_SIGN_CONVENTION) in collapse(prompt.SYSTEM_PROMPT)
401+
402+
403+
def test_sign_convention_carries_no_numeric_literal():
404+
"""A faithful copy of the instruction must not itself fail the provenance gate (b24737a)."""
405+
assert guardrails._standalone_number_tokens(_SIGN_CONVENTION) == []
406+
407+
376408
def test_extract_is_available_alongside_narrate():
377409
"""Both task layers share one Provider and one verification discipline (#0023)."""
378410
import inspect

tests/test_ui_drylab.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import ast
1212
import json
13+
import logging
1314
import sys
1415
from pathlib import Path
1516

@@ -117,6 +118,29 @@ def test_narration_quoting_the_data_sha256_is_not_withheld(diagnosis):
117118
assert n.text == text
118119

119120

121+
def test_withheld_narration_logs_offending_prose_for_operator(diagnosis, caplog):
122+
"""Design 1 (#0034): a withheld narration's offending prose is logged SERVER-SIDE for
123+
operator adjudication, while the returned Narration -- and therefore the HTTP response --
124+
still carries none of it. The browser redaction is unchanged; only observability is added.
125+
"""
126+
bad = "정확도는 99.7% 였고 PERMEA-W999 경고가 발생했습니다." # fabricated number + unfired code
127+
with caplog.at_level(logging.WARNING, logger="permea_ui.narration"):
128+
n = runner.narrate_diagnosis(diagnosis, provider=FakeProvider(bad))
129+
130+
# What the browser gets: withheld, no prose, no offending text leaked into the detail.
131+
assert n.status == "withheld"
132+
assert n.text is None, "withheld narration must not carry renderable prose"
133+
assert "offending output" not in (n.detail or "")
134+
assert bad not in (n.detail or ""), "offending prose must not reach the returned Narration"
135+
136+
# What the operator gets: the full offending prose, on the dedicated server-side logger.
137+
records = [r for r in caplog.records if r.name == "permea_ui.narration"]
138+
assert records, "expected a WARNING on the 'permea_ui.narration' logger"
139+
logged = "\n".join(r.getMessage() for r in records)
140+
assert bad in logged, "offending prose must be present in the operator log"
141+
assert "source_report_sha256=" in logged, "log must carry the report hash for adjudication"
142+
143+
120144
def test_missing_credentials_yields_unavailable(diagnosis, monkeypatch):
121145
for var in ("PERMEA_LLM_TOKEN", "PERMEA_LLM_ENDPOINT_ID", "PERMEA_LLM_BASE_URL"):
122146
monkeypatch.delenv(var, raising=False)

0 commit comments

Comments
 (0)