From 3ba4e7e777ee48d7b5f336bb5967c4d1c6adf8de Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 5 Jun 2026 07:15:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-fabric):=20S2-Ff=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=E2=86=94fixture=20=EB=93=9C=EB=A6=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=90=EC=A7=80=20(=EB=B9=84=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20runbook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- contract/contract-check.sh | 80 ++++++++++++++++++++++++++++++++++++ contract/shape_diff.py | 83 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100755 contract/contract-check.sh create mode 100755 contract/shape_diff.py diff --git a/contract/contract-check.sh b/contract/contract-check.sh new file mode 100755 index 0000000..cd9b808 --- /dev/null +++ b/contract/contract-check.sh @@ -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 diff --git a/contract/shape_diff.py b/contract/shape_diff.py new file mode 100755 index 0000000..bbfd29a --- /dev/null +++ b/contract/shape_diff.py @@ -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 ''}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)") + return + if lt != ft: + breaking.append(f"{path or ''}: 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 ", 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()