G7 독립 평가 입력 검증 도구 추가
This commit is contained in:
parent
fcc45e083e
commit
3067b72526
8 changed files with 881 additions and 5 deletions
|
|
@ -456,7 +456,13 @@ G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는
|
||||||
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
|
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
|
||||||
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
|
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
|
||||||
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
|
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
|
||||||
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다. 관련 API+script 계약은 97/97 통과했다.
|
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다.
|
||||||
|
- 실제 평가팀 intake는 `scripts/prepare-g7-human-voice-gain-intake.py --create-template <dir>`로 시작한다. 생성물은
|
||||||
|
header-only이고 `template_only=true`라 증거가 아니다. provenance와 비식별 participant/labeler/observation CSV를
|
||||||
|
채운 뒤 `--compile <dir> --out <pack.json>`을 실행하면 외부 report의 ICC·κ와 행 재계산값의 일치를 확인하고
|
||||||
|
production gate가 전부 통과할 때만 기존 파일을 덮어쓰지 않고 최종 pack을 만든다. 콘솔은 경로·키·라벨·잘못된
|
||||||
|
셀 값을 반사하지 않는다.
|
||||||
|
evaluator/checker 97건과 intake 6건을 합쳐 관련 계약 103/103을 통과했다. 실제 사람 데이터는 아직 없다.
|
||||||
- 무마이크 rehearsal 산출물은
|
- 무마이크 rehearsal 산출물은
|
||||||
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
|
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
|
||||||
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
|
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,10 @@
|
||||||
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
|
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
|
||||||
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
|
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
|
||||||
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
|
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
|
||||||
|
독립 평가팀용 `prepare-g7-human-voice-gain-intake.py`는 빈 header-only 템플릿을 만들고, 비식별 CSV를
|
||||||
|
production floor·ICC·κ·gain·CI로 재계산해 전부 통과한 경우에만 최종 pack을 원자 생성한다. 템플릿은
|
||||||
|
`template_only=true`라 증거로 컴파일할 수 없고 이름·이메일·원음·축어록·자유서술 열을 받지 않는다.
|
||||||
|
도구 준비만 끝난 상태이며 실제 독립 평가자 데이터는 아직 없으므로 G7 external GATE를 유지한다.
|
||||||
**공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126,
|
**공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126,
|
||||||
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
|
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
|
||||||
`9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
|
`9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
|
||||||
|
|
@ -123,7 +127,7 @@
|
||||||
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
|
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
|
||||||
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
|
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
|
||||||
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
|
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
|
||||||
human pack 계약 97/97과 기존 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
|
human pack evaluator/checker+intake 계약 103/103과 기존 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
|
||||||
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
|
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
|
||||||
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
|
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
|
||||||
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
|
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
|
||||||
|
|
|
||||||
|
|
@ -674,7 +674,7 @@
|
||||||
<p class="dg-note">195차 적용(2026-08-10): clean HEAD <code>13d1c39a…</code>·tree <code>beb93801…</code>의 4차 수동 full run은 앞선 SSE hang을 제거해 6.3분에 종료했고, 실제 동일 학습자 SSE·review ready·G4 첫 POST와 원장 count 0→1까지 통과했다. 이후 full page reload에서 이미 저장된 현재 회기 episode를 카드가 hydrate하지 않아 <code>원장 반영됨</code> 대신 <code>학습자만 실행</code>으로 되돌아가는 제품 정직성 결함을 fail-closed했다. receipt <code>periodic-learner-e2e-20260810-003150.json</code>은 공개·NAS·active DB 접촉 0과 container/volume/network/listener/temp 잔여 0을 증명한다. UI는 현재 session+prescription의 persisted episode를 read model에서 복원해 <code>현재 원장 상태</code>와 재확인 행동을 표시하도록 수정했고, 실제 reload를 포함한 자기주도 route fixture desktop/mobile <b>6/6</b>·typecheck를 통과했다. full runner는 재시도하지 않았으므로 첫 GREEN receipt와 자동 주기 승격은 여전히 미완료다.</p>
|
<p class="dg-note">195차 적용(2026-08-10): clean HEAD <code>13d1c39a…</code>·tree <code>beb93801…</code>의 4차 수동 full run은 앞선 SSE hang을 제거해 6.3분에 종료했고, 실제 동일 학습자 SSE·review ready·G4 첫 POST와 원장 count 0→1까지 통과했다. 이후 full page reload에서 이미 저장된 현재 회기 episode를 카드가 hydrate하지 않아 <code>원장 반영됨</code> 대신 <code>학습자만 실행</code>으로 되돌아가는 제품 정직성 결함을 fail-closed했다. receipt <code>periodic-learner-e2e-20260810-003150.json</code>은 공개·NAS·active DB 접촉 0과 container/volume/network/listener/temp 잔여 0을 증명한다. UI는 현재 session+prescription의 persisted episode를 read model에서 복원해 <code>현재 원장 상태</code>와 재확인 행동을 표시하도록 수정했고, 실제 reload를 포함한 자기주도 route fixture desktop/mobile <b>6/6</b>·typecheck를 통과했다. full runner는 재시도하지 않았으므로 첫 GREEN receipt와 자동 주기 승격은 여전히 미완료다.</p>
|
||||||
<p class="dg-note">196차 적용(2026-08-10): clean HEAD <code>9a7dd98c…</code>·tree <code>1dce20c5…</code>의 5차 단일 full run은 실제 동일 학습자 브라우저 단계에서 SSE·review ready·G4 <code>0→1→1</code>·G5 <code>0→1→1</code>을 78초에 모두 통과해 핵심 폐루프 첫 GREEN을 만들었다. 다만 같은 fixture를 이어서 사용한 returned-practice desktop/mobile 회귀가 이미 적재된 G4/G5를 초기 0으로 오인해 2/4 실패했고 전체 receipt <code>periodic-learner-e2e-20260810-010038.json</code>은 정직하게 FAILED다. 공개·NAS·active DB 접촉 0, container/volume/network/listener/temp 잔여 0은 유지했다. runner는 핵심 폐루프와 후속 회귀 전에 각각 새 학습자·회기 fixture를 준비하도록 분리했고 unit <b>12/12</b>·Ruff·compile을 통과했다. 실패 화면에서 리뷰 요약 literal <code>[NAME]</code>/<code>[ORG]</code>과 <code>익명 내담자가고</code> 조사 결함도 발견해 표시 전용 자연어 치환과 복합 조사 우선순위를 보강했고 session-review desktop/mobile <b>2/2</b>·typecheck·build를 통과했다. 당시에는 전체 GREEN receipt가 없어 자동 full runner를 등록하지 않았다.</p>
|
<p class="dg-note">196차 적용(2026-08-10): clean HEAD <code>9a7dd98c…</code>·tree <code>1dce20c5…</code>의 5차 단일 full run은 실제 동일 학습자 브라우저 단계에서 SSE·review ready·G4 <code>0→1→1</code>·G5 <code>0→1→1</code>을 78초에 모두 통과해 핵심 폐루프 첫 GREEN을 만들었다. 다만 같은 fixture를 이어서 사용한 returned-practice desktop/mobile 회귀가 이미 적재된 G4/G5를 초기 0으로 오인해 2/4 실패했고 전체 receipt <code>periodic-learner-e2e-20260810-010038.json</code>은 정직하게 FAILED다. 공개·NAS·active DB 접촉 0, container/volume/network/listener/temp 잔여 0은 유지했다. runner는 핵심 폐루프와 후속 회귀 전에 각각 새 학습자·회기 fixture를 준비하도록 분리했고 unit <b>12/12</b>·Ruff·compile을 통과했다. 실패 화면에서 리뷰 요약 literal <code>[NAME]</code>/<code>[ORG]</code>과 <code>익명 내담자가고</code> 조사 결함도 발견해 표시 전용 자연어 치환과 복합 조사 우선순위를 보강했고 session-review desktop/mobile <b>2/2</b>·typecheck·build를 통과했다. 당시에는 전체 GREEN receipt가 없어 자동 full runner를 등록하지 않았다.</p>
|
||||||
<p class="dg-note">197차 적용(2026-08-10): clean HEAD <code>4cd35488…</code>·tree <code>001a7c74…</code>의 6차 full run은 핵심 폐루프를 두 번째로 통과했고, 별도 returned-practice fixture도 초기 G4/G5 0을 증명했다. desktop G4/G5 2건은 GREEN이었지만 desktop이 만든 성공 상태를 mobile G4가 다시 읽을 때 테스트가 <code>반영 상태 다시 확인</code> 대신 최초 버튼을 찾아 5분 timeout, mobile G5는 serial 중단됐다. receipt <code>periodic-learner-e2e-20260810-012909.json</code>은 FAILED이며 public/NAS/active DB 접촉 0·exact cleanup GREEN이다. mobile 성공-state locator를 교정했고 실패 화면에서 추가로 확인한 강점·개선점·다음 문장·내담자 반응·워크시트 근거의 literal <code>[NAME]</code>/<code>[ORG]</code>도 저장/API는 유지한 채 표시층에서 자연어화했다. focused session-review desktop/mobile <b>2/2</b>·typecheck·live spec 4건 collection을 통과했지만 full runner는 재실행하지 않았다.</p>
|
<p class="dg-note">197차 적용(2026-08-10): clean HEAD <code>4cd35488…</code>·tree <code>001a7c74…</code>의 6차 full run은 핵심 폐루프를 두 번째로 통과했고, 별도 returned-practice fixture도 초기 G4/G5 0을 증명했다. desktop G4/G5 2건은 GREEN이었지만 desktop이 만든 성공 상태를 mobile G4가 다시 읽을 때 테스트가 <code>반영 상태 다시 확인</code> 대신 최초 버튼을 찾아 5분 timeout, mobile G5는 serial 중단됐다. receipt <code>periodic-learner-e2e-20260810-012909.json</code>은 FAILED이며 public/NAS/active DB 접촉 0·exact cleanup GREEN이다. mobile 성공-state locator를 교정했고 실패 화면에서 추가로 확인한 강점·개선점·다음 문장·내담자 반응·워크시트 근거의 literal <code>[NAME]</code>/<code>[ORG]</code>도 저장/API는 유지한 채 표시층에서 자연어화했다. focused session-review desktop/mobile <b>2/2</b>·typecheck·live spec 4건 collection을 통과했지만 full runner는 재실행하지 않았다.</p>
|
||||||
<p class="dg-note">198차 적용(2026-08-10): clean HEAD <code>aa81af29…</code>·tree <code>3901d71c…</code>의 7차 full run이 499.8초에 첫 전체 <b>GREEN</b> receipt <code>periodic-learner-e2e-20260810-020117.json</code>·SHA-256 <code>bae963de…f32e1a</code>를 만들었다. 실제 브라우저 same-learner session 생성·SSE·review ready·G4/G5 <code>0→1→1</code>, 별도 zero-state returned-practice desktop/mobile <b>4/4</b>, route mock 0을 통과했다. public 8001·active DB 55432·engine 9099·NAS 접촉은 0이며 전용 container 4·volume 3·network 1·engine PID와 6578-6581 listener는 cleanup 뒤 모두 0이다. 기존 <code>vignette-e2e</code> hourly heartbeat는 중복 생성 없이 갱신해 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 때만 preflight 뒤 full runner를 최대 1회 실행하고, 실패 시 같은 heartbeat에서 재시도하지 않는다. G7 human pack validator·runner/checker current 계약도 <b>97/97</b>로 재검증했으며 물리 마이크는 열지 않았다.</p>
|
<p class="dg-note">198차 적용(2026-08-10): clean HEAD <code>aa81af29…</code>·tree <code>3901d71c…</code>의 7차 full run이 499.8초에 첫 전체 <b>GREEN</b> receipt <code>periodic-learner-e2e-20260810-020117.json</code>·SHA-256 <code>bae963de…f32e1a</code>를 만들었다. 실제 브라우저 same-learner session 생성·SSE·review ready·G4/G5 <code>0→1→1</code>, 별도 zero-state returned-practice desktop/mobile <b>4/4</b>, route mock 0을 통과했다. public 8001·active DB 55432·engine 9099·NAS 접촉은 0이며 전용 container 4·volume 3·network 1·engine PID와 6578-6581 listener는 cleanup 뒤 모두 0이다. 기존 <code>vignette-e2e</code> hourly heartbeat는 중복 생성 없이 갱신해 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 때만 preflight 뒤 full runner를 최대 1회 실행하고, 실패 시 같은 heartbeat에서 재시도하지 않는다. G7 human pack evaluator·runner/checker와 후속 intake compiler 계약도 <b>103/103</b>으로 재검증했으며 물리 마이크는 열지 않았다.</p>
|
||||||
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
||||||
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
||||||
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
||||||
|
|
@ -929,7 +929,7 @@
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="doing" data-cat="Outcome OS·음성" data-owner="0">
|
<article class="scard" data-status="doing" data-cat="Outcome OS·음성" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">G7 GATE · INTERNAL DONE · EXTERNAL PROOF</span><span class="scard-mid"><span class="scard-title">Multimodal Alliance — 음성·비언어 동맹 신호</span><span class="scard-sum">clean 공개 runtime·인증 WSS 무마이크 rehearsal까지 닫았고, 동의 마이크·사람 평가 증거를 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">G7 GATE · INTERNAL DONE · EXTERNAL PROOF</span><span class="scard-mid"><span class="scard-title">Multimodal Alliance — 음성·비언어 동맹 신호</span><span class="scard-sum">clean 공개 runtime·인증 WSS 무마이크 rehearsal까지 닫았고, 동의 마이크·사람 평가 증거를 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
<div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. human pack은 category·κ·preregistration·paired completeness·결측 ITT를 캡처 전에 검증하며 관련 계약 97/97·runtime sampler 4/4를 통과했다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. pack은 <code>check-g7-human-voice-gain.py</code>를 먼저 통과해야 하며 부적합하면 마이크를 열기 전에 exit 2다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div>
|
<div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. human pack은 category·κ·preregistration·paired completeness·결측 ITT를 캡처 전에 검증한다. 새 intake compiler는 non-evidence 빈 틀에서 시작해 비식별 CSV의 production floor·ICC·κ·gain·CI를 재계산하고 모두 통과한 경우에만 pack을 쓰며 관련 계약은 103/103·runtime sampler 4/4다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 실제 독립 human voice-gain pack이다. 작성 도구만 준비됐고 사람 데이터는 아직 없다. pack은 <code>check-g7-human-voice-gain.py</code>를 먼저 통과해야 하며 부적합하면 마이크를 열기 전에 exit 2다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0">
|
<article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0">
|
||||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · CLEAN-HEAD NAS VERIFIED</span><span class="scard-mid"><span class="scard-title">Autonomous Content & Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback과 current clean source의 NAS-origin 112/112를 모두 닫았다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
<button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · CLEAN-HEAD NAS VERIFIED</span><span class="scard-mid"><span class="scard-title">Autonomous Content & Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback과 current clean source의 NAS-origin 112/112를 모두 닫았다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,9 @@ hourly heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material mile
|
||||||
- 2026-08-09 G7 human pack preflight: production runner는 캡처 전에 category·categorical κ≥0.70,
|
- 2026-08-09 G7 human pack preflight: production runner는 캡처 전에 category·categorical κ≥0.70,
|
||||||
preregistration 선행, 50회기/150축 complete pairing, 양 조건 결측 ITT를 검증한다. 독립 검수는
|
preregistration 선행, 50회기/150축 complete pairing, 양 조건 결측 ITT를 검증한다. 독립 검수는
|
||||||
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이며 `--print-schema`도 지원한다.
|
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이며 `--print-schema`도 지원한다.
|
||||||
부적합 pack은 마이크를 열기 전에 exit 2다. API evaluator 11/11 + G7 script 86/86, 합계 97/97 통과했다.
|
부적합 pack은 마이크를 열기 전에 exit 2다. `prepare-g7-human-voice-gain-intake.py`는 non-evidence 빈 템플릿과
|
||||||
|
strict CSV compiler를 제공하며 production gate 통과 전에는 pack을 쓰지 않는다. API evaluator 11/11 + G7 script
|
||||||
|
86/86 + intake compiler 6/6, 합계 103/103을 통과했다. 실제 사람 데이터가 없으므로 external GATE는 열려 있다.
|
||||||
- 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를
|
- 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를
|
||||||
통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로
|
통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로
|
||||||
바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는
|
바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@
|
||||||
참가자를 포함한 총 최소 31명/held-out 50회기/150축 독립 blind human-held-out voice-gain evaluator와 이를
|
참가자를 포함한 총 최소 31명/held-out 50회기/150축 독립 blind human-held-out voice-gain evaluator와 이를
|
||||||
같은 public host·동시 50분 시간창으로 묶는 canonical checker까지
|
같은 public host·동시 50분 시간창으로 묶는 canonical checker까지
|
||||||
code/internal 준비를 마쳤다.
|
code/internal 준비를 마쳤다.
|
||||||
|
독립 평가 입력은 `prepare-g7-human-voice-gain-intake.py --create-template`의 명시적 non-evidence 빈 틀로 시작하고,
|
||||||
|
`--compile ... --out ...`가 비식별 CSV의 production floor·ICC·κ·gain·CI를 재계산해 모두 통과할 때만 pack을
|
||||||
|
생성한다. 이 작성 도구는 준비됐지만 실제 참가자·독립 평가자 데이터는 아직 수집되지 않았다.
|
||||||
운영 TTS는 MeloTTS Korean(MIT) loopback으로 전환했고 외부 OpenAI adapter와 회기 전·중 AI 생성 음성 고지는
|
운영 TTS는 MeloTTS Korean(MIT) loopback으로 전환했고 외부 OpenAI adapter와 회기 전·중 AI 생성 음성 고지는
|
||||||
보존했다. clean public runtime·`local_whisper`/`melotts` exact ready·authenticated WSS의 30초 무마이크 rehearsal은
|
보존했다. clean public runtime·`local_whisper`/`melotts` exact ready·authenticated WSS의 30초 무마이크 rehearsal은
|
||||||
voice/runtime/topology 각 7 samples로 통과했다. 남은 범위: 실행 직전 장치 선택·명시 동의 물리 마이크와
|
voice/runtime/topology 각 7 samples로 통과했다. 남은 범위: 실행 직전 장치 선택·명시 동의 물리 마이크와
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,19 @@ runtime/topology interval 기본값은 child collector 상한과 같은 60초이
|
||||||
사람 데이터 운영자는 52분 실행과 독립적으로 pack을 먼저 검증할 수 있다. `--print-schema`는 authoritative JSON Schema를
|
사람 데이터 운영자는 52분 실행과 독립적으로 pack을 먼저 검증할 수 있다. `--print-schema`는 authoritative JSON Schema를
|
||||||
출력하고, 오류는 JSON pointer와 유형만 반환해 participant/labeler key·라벨 값·PII를 반사하지 않는다.
|
출력하고, 오류는 JSON pointer와 유형만 반환해 participant/labeler key·라벨 값·PII를 반사하지 않는다.
|
||||||
|
|
||||||
|
복잡한 nested JSON을 사람이 직접 조립하지 않도록 intake 도구를 제공한다. `--create-template`는 header-only CSV와
|
||||||
|
`template_only=true` manifest를 만들어 **증거로 사용할 수 없다**. 실제 외부 protocol·consent·split·labeling·analysis
|
||||||
|
artifact SHA와 비식별 participant/labeler/observation 수치를 채운 뒤 `--compile`하면 ICC·categorical κ와 production
|
||||||
|
floor·gain·CI를 행에서 다시 계산한다. 모든 gate가 통과한 경우에만 기존 파일을 덮어쓰지 않고 최종 pack을 원자 생성한다.
|
||||||
|
이름·이메일·원음·축어록·자유서술 열은 받지 않으며 콘솔에도 입력 경로·키·라벨·잘못된 셀 값을 출력하지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& $py -X utf8 -B scripts/prepare-g7-human-voice-gain-intake.py --create-template <intake-dir>
|
||||||
|
# 실제 독립 평가팀이 manifest와 CSV를 채운 뒤에만 실행
|
||||||
|
& $py -X utf8 -B scripts/prepare-g7-human-voice-gain-intake.py `
|
||||||
|
--compile <intake-dir> --out <pack.json>
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --print-schema
|
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --print-schema
|
||||||
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>
|
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>
|
||||||
|
|
|
||||||
578
scripts/prepare-g7-human-voice-gain-intake.py
Normal file
578
scripts/prepare-g7-human-voice-gain-intake.py
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare or compile a deidentified G7 human voice-gain intake.
|
||||||
|
|
||||||
|
Template mode creates empty, explicitly non-evidence input files. Compile mode
|
||||||
|
accepts only deidentified metrics, recomputes reliability and production gates,
|
||||||
|
and writes a final pack only when every production requirement passes. Console
|
||||||
|
output never includes input paths, participant/labeler keys, labels, or values
|
||||||
|
from invalid cells.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
API_ROOT = REPO_ROOT / "apps/api"
|
||||||
|
if str(API_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(API_ROOT))
|
||||||
|
|
||||||
|
from app.contracts.g7_external_evidence import ( # noqa: E402
|
||||||
|
G7HumanVoiceGainEvidencePack,
|
||||||
|
)
|
||||||
|
from app.services.g7_voice_gain_evidence import ( # noqa: E402
|
||||||
|
evaluate_human_voice_gain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
PARTICIPANTS_NAME = "participants.csv"
|
||||||
|
LABELERS_NAME = "labelers.csv"
|
||||||
|
OBSERVATIONS_NAME = "observations.csv"
|
||||||
|
README_NAME = "README.md"
|
||||||
|
INTAKE_SCHEMA_VERSION = "g7_human_voice_gain_intake_v1"
|
||||||
|
|
||||||
|
MANIFEST_KEYS = {
|
||||||
|
"intake_schema_version",
|
||||||
|
"template_only",
|
||||||
|
"provenance",
|
||||||
|
"text_only_model",
|
||||||
|
"voice_enabled_model",
|
||||||
|
"power_plan",
|
||||||
|
"reported_icc",
|
||||||
|
"reported_categorical_kappa",
|
||||||
|
"reliability_report_sha256",
|
||||||
|
}
|
||||||
|
PARTICIPANT_FIELDS = (
|
||||||
|
"participant_key",
|
||||||
|
"split",
|
||||||
|
"consent_receipt_sha256",
|
||||||
|
)
|
||||||
|
LABELER_FIELDS = (
|
||||||
|
"labeler_key",
|
||||||
|
"blinded_to_model_condition",
|
||||||
|
"blinded_to_other_labelers",
|
||||||
|
"labeled_independently",
|
||||||
|
"attestation_sha256",
|
||||||
|
)
|
||||||
|
OBSERVATION_FIELDS = (
|
||||||
|
"observation_id",
|
||||||
|
"participant_key",
|
||||||
|
"session_key",
|
||||||
|
"axis",
|
||||||
|
"text_only_status",
|
||||||
|
"text_only_score",
|
||||||
|
"voice_enabled_status",
|
||||||
|
"voice_enabled_score",
|
||||||
|
"labeler_key",
|
||||||
|
"label_score",
|
||||||
|
"label_category",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEIDENTIFIED_PARTICIPANT_RE = re.compile(
|
||||||
|
r"^(?:calibration|held)-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$"
|
||||||
|
)
|
||||||
|
DEIDENTIFIED_LABELER_RE = re.compile(r"^labeler-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
|
||||||
|
DEIDENTIFIED_SESSION_RE = re.compile(r"^session-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IntakeError(Exception):
|
||||||
|
code: str
|
||||||
|
source: str = "intake"
|
||||||
|
row: int | None = None
|
||||||
|
field: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _base_report() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "vignette.g7-human-voice-gain-intake.v1",
|
||||||
|
"passed": False,
|
||||||
|
"pack_written": False,
|
||||||
|
"clinical_claim_allowed": False,
|
||||||
|
"privacy_boundary": {
|
||||||
|
"input_paths_logged": False,
|
||||||
|
"participant_keys_logged": False,
|
||||||
|
"labeler_keys_logged": False,
|
||||||
|
"labels_logged": False,
|
||||||
|
"raw_cell_values_logged": False,
|
||||||
|
"raw_audio_or_transcript_accepted": False,
|
||||||
|
},
|
||||||
|
"errors": [],
|
||||||
|
"result": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_error(error: IntakeError) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"code": error.code, "source": error.source}
|
||||||
|
if error.row is not None:
|
||||||
|
result["row"] = error.row
|
||||||
|
if error.field is not None:
|
||||||
|
result["field"] = error.field
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _json_pointer(location: tuple[int | str, ...]) -> str:
|
||||||
|
if not location:
|
||||||
|
return "/"
|
||||||
|
return "/" + "/".join(
|
||||||
|
str(item).replace("~", "~0").replace("/", "~1") for item in location
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validation_errors(error: ValidationError) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": f"contract:{item['type']}",
|
||||||
|
"source": "compiled_pack",
|
||||||
|
"pointer": _json_pointer(tuple(item["loc"])),
|
||||||
|
}
|
||||||
|
for item in error.errors(
|
||||||
|
include_url=False,
|
||||||
|
include_context=False,
|
||||||
|
include_input=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_exact_keys(payload: dict[str, Any]) -> None:
|
||||||
|
if set(payload) != MANIFEST_KEYS:
|
||||||
|
raise IntakeError("manifest_fields_invalid", source=MANIFEST_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest(directory: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise IntakeError(
|
||||||
|
f"manifest_read:{type(exc).__name__}", source=MANIFEST_NAME
|
||||||
|
) from None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise IntakeError("manifest_object_required", source=MANIFEST_NAME)
|
||||||
|
_require_exact_keys(value)
|
||||||
|
if value.get("intake_schema_version") != INTAKE_SCHEMA_VERSION:
|
||||||
|
raise IntakeError("intake_schema_version_invalid", source=MANIFEST_NAME)
|
||||||
|
if value.get("template_only") is not False:
|
||||||
|
raise IntakeError("template_cannot_compile", source=MANIFEST_NAME)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv(
|
||||||
|
directory: Path, filename: str, fields: tuple[str, ...]
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
with (directory / filename).open(
|
||||||
|
"r", encoding="utf-8-sig", newline=""
|
||||||
|
) as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if reader.fieldnames != list(fields):
|
||||||
|
raise IntakeError("csv_headers_invalid", source=filename)
|
||||||
|
rows = []
|
||||||
|
for row_number, row in enumerate(reader, start=2):
|
||||||
|
if None in row or any(value is None for value in row.values()):
|
||||||
|
raise IntakeError(
|
||||||
|
"csv_shape_invalid", source=filename, row=row_number
|
||||||
|
)
|
||||||
|
if any(len(value) > 256 for value in row.values()):
|
||||||
|
raise IntakeError(
|
||||||
|
"csv_cell_too_long", source=filename, row=row_number
|
||||||
|
)
|
||||||
|
rows.append(dict(row))
|
||||||
|
except IntakeError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnicodeError, csv.Error) as exc:
|
||||||
|
raise IntakeError(f"csv_read:{type(exc).__name__}", source=filename) from None
|
||||||
|
if not rows:
|
||||||
|
raise IntakeError("csv_rows_required", source=filename)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_true(value: str, *, source: str, row: int, field: str) -> bool:
|
||||||
|
if value != "true":
|
||||||
|
raise IntakeError("literal_true_required", source, row, field)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_score(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
nullable: bool,
|
||||||
|
source: str,
|
||||||
|
row: int,
|
||||||
|
field: str,
|
||||||
|
) -> float | None:
|
||||||
|
if value == "" and nullable:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except ValueError:
|
||||||
|
raise IntakeError("score_invalid", source, row, field) from None
|
||||||
|
if not 0.0 <= result <= 1.0:
|
||||||
|
raise IntakeError("score_out_of_range", source, row, field)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_participants(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
||||||
|
participants = []
|
||||||
|
for row_number, row in enumerate(rows, start=2):
|
||||||
|
key = row["participant_key"]
|
||||||
|
split = row["split"]
|
||||||
|
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(key) is None:
|
||||||
|
raise IntakeError(
|
||||||
|
"deidentified_participant_key_required",
|
||||||
|
PARTICIPANTS_NAME,
|
||||||
|
row_number,
|
||||||
|
"participant_key",
|
||||||
|
)
|
||||||
|
expected_prefix = "calibration-" if split == "calibration" else "held-"
|
||||||
|
if split not in {"calibration", "held_out"} or not key.startswith(
|
||||||
|
expected_prefix
|
||||||
|
):
|
||||||
|
raise IntakeError(
|
||||||
|
"participant_split_key_mismatch",
|
||||||
|
PARTICIPANTS_NAME,
|
||||||
|
row_number,
|
||||||
|
"split",
|
||||||
|
)
|
||||||
|
participants.append(row)
|
||||||
|
return participants
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_labelers(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
||||||
|
labelers = []
|
||||||
|
for row_number, row in enumerate(rows, start=2):
|
||||||
|
key = row["labeler_key"]
|
||||||
|
if DEIDENTIFIED_LABELER_RE.fullmatch(key) is None:
|
||||||
|
raise IntakeError(
|
||||||
|
"deidentified_labeler_key_required",
|
||||||
|
LABELERS_NAME,
|
||||||
|
row_number,
|
||||||
|
"labeler_key",
|
||||||
|
)
|
||||||
|
labelers.append(
|
||||||
|
{
|
||||||
|
"labeler_key": key,
|
||||||
|
"blinded_to_model_condition": _parse_true(
|
||||||
|
row["blinded_to_model_condition"],
|
||||||
|
source=LABELERS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="blinded_to_model_condition",
|
||||||
|
),
|
||||||
|
"blinded_to_other_labelers": _parse_true(
|
||||||
|
row["blinded_to_other_labelers"],
|
||||||
|
source=LABELERS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="blinded_to_other_labelers",
|
||||||
|
),
|
||||||
|
"labeled_independently": _parse_true(
|
||||||
|
row["labeled_independently"],
|
||||||
|
source=LABELERS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="labeled_independently",
|
||||||
|
),
|
||||||
|
"attestation_sha256": row["attestation_sha256"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return labelers
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_observations(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
||||||
|
order: list[tuple[str, str, str, str]] = []
|
||||||
|
for row_number, row in enumerate(rows, start=2):
|
||||||
|
participant_key = row["participant_key"]
|
||||||
|
session_key = row["session_key"]
|
||||||
|
labeler_key = row["labeler_key"]
|
||||||
|
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(participant_key) is None:
|
||||||
|
raise IntakeError(
|
||||||
|
"deidentified_participant_key_required",
|
||||||
|
OBSERVATIONS_NAME,
|
||||||
|
row_number,
|
||||||
|
"participant_key",
|
||||||
|
)
|
||||||
|
if DEIDENTIFIED_SESSION_RE.fullmatch(session_key) is None:
|
||||||
|
raise IntakeError(
|
||||||
|
"deidentified_session_key_required",
|
||||||
|
OBSERVATIONS_NAME,
|
||||||
|
row_number,
|
||||||
|
"session_key",
|
||||||
|
)
|
||||||
|
if DEIDENTIFIED_LABELER_RE.fullmatch(labeler_key) is None:
|
||||||
|
raise IntakeError(
|
||||||
|
"deidentified_labeler_key_required",
|
||||||
|
OBSERVATIONS_NAME,
|
||||||
|
row_number,
|
||||||
|
"labeler_key",
|
||||||
|
)
|
||||||
|
key = (
|
||||||
|
row["observation_id"],
|
||||||
|
participant_key,
|
||||||
|
session_key,
|
||||||
|
row["axis"],
|
||||||
|
)
|
||||||
|
core = {
|
||||||
|
"observation_id": row["observation_id"],
|
||||||
|
"participant_key": participant_key,
|
||||||
|
"session_key": session_key,
|
||||||
|
"axis": row["axis"],
|
||||||
|
"text_only_status": row["text_only_status"],
|
||||||
|
"text_only_score": _parse_score(
|
||||||
|
row["text_only_score"],
|
||||||
|
nullable=True,
|
||||||
|
source=OBSERVATIONS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="text_only_score",
|
||||||
|
),
|
||||||
|
"voice_enabled_status": row["voice_enabled_status"],
|
||||||
|
"voice_enabled_score": _parse_score(
|
||||||
|
row["voice_enabled_score"],
|
||||||
|
nullable=True,
|
||||||
|
source=OBSERVATIONS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="voice_enabled_score",
|
||||||
|
),
|
||||||
|
"raw_audio_included": False,
|
||||||
|
"transcript_included": False,
|
||||||
|
}
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = {**core, "labels": []}
|
||||||
|
order.append(key)
|
||||||
|
elif any(grouped[key][name] != value for name, value in core.items()):
|
||||||
|
raise IntakeError(
|
||||||
|
"observation_core_mismatch", OBSERVATIONS_NAME, row_number
|
||||||
|
)
|
||||||
|
grouped[key]["labels"].append(
|
||||||
|
{
|
||||||
|
"labeler_key": labeler_key,
|
||||||
|
"score": _parse_score(
|
||||||
|
row["label_score"],
|
||||||
|
nullable=False,
|
||||||
|
source=OBSERVATIONS_NAME,
|
||||||
|
row=row_number,
|
||||||
|
field="label_score",
|
||||||
|
),
|
||||||
|
"category": row["label_category"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [grouped[key] for key in order]
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_pack(directory: Path) -> dict[str, Any]:
|
||||||
|
manifest = _read_manifest(directory)
|
||||||
|
participants = _parse_participants(
|
||||||
|
_read_csv(directory, PARTICIPANTS_NAME, PARTICIPANT_FIELDS)
|
||||||
|
)
|
||||||
|
labelers = _parse_labelers(_read_csv(directory, LABELERS_NAME, LABELER_FIELDS))
|
||||||
|
observations = _parse_observations(
|
||||||
|
_read_csv(directory, OBSERVATIONS_NAME, OBSERVATION_FIELDS)
|
||||||
|
)
|
||||||
|
labeler_keys = [item["labeler_key"] for item in labelers]
|
||||||
|
return {
|
||||||
|
"provenance": manifest["provenance"],
|
||||||
|
"text_only_model": manifest["text_only_model"],
|
||||||
|
"voice_enabled_model": manifest["voice_enabled_model"],
|
||||||
|
"power_plan": manifest["power_plan"],
|
||||||
|
"participants": participants,
|
||||||
|
"labeler_attestations": labelers,
|
||||||
|
"reliability": {
|
||||||
|
"labeler_keys": labeler_keys,
|
||||||
|
"reported_icc": manifest["reported_icc"],
|
||||||
|
"reported_categorical_kappa": manifest["reported_categorical_kappa"],
|
||||||
|
"report_sha256": manifest["reliability_report_sha256"],
|
||||||
|
},
|
||||||
|
"observations": observations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compile_intake(directory: Path) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||||
|
report = _base_report()
|
||||||
|
try:
|
||||||
|
payload = _draft_pack(directory)
|
||||||
|
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||||
|
result = evaluate_human_voice_gain(pack)
|
||||||
|
except IntakeError as exc:
|
||||||
|
report["errors"] = [_safe_error(exc)]
|
||||||
|
return report, None
|
||||||
|
except ValidationError as exc:
|
||||||
|
report["errors"] = _validation_errors(exc)
|
||||||
|
return report, None
|
||||||
|
except Exception as exc:
|
||||||
|
report["errors"] = [
|
||||||
|
{"code": f"evaluation:{type(exc).__name__}", "source": "compiled_pack"}
|
||||||
|
]
|
||||||
|
return report, None
|
||||||
|
|
||||||
|
report["passed"] = result.passed
|
||||||
|
report["result"] = result.model_dump(mode="json")
|
||||||
|
if not result.passed:
|
||||||
|
return report, None
|
||||||
|
return report, pack.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_create_json(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
if path.exists():
|
||||||
|
raise IntakeError("output_exists", source="output")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
dir=path.parent,
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary = Path(handle.name)
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
try:
|
||||||
|
os.link(temporary, path)
|
||||||
|
except FileExistsError:
|
||||||
|
raise IntakeError("output_exists", source="output") from None
|
||||||
|
except OSError as exc:
|
||||||
|
raise IntakeError(
|
||||||
|
f"output_link:{type(exc).__name__}", source="output"
|
||||||
|
) from None
|
||||||
|
try:
|
||||||
|
temporary.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise IntakeError(
|
||||||
|
f"output_cleanup:{type(exc).__name__}", source="output"
|
||||||
|
) from None
|
||||||
|
temporary = None
|
||||||
|
finally:
|
||||||
|
if temporary is not None:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_template(directory: Path) -> None:
|
||||||
|
if directory.exists():
|
||||||
|
raise IntakeError("template_directory_exists", source="template")
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
manifest = {
|
||||||
|
"intake_schema_version": INTAKE_SCHEMA_VERSION,
|
||||||
|
"template_only": True,
|
||||||
|
"provenance": {
|
||||||
|
"protocol_sha256": "",
|
||||||
|
"consent_protocol_sha256": "",
|
||||||
|
"dataset_manifest_sha256": "",
|
||||||
|
"split_manifest_sha256": "",
|
||||||
|
"labeling_protocol_sha256": "",
|
||||||
|
"analysis_plan_sha256": "",
|
||||||
|
"registered_at": "",
|
||||||
|
"held_out_labels_opened_at": "",
|
||||||
|
},
|
||||||
|
"text_only_model": {
|
||||||
|
"role": "text_only_baseline",
|
||||||
|
"provider": "",
|
||||||
|
"model_id": "",
|
||||||
|
"model_version": "",
|
||||||
|
"artifact_sha256": "",
|
||||||
|
"configuration_sha256": "",
|
||||||
|
},
|
||||||
|
"voice_enabled_model": {
|
||||||
|
"role": "voice_enabled_candidate",
|
||||||
|
"provider": "",
|
||||||
|
"model_id": "",
|
||||||
|
"model_version": "",
|
||||||
|
"artifact_sha256": "",
|
||||||
|
"configuration_sha256": "",
|
||||||
|
},
|
||||||
|
"power_plan": {
|
||||||
|
"primary_metric": "paired_one_minus_mae_gain",
|
||||||
|
"clustering_unit": "participant",
|
||||||
|
"required_held_out_participants": 30,
|
||||||
|
"required_held_out_sessions": 50,
|
||||||
|
"required_paired_axis_observations": 150,
|
||||||
|
"alpha": 0.05,
|
||||||
|
"target_power": 0.8,
|
||||||
|
"minimally_detectable_gain": 0.01,
|
||||||
|
"planned_bootstrap_samples": 10000,
|
||||||
|
},
|
||||||
|
"reported_icc": None,
|
||||||
|
"reported_categorical_kappa": None,
|
||||||
|
"reliability_report_sha256": "",
|
||||||
|
}
|
||||||
|
(directory / MANIFEST_NAME).write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
for filename, fields in (
|
||||||
|
(PARTICIPANTS_NAME, PARTICIPANT_FIELDS),
|
||||||
|
(LABELERS_NAME, LABELER_FIELDS),
|
||||||
|
(OBSERVATIONS_NAME, OBSERVATION_FIELDS),
|
||||||
|
):
|
||||||
|
with (directory / filename).open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
csv.writer(handle, lineterminator="\n").writerow(fields)
|
||||||
|
(directory / README_NAME).write_text(
|
||||||
|
"# G7 human voice-gain intake\n\n"
|
||||||
|
"이 디렉터리는 빈 입력 틀이지 증거가 아니다. `manifest.json`의 모든 provenance를 "
|
||||||
|
"실제 외부 연구 산출물 SHA-256으로 채우고 `template_only`를 `false`로 바꿔라. "
|
||||||
|
"CSV에는 비식별 키와 수치만 넣고 이름, 이메일, 원음, 축어록, 자유서술을 넣지 마라. "
|
||||||
|
"각 held-out 회기는 goal/task/bond 3축과 동일한 blind labeler panel을 가져야 한다.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
result = argparse.ArgumentParser(description=__doc__)
|
||||||
|
mode = result.add_mutually_exclusive_group(required=True)
|
||||||
|
mode.add_argument("--create-template", type=Path, metavar="DIR")
|
||||||
|
mode.add_argument("--compile", type=Path, metavar="DIR")
|
||||||
|
result.add_argument("--out", type=Path, help="final pack JSON; compile only")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Iterable[str] | None = None) -> int:
|
||||||
|
args = parser().parse_args(list(argv) if argv is not None else None)
|
||||||
|
report = _base_report()
|
||||||
|
if args.create_template is not None:
|
||||||
|
if args.out is not None:
|
||||||
|
parser().error("--out is compile-only")
|
||||||
|
try:
|
||||||
|
create_template(args.create_template)
|
||||||
|
except IntakeError as exc:
|
||||||
|
report["errors"] = [_safe_error(exc)]
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return 1
|
||||||
|
report["template_created"] = True
|
||||||
|
report["template_is_evidence"] = False
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.out is None:
|
||||||
|
parser().error("--compile requires --out")
|
||||||
|
report, pack_payload = compile_intake(args.compile)
|
||||||
|
if pack_payload is not None:
|
||||||
|
try:
|
||||||
|
_atomic_create_json(args.out, pack_payload)
|
||||||
|
except IntakeError as exc:
|
||||||
|
report["passed"] = False
|
||||||
|
report["errors"] = [_safe_error(exc)]
|
||||||
|
else:
|
||||||
|
report["pack_written"] = True
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return 0 if report["passed"] is True and report["pack_written"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
270
scripts/test_prepare_g7_human_voice_gain_intake.py
Normal file
270
scripts/test_prepare_g7_human_voice_gain_intake.py
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import csv
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
API_ROOT = REPO_ROOT / "apps/api"
|
||||||
|
if str(API_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(API_ROOT))
|
||||||
|
|
||||||
|
from app.contracts.g7_external_evidence import ( # noqa: E402
|
||||||
|
G7HumanVoiceGainEvidencePack,
|
||||||
|
)
|
||||||
|
from app.test_g7_voice_gain_evidence import _valid_payload # noqa: E402
|
||||||
|
from scripts.test_g7_external_proof import human_pack # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_PATH = Path(__file__).with_name("prepare-g7-human-voice-gain-intake.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"prepare_g7_human_voice_gain_intake", SCRIPT_PATH
|
||||||
|
)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = MODULE
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(
|
||||||
|
path: Path, fields: tuple[str, ...], rows: list[dict[str, object]]
|
||||||
|
) -> None:
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fields, lineterminator="\n")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_intake(directory: Path, payload: dict[str, object]) -> None:
|
||||||
|
reliability = payload["reliability"]
|
||||||
|
assert isinstance(reliability, dict)
|
||||||
|
manifest = {
|
||||||
|
"intake_schema_version": MODULE.INTAKE_SCHEMA_VERSION,
|
||||||
|
"template_only": False,
|
||||||
|
"provenance": payload["provenance"],
|
||||||
|
"text_only_model": payload["text_only_model"],
|
||||||
|
"voice_enabled_model": payload["voice_enabled_model"],
|
||||||
|
"power_plan": payload["power_plan"],
|
||||||
|
"reported_icc": reliability["reported_icc"],
|
||||||
|
"reported_categorical_kappa": reliability["reported_categorical_kappa"],
|
||||||
|
"reliability_report_sha256": reliability["report_sha256"],
|
||||||
|
}
|
||||||
|
(directory / MODULE.MANIFEST_NAME).write_text(
|
||||||
|
json.dumps(manifest), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
participants = payload["participants"]
|
||||||
|
assert isinstance(participants, list)
|
||||||
|
_write_csv(
|
||||||
|
directory / MODULE.PARTICIPANTS_NAME,
|
||||||
|
MODULE.PARTICIPANT_FIELDS,
|
||||||
|
participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
attestations = payload["labeler_attestations"]
|
||||||
|
assert isinstance(attestations, list)
|
||||||
|
labeler_rows = []
|
||||||
|
for item in attestations:
|
||||||
|
assert isinstance(item, dict)
|
||||||
|
labeler_rows.append(
|
||||||
|
{
|
||||||
|
"labeler_key": item["labeler_key"],
|
||||||
|
"blinded_to_model_condition": "true",
|
||||||
|
"blinded_to_other_labelers": "true",
|
||||||
|
"labeled_independently": "true",
|
||||||
|
"attestation_sha256": item["attestation_sha256"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_csv(
|
||||||
|
directory / MODULE.LABELERS_NAME,
|
||||||
|
MODULE.LABELER_FIELDS,
|
||||||
|
labeler_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
observations = payload["observations"]
|
||||||
|
assert isinstance(observations, list)
|
||||||
|
observation_rows = []
|
||||||
|
for observation in observations:
|
||||||
|
assert isinstance(observation, dict)
|
||||||
|
labels = observation["labels"]
|
||||||
|
assert isinstance(labels, list)
|
||||||
|
for label in labels:
|
||||||
|
assert isinstance(label, dict)
|
||||||
|
observation_rows.append(
|
||||||
|
{
|
||||||
|
"observation_id": observation["observation_id"],
|
||||||
|
"participant_key": observation["participant_key"],
|
||||||
|
"session_key": observation["session_key"],
|
||||||
|
"axis": observation["axis"],
|
||||||
|
"text_only_status": observation["text_only_status"],
|
||||||
|
"text_only_score": (
|
||||||
|
""
|
||||||
|
if observation.get("text_only_score") is None
|
||||||
|
else observation["text_only_score"]
|
||||||
|
),
|
||||||
|
"voice_enabled_status": observation["voice_enabled_status"],
|
||||||
|
"voice_enabled_score": (
|
||||||
|
""
|
||||||
|
if observation.get("voice_enabled_score") is None
|
||||||
|
else observation["voice_enabled_score"]
|
||||||
|
),
|
||||||
|
"labeler_key": label["labeler_key"],
|
||||||
|
"label_score": label["score"],
|
||||||
|
"label_category": label["category"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_csv(
|
||||||
|
directory / MODULE.OBSERVATIONS_NAME,
|
||||||
|
MODULE.OBSERVATION_FIELDS,
|
||||||
|
observation_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class G7HumanVoiceGainIntakeTests(unittest.TestCase):
|
||||||
|
def test_empty_template_is_explicitly_not_evidence(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
target = Path(root) / "intake"
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
exit_code = MODULE.main(["--create-template", str(target)])
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
MODULE.MANIFEST_NAME,
|
||||||
|
MODULE.PARTICIPANTS_NAME,
|
||||||
|
MODULE.LABELERS_NAME,
|
||||||
|
MODULE.OBSERVATIONS_NAME,
|
||||||
|
MODULE.README_NAME,
|
||||||
|
},
|
||||||
|
{item.name for item in target.iterdir()},
|
||||||
|
)
|
||||||
|
manifest = json.loads(
|
||||||
|
(target / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertIs(True, manifest["template_only"])
|
||||||
|
self.assertFalse(json.loads(output.getvalue())["template_is_evidence"])
|
||||||
|
|
||||||
|
report, pack = MODULE.compile_intake(target)
|
||||||
|
self.assertIsNone(pack)
|
||||||
|
self.assertEqual("template_cannot_compile", report["errors"][0]["code"])
|
||||||
|
|
||||||
|
def test_production_intake_compiles_and_checks_reported_reliability(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
directory = Path(root) / "private-human-intake"
|
||||||
|
directory.mkdir()
|
||||||
|
payload = human_pack()
|
||||||
|
_write_intake(directory, payload)
|
||||||
|
output_path = Path(root) / "compiled.json"
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(stdout):
|
||||||
|
exit_code = MODULE.main(
|
||||||
|
["--compile", str(directory), "--out", str(output_path)]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
report = json.loads(stdout.getvalue())
|
||||||
|
self.assertTrue(report["passed"])
|
||||||
|
self.assertTrue(report["pack_written"])
|
||||||
|
self.assertEqual(30, report["result"]["held_out_participants"])
|
||||||
|
serialized_report = json.dumps(report, ensure_ascii=False)
|
||||||
|
self.assertNotIn("private-human-intake", serialized_report)
|
||||||
|
self.assertNotIn("held-000", serialized_report)
|
||||||
|
self.assertNotIn("labeler-001", serialized_report)
|
||||||
|
|
||||||
|
compiled = json.loads(output_path.read_text(encoding="utf-8"))
|
||||||
|
pack = G7HumanVoiceGainEvidencePack.model_validate(compiled)
|
||||||
|
self.assertEqual(1.0, pack.reliability.reported_icc)
|
||||||
|
self.assertEqual(1.0, pack.reliability.reported_categorical_kappa)
|
||||||
|
self.assertTrue(
|
||||||
|
all(not item.raw_audio_included for item in pack.observations)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(not item.transcript_included for item in pack.observations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_underpowered_intake_never_writes_a_pack(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
directory = Path(root) / "intake"
|
||||||
|
directory.mkdir()
|
||||||
|
_write_intake(directory, _valid_payload())
|
||||||
|
output_path = Path(root) / "compiled.json"
|
||||||
|
|
||||||
|
report, pack = MODULE.compile_intake(directory)
|
||||||
|
|
||||||
|
self.assertIsNone(pack)
|
||||||
|
self.assertFalse(report["passed"])
|
||||||
|
self.assertIn(
|
||||||
|
"production_participant_floor",
|
||||||
|
report["result"]["failure_reasons"],
|
||||||
|
)
|
||||||
|
self.assertFalse(output_path.exists())
|
||||||
|
|
||||||
|
def test_reported_reliability_must_match_rows(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
directory = Path(root) / "intake"
|
||||||
|
directory.mkdir()
|
||||||
|
payload = human_pack()
|
||||||
|
reliability = payload["reliability"]
|
||||||
|
assert isinstance(reliability, dict)
|
||||||
|
reliability["reported_icc"] = 0.8
|
||||||
|
_write_intake(directory, payload)
|
||||||
|
|
||||||
|
report, pack = MODULE.compile_intake(directory)
|
||||||
|
|
||||||
|
self.assertIsNone(pack)
|
||||||
|
self.assertFalse(report["passed"])
|
||||||
|
self.assertIn(
|
||||||
|
"reported_icc_matches_rows",
|
||||||
|
report["result"]["failure_reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unexpected_raw_material_column_is_rejected_without_echo(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
directory = Path(root) / "person-at-example.test"
|
||||||
|
directory.mkdir()
|
||||||
|
_write_intake(directory, human_pack())
|
||||||
|
path = directory / MODULE.OBSERVATIONS_NAME
|
||||||
|
rows = list(csv.reader(path.read_text(encoding="utf-8").splitlines()))
|
||||||
|
rows[0].append("raw_transcript")
|
||||||
|
rows[1].append("private words")
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
csv.writer(handle, lineterminator="\n").writerows(rows)
|
||||||
|
|
||||||
|
report, pack = MODULE.compile_intake(directory)
|
||||||
|
|
||||||
|
self.assertIsNone(pack)
|
||||||
|
self.assertEqual("csv_headers_invalid", report["errors"][0]["code"])
|
||||||
|
serialized = json.dumps(report, ensure_ascii=False)
|
||||||
|
self.assertNotIn("private words", serialized)
|
||||||
|
self.assertNotIn("person-at-example.test", serialized)
|
||||||
|
|
||||||
|
def test_existing_output_is_not_overwritten(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
directory = Path(root) / "intake"
|
||||||
|
directory.mkdir()
|
||||||
|
_write_intake(directory, human_pack())
|
||||||
|
output_path = Path(root) / "compiled.json"
|
||||||
|
output_path.write_text("preserve", encoding="utf-8")
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(stdout):
|
||||||
|
exit_code = MODULE.main(
|
||||||
|
["--compile", str(directory), "--out", str(output_path)]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, exit_code)
|
||||||
|
self.assertEqual("preserve", output_path.read_text(encoding="utf-8"))
|
||||||
|
report = json.loads(stdout.getvalue())
|
||||||
|
self.assertEqual("output_exists", report["errors"][0]["code"])
|
||||||
|
self.assertFalse(report["pack_written"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue