feat(ai-fabric): S2-Ff 라이브↔fixture 드리프트 감지 (비차단 runbook)
contract/contract-check.sh + contract/shape_diff.py — 라이브 엔드포인트 재호출 → 동결 fixture 와 키/타입 *모양* diff(LLM 스칼라 값 무시). 드리프트 = 비0 exit + 재캡처 안내. PR 게이트 아님(수동/Tailscale-CI 트리거). 가시적 스킵(silent green 금지). - llm-router /v1/chat/completions ↔ llm-router-chat.response.json (라이브 실행 PASS) - DS /search/ask ↔ ask.json (best-effort, 인증 필요시 가시 SKIP) - exit 0=드리프트없음 · 1=breaking 드리프트 · 2=전부 도달불가(green 아님) - 음성 테스트 검증: 타입변경/키삭제 드리프트 감지 + exit 1 확인(no-op 아님) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# contract-check.sh — S2-Ff: 라이브 엔드포인트 ↔ 동결 fixture *모양* 드리프트 감지 (비차단 runbook).
|
||||
#
|
||||
# 라이브 재호출 → 키/타입 diff(LLM 값은 무시) → 드리프트면 비0 exit + fixture-update 안내.
|
||||
# PR 게이트 아님 — 수동/Tailscale-CI 트리거([[feedback_pr_gate_vs_runbook_separation]]).
|
||||
# fixture 는 동결·불변이며, 이 도구가 감지한 드리프트가 **재캡처 PR 의 유일 합법 트리거**(S2-0d).
|
||||
#
|
||||
# exit: 0 = 드리프트 없음(체크된 것 중) · 1 = breaking 드리프트 · 2 = 아무것도 체크 못함(전부 도달불가)
|
||||
# env override: AIFABRIC_LOCALMLX_URL, AIFABRIC_DS_URL
|
||||
#
|
||||
# 스킵은 항상 *가시적*으로 출력(silent green 금지, [[feedback_silent_skip_accumulation]]).
|
||||
set -uo pipefail # NOT -e: 개별 체크 실패를 모아 보고
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FIX="$SCRIPT_DIR/fixtures"
|
||||
PY="$(command -v python3 || echo /usr/bin/python3)"
|
||||
LLM_URL="${AIFABRIC_LOCALMLX_URL:-http://100.76.254.116:8890}"
|
||||
DS_URL="${AIFABRIC_DS_URL:-https://document.hyungi.net/api}"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
breaking=0
|
||||
checked=0
|
||||
|
||||
echo "== S2-Ff contract drift check =="
|
||||
echo " llm-router: $LLM_URL"
|
||||
echo " DS: $DS_URL"
|
||||
|
||||
# --- 1) llm-router /v1/chat/completions (S2 fixture) ---
|
||||
echo
|
||||
echo "[1] llm-router /v1/chat/completions ↔ llm-router-chat.response.json"
|
||||
# 동결 request fixture 에서 _meta 떼고 그대로 wire body 로 POST(call-shape 도 함께 검증).
|
||||
"$PY" -c 'import json,sys; o=json.load(open(sys.argv[1],encoding="utf-8")); o.pop("_meta",None); json.dump(o,open(sys.argv[2],"w"))' \
|
||||
"$FIX/llm-router-chat.request.json" "$TMP/req.json" 2>"$TMP/strip_err" || { echo " SKIP — request fixture 처리 실패: $(head -c160 "$TMP/strip_err")"; }
|
||||
if [ -s "$TMP/req.json" ]; then
|
||||
code="$(curl -sS -m 60 -o "$TMP/llm_live.json" -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' --data @"$TMP/req.json" \
|
||||
"$LLM_URL/v1/chat/completions" 2>"$TMP/llm_err" || true)"
|
||||
if [ "$code" = "200" ]; then
|
||||
checked=$((checked + 1))
|
||||
if "$PY" "$SCRIPT_DIR/shape_diff.py" "$FIX/llm-router-chat.response.json" "$TMP/llm_live.json"; then
|
||||
echo " PASS — shape 일치"
|
||||
else
|
||||
echo " >> 드리프트: llm-router-chat.{request,response}.json 재캡처 PR 필요"
|
||||
breaking=1
|
||||
fi
|
||||
else
|
||||
echo " SKIP — 도달 불가/비200 (http=${code:-curlfail}; 맥미니 offline?) $(head -c120 "$TMP/llm_err" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2) DS /search/ask (S1 fixture, best-effort: 인증 필요할 수 있음) ---
|
||||
echo
|
||||
echo "[2] DS /search/ask ↔ ask.json (best-effort)"
|
||||
code="$(curl -sS -m 30 -G -o "$TMP/ds_live.json" -w '%{http_code}' \
|
||||
--data-urlencode 'q=충격시험은 언제 면제되나' --data-urlencode 'backend=mac-mini-default' \
|
||||
"$DS_URL/search/ask" 2>"$TMP/ds_err" || true)"
|
||||
case "$code" in
|
||||
200)
|
||||
checked=$((checked + 1))
|
||||
if "$PY" "$SCRIPT_DIR/shape_diff.py" "$FIX/ask.json" "$TMP/ds_live.json"; then
|
||||
echo " PASS — shape 일치"
|
||||
else
|
||||
echo " >> 드리프트: ask.json(S1) 재캡처 검토"
|
||||
breaking=1
|
||||
fi ;;
|
||||
401|403) echo " SKIP — 인증 필요(JWT). DS ask 체크는 토큰 주입 시에만(S1 도메인)." ;;
|
||||
*) echo " SKIP — 도달 불가/비200 (http=${code:-curlfail})." ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
if [ "$breaking" -ne 0 ]; then
|
||||
echo "RESULT: DRIFT 발견 — 위 fixture 재캡처 PR 필요(S2-0d 의 유일 합법 fixture 변경 경로)."
|
||||
exit 1
|
||||
elif [ "$checked" -eq 0 ]; then
|
||||
echo "RESULT: 체크한 엔드포인트 0건(전부 도달 불가) — 드리프트 판정 불가. (green 아님 → exit 2)"
|
||||
exit 2
|
||||
else
|
||||
echo "RESULT: OK — 체크 ${checked}건 모두 shape 일치, 드리프트 없음."
|
||||
exit 0
|
||||
fi
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S2-Ff shape diff — frozen fixture ↔ live response 의 *모양*(키/타입)만 비교.
|
||||
|
||||
스칼라 *값*은 무시한다(LLM 응답 본문·id·토큰수는 호출마다 다름). 비교 대상 = 키 존재 + 타입.
|
||||
argv[1] = frozen fixture 경로, argv[2] = live 응답 JSON 경로.
|
||||
exit 1 = breaking 드리프트(frozen 키 삭제 / 타입 변경), exit 0 = clean(동일 or live 신규키만).
|
||||
양쪽 top-level `_meta`(우리 주석)는 비교에서 제외.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def strip_meta(o):
|
||||
return {k: v for k, v in o.items() if k != "_meta"} if isinstance(o, dict) else o
|
||||
|
||||
|
||||
def tag(v):
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, bool):
|
||||
return "bool"
|
||||
if isinstance(v, (int, float)):
|
||||
return "number"
|
||||
if isinstance(v, str):
|
||||
return "string"
|
||||
if isinstance(v, list):
|
||||
return "array"
|
||||
if isinstance(v, dict):
|
||||
return "object"
|
||||
return "unknown"
|
||||
|
||||
|
||||
breaking = []
|
||||
info = []
|
||||
|
||||
|
||||
def diff(live, frozen, path):
|
||||
lt, ft = tag(live), tag(frozen)
|
||||
# nullability 는 드리프트로 보지 않음(fixture 의 null = nullable 표식).
|
||||
if lt == "null" or ft == "null":
|
||||
if lt != ft:
|
||||
info.append(f"{path or '<root>'}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)")
|
||||
return
|
||||
if lt != ft:
|
||||
breaking.append(f"{path or '<root>'}: frozen={ft} live={lt} (타입 변경)")
|
||||
return
|
||||
if ft == "object":
|
||||
for k, fv in frozen.items():
|
||||
p = f"{path}.{k}" if path else k
|
||||
if k in live:
|
||||
diff(live[k], fv, p)
|
||||
else:
|
||||
breaking.append(f"{p}: frozen 에 있으나 live 에 없음 (키 삭제)")
|
||||
for k in live:
|
||||
if k not in frozen:
|
||||
p = f"{path}.{k}" if path else k
|
||||
info.append(f"{p}: live 신규 키 (비파괴)")
|
||||
elif ft == "array":
|
||||
if frozen:
|
||||
if live:
|
||||
diff(live[0], frozen[0], f"{path}[0]")
|
||||
else:
|
||||
info.append(f"{path}[]: live 빈 배열 — element shape 검증 불가")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: shape_diff.py <frozen_fixture> <live_json>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
frozen = strip_meta(json.load(open(sys.argv[1], encoding="utf-8")))
|
||||
live = strip_meta(json.load(open(sys.argv[2], encoding="utf-8")))
|
||||
diff(live, frozen, "")
|
||||
for m in breaking:
|
||||
print(f" DRIFT {m}")
|
||||
for m in info:
|
||||
print(f" info {m}")
|
||||
if not breaking and not info:
|
||||
print(" (shape identical)")
|
||||
sys.exit(1 if breaking else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user