첫 버전은 "몇 번째 줄 이후"로 기준선을 잡았는데, 로그 회전 설정을 확인해 보니 그 방식이 하루도 못 간다: logrotate가 크기 1M 기준 + `copytruncate`라, 회전 시 내용이 .1로 복사되고 원본은 0바이트로 잘린다. 패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다. 대신 gateway.log* 전 파일(.gz 포함)을 합쳐 패턴별 누적 횟수를 세고 기준선 값을 뺀다. 회전해도 내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다. rotate 14라 파일이 실제로 없어지려면 현재 증가 속도로 반년 이상 걸리고, 그때는 차이가 음수로 나와 경고한다. 패치 직전 기준(누적 1,011턴, 턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 / SEARCH-ABANDONMENT 0.010 / 중복 SKIP 0.035 / 합성 Answer: 0.009 / standing down 0(미존재). 2026-09-09 10:07 KST 1회 실행되도록 systemd user timer(guard-audit.timer) 등록. 이 박스는 매일 02시에 자므로 Persistent=true로 기상 후 따라잡게 했다. 결과는 .smallclaw/logs/audit/report-<날짜>.txt 로 남는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
140 lines
6.6 KiB
Python
Executable File
140 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
가드 발동률 실측 — 2026-09-04 가드 패치(db6a863 / 10e54d3 / b2075c6) 이후 구간을 잰다.
|
|
|
|
왜 필요한가. 이번 패치들은 전부 실사용 로그를 표본으로 보정했지만, 보정이 맞았는지는
|
|
"패치 이후 로그"로만 확인할 수 있다([[feedback_calibrate_on_real_data]]). 특히 관련성
|
|
관문(sourceCoversQuery)은 양쪽으로 어긋날 수 있다:
|
|
· 너무 헐거우면 → NUMERIC-GROUNDING이 계속 맞는 답을 지운다(원래 사고의 재발)
|
|
· 너무 빡빡하면 → standing down이 과해져 진짜 날조가 그대로 통과한다
|
|
|
|
## 왜 줄 번호가 아니라 누적 카운트인가
|
|
|
|
로그는 크기 기준(1M)으로 회전하고 `copytruncate`를 쓴다 — 회전 시 내용이 .1로 복사되고
|
|
원본은 0바이트로 잘린다. 그래서 "몇 번째 줄 이후"라는 기준은 회전 한 번에 무효가 된다
|
|
(패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다).
|
|
|
|
대신 gateway.log* **전 파일(.gz 포함)** 을 합쳐 패턴별 누적 횟수를 세고, 기준선의 값을
|
|
뺀다. 회전해도 내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다.
|
|
`rotate 14`라 파일이 실제로 없어지려면 현재 증가 속도로 반년 이상 걸린다 — 그때는 차이가
|
|
음수로 나오고 스크립트가 경고한다.
|
|
|
|
사용:
|
|
python3 scripts/guard-firing-audit.py # 기준선 이후 증가분
|
|
python3 scripts/guard-firing-audit.py --total # 누적 전체(기준선 무시)
|
|
"""
|
|
|
|
import glob
|
|
import gzip
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
LOG_GLOB = os.path.join(ROOT, '.smallclaw/logs/gateway.log*')
|
|
BASELINE = os.path.join(ROOT, '.smallclaw/logs/audit/guard-baseline.json')
|
|
|
|
# (키, 라벨, 정규식, 이번 패치와의 관계)
|
|
PATTERNS = [
|
|
('numeric_standdown', 'NUMERIC standing down', r'NUMERIC-GROUNDING: standing down', 'new'),
|
|
('numeric_reprompt', 'NUMERIC 재프롬프트', r'NUMERIC-GROUNDING POST-CHECK', 'should-drop'),
|
|
('search_abandonment', 'SEARCH-ABANDONMENT', r'SEARCH-ABANDONMENT POST-CHECK', 'should-rise'),
|
|
('dead_end_fallback', 'DEAD-END FALLBACK', r'DEAD-END FALLBACK POST-CHECK', 'new'),
|
|
('sycophantic', 'SYCOPHANTIC', r'SYCOPHANTIC-CONFIRMATION POST-CHECK', 'new'),
|
|
('auto_recover', 'AUTO-RECOVER', r'\[v2\] AUTO-RECOVER \(', 'baseline'),
|
|
('empty_grounding', 'EMPTY-GROUNDING', r'EMPTY-GROUNDING POST-CHECK', 'baseline'),
|
|
('intent_only', 'INTENT-ONLY', r'INTENT-ONLY POST-CHECK', 'baseline'),
|
|
('messaging', 'MESSAGING', r'MESSAGING POST-CHECK', 'baseline'),
|
|
('stale_version', 'STALE-VERSION QUERY', r'STALE-VERSION QUERY', 'baseline'),
|
|
('dup_skip', '중복 도구호출 SKIP', r'SKIP: duplicate tool call', 'baseline'),
|
|
('synth_answer', '합성 Answer: 줄', r'TOOL OK: Answer: The current', 'should-be-0'),
|
|
('user_turns', '사용자 턴', r'^\[v2\] USER \[', 'baseline'),
|
|
]
|
|
|
|
NOTE = {
|
|
'new': '이번에 추가',
|
|
'should-drop': '줄어야 정상',
|
|
'should-rise': '패치 전엔 이 어법을 못 잡았음',
|
|
'should-be-0': '0이어야 정상',
|
|
'baseline': '',
|
|
}
|
|
|
|
FIG_RE = re.compile(r'figure\(s\) not found in tool output \[([^\]]*)\]')
|
|
COV_RE = re.compile(r'cover only (\d+)% of the query')
|
|
|
|
|
|
def read_all_logs() -> str:
|
|
parts = []
|
|
for p in sorted(glob.glob(LOG_GLOB)):
|
|
try:
|
|
if p.endswith('.gz'):
|
|
parts.append(gzip.open(p, 'rt', errors='replace').read())
|
|
else:
|
|
parts.append(open(p, errors='replace').read())
|
|
except OSError as exc:
|
|
print(f' ⚠ {os.path.basename(p)} 읽기 실패: {exc}', file=sys.stderr)
|
|
if not parts:
|
|
sys.exit(f'로그를 찾지 못했습니다: {LOG_GLOB}')
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def main() -> None:
|
|
text = read_all_logs()
|
|
total = {key: len(re.findall(rx, text, re.M)) for key, _, rx, _ in PATTERNS}
|
|
|
|
base = {}
|
|
if '--total' not in sys.argv and os.path.exists(BASELINE):
|
|
base = json.load(open(BASELINE)).get('counts', {})
|
|
|
|
if base:
|
|
delta = {k: total[k] - base.get(k, 0) for k in total}
|
|
if any(v < 0 for v in delta.values()):
|
|
print('⚠ 일부 카운트가 기준선보다 작습니다 — 오래된 로그 파일이 rotate 14를 넘겨')
|
|
print(' 삭제된 듯합니다. 아래 숫자는 신뢰하지 마시고 --total 로 보세요.\n')
|
|
counts, label = delta, '기준선(2026-09-04) 이후 증가분'
|
|
else:
|
|
counts, label = total, '누적 전체'
|
|
|
|
turns = counts.get('user_turns', 0)
|
|
print(f'구간: {label} — 사용자 턴 {turns}건\n')
|
|
if turns <= 0:
|
|
print('아직 이 구간에 대화가 거의 없습니다. 며칠 더 쓰고 다시 도세요.')
|
|
return
|
|
|
|
print(f'{"가드":<24}{"발동":>6}{"턴당":>9} 비고')
|
|
print('-' * 64)
|
|
for key, lbl, _, kind in PATTERNS:
|
|
if key == 'user_turns':
|
|
continue
|
|
c = counts[key]
|
|
print(f'{lbl:<24}{c:>6}{c / turns:>9.3f} {NOTE[kind]}')
|
|
|
|
# 지워진 수치가 실제로 무엇이었는지 — 사용자가 원한 답이 지워졌는지는 눈으로만 판정된다.
|
|
figs = FIG_RE.findall(text)
|
|
if figs:
|
|
print(f'\n지워진 수치 (누적 {len(figs)}건, 최근 12건):')
|
|
for f in figs[-12:]:
|
|
print(f' · {f}')
|
|
print(' ↑ 사용자가 원했던 답이 여기 섞여 있으면 관련성 관문이 아직 헐겁다.')
|
|
|
|
covs = sorted(int(m) for m in COV_RE.findall(text))
|
|
if covs:
|
|
print(f'\nstanding down 커버리지: {covs}')
|
|
print(f' 중앙값 {covs[len(covs) // 2]}% (임계값 34%)')
|
|
near = [c for c in covs if 25 <= c <= 33]
|
|
if near:
|
|
print(f' ⚠ 임계값 바로 아래(25~33%) {len(near)}건 — 경계 사례가 쌓이면 값 재검토')
|
|
|
|
print('\n판독 기준')
|
|
print(' · 합성 Answer: 줄이 0이 아니면 → generic 경로가 되살아났는지 확인')
|
|
print(' · SEARCH-ABANDONMENT이 여전히 0이면 → 포기 어법이 또 바뀐 것(NOTHING_FOUND 갱신)')
|
|
print(' · DEAD-END FALLBACK이 턴당 0.05 넘으면 → PRIOR_DEAD_ENDS_REQUIRED=1이 낮은 것')
|
|
print(' · standing down이 NUMERIC 재프롬프트보다 훨씬 많으면 → 관문이 너무 빡빡')
|
|
print(' · 패치 전 기준(턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 /')
|
|
print(' SEARCH-ABANDONMENT 0.010 / 합성 Answer: 0.009')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|