"""Self-contained presentation snapshots; canonical contracts remain in repository Markdown."""
from pathlib import Path
from html import escape
import json,shutil,hashlib,datetime
ROOT=Path(__file__).resolve().parent
REPO=Path('/workspace/contextra-analytics-runtime-lifecycle-refactor')
PRESENT=Path('/workspace/architecture_docs/topics/contextra/20261101_fight_fall_web_handoff')
EXPORT=ROOT/'Contextra_Web_Handoff_20261101'
now=datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))).strftime('%Y-%m-%d %H:%M:%S KST')
assert json.loads((ROOT/'evidence/verified.json').read_text())['sourceVerified']
if EXPORT.exists():
 old=json.loads((ROOT/'web-packet-manifest.json').read_text())
 assert all(hashlib.sha256((EXPORT/n).read_bytes()).hexdigest()==h for n,h in old['files'].items())
 if not (ROOT/'web-packet-manifest.before-css.json').exists():shutil.copy2(ROOT/'web-packet-manifest.json',ROOT/'web-packet-manifest.before-css.json')
else:PRESENT.mkdir(exist_ok=False);EXPORT.mkdir(exist_ok=False)

CSS='''
:root{color-scheme:light;font-family:Arial,"Malgun Gothic",sans-serif;color:#20282a;background:#fff;letter-spacing:0}
*{box-sizing:border-box}body{margin:0;font-size:16px;line-height:1.65;overflow-wrap:anywhere}main{max-width:1080px;margin:auto;padding:30px 28px 70px}
.masthead{border-bottom:3px solid #137b68;padding-bottom:18px}.kicker{font-size:13px;font-weight:700;color:#356257;margin:0}
h1{font-size:32px;line-height:1.25;margin:10px 0 14px}h2{font-size:23px;line-height:1.4;margin:0 0 16px}h3{font-size:18px;margin:22px 0 10px}
p{margin:10px 0}a{color:#086858;text-underline-offset:3px}a:focus-visible,summary:focus-visible{outline:3px solid #1169bb;outline-offset:3px}
nav{display:flex;flex-wrap:wrap;gap:12px 22px;padding:16px 0;border-bottom:1px solid #c9d3d1;font-size:14px}
section{padding:28px 0;border-bottom:1px solid #d7dfdc;scroll-margin-top:12px}.status{border-left:5px solid #137b68;padding:8px 16px;background:#f0f8f5}
.warning{border-left:5px solid #ba5d19;padding:8px 16px;background:#fff7ed}.muted{color:#586461;font-size:14px}
ul,ol{padding-left:24px;margin:12px 0}li{margin:7px 0}code{font-family:Consolas,monospace;font-size:14px;overflow-wrap:anywhere}
pre{background:#f1f5f4;border:1px solid #ccd8d3;border-radius:4px;padding:16px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.5}
.table-scroll{max-width:100%;overflow:auto;border:1px solid #ccd8d3}table{border-collapse:collapse;width:100%;min-width:640px;font-size:14px}th,td{text-align:left;vertical-align:top;padding:12px;border-bottom:1px solid #dce4e0}th{background:#eef4f1;font-weight:700}tr:last-child td{border-bottom:0}
.flow{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;list-style:none;padding:0;counter-reset:step}.flow li{border-top:3px solid #137b68;padding:12px 6px 0;margin:0}.flow strong{display:block;font-size:16px}.flow span{font-size:14px;color:#4d5a56}
.legend{display:flex;gap:18px;flex-wrap:wrap;align-items:center}.swatch{display:inline-block;width:18px;height:18px;border:1px solid #59635f;vertical-align:middle;margin-right:7px}.red{background:#ff3030}.neutral{background:#ddd}.chip{font-size:13px;font-weight:700;color:#16624c}
details{padding:10px 0}summary{cursor:pointer;font-weight:700}footer{font-size:13px;color:#53625c;padding-top:24px;overflow-wrap:anywhere}
@media(max-width:600px){main{padding:20px 16px 42px}h1{font-size:28px}h2{font-size:21px}.flow{grid-template-columns:repeat(2,minmax(0,1fr))}nav{gap:10px 16px}}
@media print{main{max-width:none;padding:0}nav{display:none}section{break-inside:avoid}pre{white-space:pre-wrap}.table-scroll{overflow:visible}table{min-width:0;font-size:11px}a{color:inherit}}
'''
def table(headers,rows):
 return '<div class="table-scroll" tabindex="0" aria-label="설정 표"><table><thead><tr>'+''.join('<th>'+escape(x)+'</th>' for x in headers)+'</tr></thead><tbody>'+''.join('<tr>'+''.join('<td>'+escape(str(x))+'</td>' for x in row)+'</tr>' for row in rows)+'</tbody></table></div>'
def ul(items):return '<ul>'+''.join('<li>'+x+'</li>' for x in items)+'</ul>'
def sample(value):return '<pre data-json="true"><code>'+escape(json.dumps(value,ensure_ascii=False,indent=2))+'</code></pre>'
def section(id,title,body):return f'<section id="{id}"><h2>{title}</h2>{body}</section>'

for scenario,version,ko in [('FIGHT','R5','싸움'),('FALL','R2','쓰러짐')]:
 fight=scenario=='FIGHT';block='fight_r5' if fight else 'fall_r2';engine=version.lower()
 title=f'{scenario} {version} Web 연계 안내'
 common=[['사람 score','숫자 입력','person_confidence','number 0~1','0.25 / 하한0.25'],['관절 score','숫자 입력','pose_confidence','number 0~1','0.10 / 하한0.10'],['알람 간격(초)','숫자 입력','cooldown_seconds','number 0~300','10초']]
 extra=[['관측창(초)','숫자 입력',block+'.window_seconds','number 0.3~3.0','1.0초'],['일반 유효 관측 수','정수 stepper',block+'.min_samples','integer 1~16','2'],['근접 유효 관측 수','정수 stepper',block+'.contact_min_samples','integer 1~16','3']] if fight else []
 cfg={'schema_version':1}
 if fight:cfg.update(window_seconds=1.0,min_samples=2,contact_min_samples=3)
 request={'scenarioCode':scenario,'enabled':True,'rois':[],'parameters':{'person_confidence':.25,'pose_confidence':.1,'cooldown_seconds':10,block:cfg}}
 reset={block:{'schema_version':1,'reset_to_defaults':True}}
 legacy='pair_distance_ratio · pair_close_hold_seconds · arm_motion_ratio · required_bursts' if fight else 'down_angle_deg · recovery_hold_seconds'
 status='AI 적용 완료 · camera24의 짧은 알람/미디어 사용자 확인 완료 · 추가 고도화 보류' if fight else 'AI 적용 완료 · 36입력 재현 일치 · 브라우저 수용은 사용자 확인 전까지 미확인'
 intro='상대와 관련된 실제 손·발·근접 동작 근거를 최근 관측과 함께 판단합니다. 단순 근접이나 빠른 움직임만을 싸움으로 확정하지 않습니다.' if fight else '현재 사람별 Pose에서 가로형 R1과 깊이 방향 D1/D2 근거를 확인합니다. 같은 Tracking ID로 넘어지는 과정을 끝까지 보아야만 동작하는 방식은 아닙니다.'
 source=f'docs/integration/{scenario}_{version}_WEB_CONFIG.ko.md'
 body=f'<header class="masthead"><p class="kicker">CONTEXTRA / 동료 전달용 설정 계약</p><h1>{title}</h1><p>{intro}</p><p class="muted">검토 {now} · presentation snapshot · 원문 Markdown 우선</p></header>'
 body+='<nav aria-label="문서 목차">'+''.join(f'<a href="#{k}">{v}</a>' for k,v in [('overview','핵심 흐름'),('controls','브라우저 설정'),('request','Start JSON'),('rules','생략·초기화·오류'),('display','빨강과 알람'),('check','연계 확인')])+'</nav>'
 body+=section('overview','1. Web을 먼저 고치지 않아도 동작합니다',f'<div class="status"><strong>{status}</strong></div><ol class="flow"><li><strong>1 / Web Start</strong><span>기존 6개 시나리오·On/Off·ROI·입력</span></li><li><strong>2 / AI 검증·저장</strong><span>구형값 무시, 새 설정 검증, 실효값 비교</span></li><li><strong>3 / 고정 엔진</strong><span>현재 판정과 표시 유지 분리</span></li><li><strong>4 / Redis·Event</strong><span>그리기와 새 알람은 별도 경로</span></li></ol>'+ul([
 f'AI가 소유하는 <code>{scenario.lower()}.engine={engine}</code> 선택을 Web이 바꾸지 않습니다. 새 블록은 설정 버전이며 엔진 전환 명령이 아닙니다.',
 '기존 저장 채널과 신규 등록 template에 기본 엔진을 적용했습니다. 모든 채널 강제 Start나 시나리오 강제 On은 아닙니다.',
 '기존 권한·전송·ROI·다른 시나리오·모델·Tracker를 보존합니다. Web 반복 Start 정책과 실제 입력 변경 의미도 그대로입니다.']))
 controls=table(['브라우저 항목','권장 컨트롤','parameters 경로','유효 타입·범위','최초 기본'],common+extra)
 controls+=ul(['On/Off는 기존 toggle, ROI는 기존 편집 도구를 유지합니다. 저장값이 있으면 기본값보다 우선 표시합니다.',
 'score는 행동 확률이 아닙니다. 사람0.15/관절0.05 같은 유효한 낮은 값은 AI에서0.25/0.10으로 보정하고,0.35/0.30처럼 높은 값은 유지합니다.',
 'bool·문자·null은 숫자가 아닙니다. UI의 빈 입력을0이나 기본값으로 몰래 변환하지 않습니다. NaN/Infinity는 유효 JSON 숫자가 아닙니다.',
 '계산과 기여 빨강은 해당 시나리오 score를 따릅니다. 공유 원시 Pose/Tracker와 별도 raw skeleton 표시 설정은 일괄 변경하지 않습니다.'])
 controls+=('<p>두 관측 수는 각각 <code>floor(window_seconds × 10) + 1</code> 이하여야 합니다. 관측 수는 펀치 횟수나 행동 종류 수가 아니며, 현재의 방향·거리·억제 조건도 통과해야 합니다.</p>' if fight else '<p>공개 조작은 위 세 값뿐입니다. D1/D2 각도·길이 비율·관측 수·재무장·표시 유지 시간을 UI로 노출하지 않습니다.</p>')
 controls+='<div class="warning"><strong>구형 동작 항목은 적용되지 않습니다.</strong><p><code>'+escape(legacy)+'</code></p><p>새 UI에서 숨기거나 미적용으로 표시하고, 새 규칙의 의미로 임의 변환하지 않습니다.</p></div>'
 body+=section('controls','2. 브라우저에서 조절할 항목',controls)
 body+=section('request','3. Start JSON', '<p>다음은 <strong>scenarios 배열의 항목 하나</strong>입니다. 전체 Start가 아닙니다. 나머지5개 항목과 cameraId·rtspUrl·startWallTimeMs·rtpClockRate, 기존 선택 녹화 식별자를 보존합니다.</p>'+sample(request)+'<p><code>rois: []</code>는 전체화면 예시입니다. 사용 중인 ROI를 지우지 마세요. 공통 세 값은 parameters 바로 아래, 전용 블록은 그 안에 둡니다. schema_version은 블록이 있으면 정수1 필수입니다.</p>')
 reset_scope='관측창1.0초·일반2·근접3만 초기화합니다. 사람/관절 score·cooldown은 초기화하지 않습니다.' if fight else 'FALL 사람0.25·관절0.10·cooldown10초만 초기화합니다.'
 initial='R5 관측 설정 초기화' if fight else 'FALL 공통 설정 초기화'
 reset_caution='초기화 블록 내부에 다른 조작 키를 함께 보내지 않습니다. 공통값의 별도 변경과 초기화 범위를 혼동하지 않습니다.' if fight else '초기화 요청에 공통 세 필드를 함께 보내면 거부합니다. reset:false도 오류이며 키를 생략해야 합니다.'
 rules=table(['상황','AI 처리'],[['공통값 또는 블록 생략','저장값 유지. 최초 미설정만 승인 기본값'],['구형 요청의 잘못된 optional 값','해당 값 무시·저장값 유지·사유 기록'],['새 블록 명시 후 잘못된 값/버전/키','저장·실행 변경 전에 전체 거부'],['유효한 낮은 score','하한 보정. 오류와 구분'],['동일 실효 Start 반복','불필요한 run 교체 없음'],['실제 URI·시간 anchor·ROI·설정 변경','기존 controlled restart/적용 계약 유지']])
 rules+=f'<h3>{initial}</h3><p>{reset_scope} On/Off·ROI·engine·다른 시나리오는 그대로입니다.</p>'+sample(reset)+f'<p>{reset_caution}</p>'
 rules+='<p class="warning">FIGHT reset은 내부 관측 설정, FALL reset은 공통 세 설정입니다. 공통 초기화 버튼 하나로 같은 범위를 적용하지 마세요.</p>'
 rules+='<details><summary>오류·반복 요청 구현 시 주의</summary>'+ul([
 'parameters 자체가 구형 null/문자/배열이면 optional 무시입니다. 전용 새 블록의 null·배열·빈 객체·버전 오류는 엄격 거부입니다.',
 f'대표 오류는 <code>{scenario}_{version}_NOT_ENABLED</code>, <code>SCHEMA_VERSION_REQUIRED/UNSUPPORTED</code>, <code>FIELDS_INVALID</code>, <code>SCORE_INVALID</code>, <code>COOLDOWN_INVALID</code>, <code>RESET_INVALID</code>입니다. 각 코드는 같은 시나리오 prefix를 가집니다.'+(' R5에는 WINDOW_INVALID·SAMPLE_COUNT_INVALID도 있습니다.' if fight else ''),
 '기존 비동기 operation에서 최종 결과를 확인합니다. 접수 응답만으로 성공을 표시하거나 잘못된 값을 보정 성공으로 처리하지 않습니다.',
 'Web이 같은 Start를 반복해도 무관합니다. 단, polling마다 새 startWallTimeMs를 만들거나 다른 시나리오 설정을 초기화하면 같은 요청이 아닙니다.'])+'</details>'
 body+=section('rules','4. 저장값 유지와 명시적 변경',rules)
 state='FIGHT' if fight else 'FALL_CONFIRMED'
 display=f'<div class="legend"><span><i class="swatch red" aria-hidden="true"></i><strong>빨강: 현재 충족 또는 1초 표시 유지</strong></span><span><i class="swatch neutral" aria-hidden="true"></i>옛 위치 유지 없음</span></div>'
 display+=ul([f'기존 objects의 bbox·color·labelText·trackId·scenarioResults·eventIds를 소비합니다. scenario=<code>{scenario}</code>, state=<code>{state}</code>, color=<code>#FF3030</code>입니다.',
 '빨강이 새 알람이라는 뜻은 아닙니다. Web은 빨강으로 별도 알람을 만들지 않고 기존 Event 수신을 사용합니다. 실시간과 Event 아티팩트에서는 유지도 빨강이며 오프라인 노랑과 다릅니다.',
 '현재 객체 대응이 확인될 때만 유지합니다. 미관측·연결 불확실에는 이전 bbox를 덧그리지 않습니다. 원래 미추적 객체의 ID는 null입니다.',
 '마지막 실제 최종 충족 이후2초 동안 다시 충족하지 않으면 재무장합니다. 부분 조건·자료 부족·유지로 연장하지 않으며 정상 회복 분류도 아닙니다.',
 '지속 충족도 기존 cooldown 이후 현재 충족이면 재알림합니다. 2초마다 알람을 뜻하지 않습니다. 차단된 과거 판정을 나중에 예약 발급하지 않습니다.',
 '새 알림은 새ID와 현재 썸네일·클립입니다. 같은 Event 전송 재시도는 같은ID를 유지합니다. 여러 기여 사람을 표시하더라도 사람별 알람을 강제하지 않습니다.',
 'Runtime 발급 → 아티팩트 접수 → 미디어 생성 → HTTP 전달 → 브라우저 표시를 구분합니다. 접수 실패도 발급 cooldown은 소비하며 성공으로 꾸미지 않습니다.'])
 body+=section('display','5. 빨강과 알람은 서로 다른 정보입니다',display)
 check=ul([f'기존 AI 관리 설정 조회에서 <code>configured.{scenario.lower()}</code>, <code>effective.{scenario.lower()}</code>, <code>restart_pending</code>을 확인합니다. engine과 score, 전용 설정이 실효값입니다.',
 '<strong>현행 설정 조회 응답에 cooldown 필드는 없습니다.</strong> AI 처리 로그와 담당자의 persistent/실행 진단으로 확인합니다. 자동 실효값 조회가 이미 있는 것처럼 UI를 만들지 않습니다.',
 f'요청·보정·무시 사유는 <code>{block}_parameter</code>, <code>{block}_parameter_ignored</code>, <code>{block}_effective</code> 로그에 있습니다. 브라우저에 AI 인증정보나 직접 접근을 새로 열지 않습니다.',
 '구형/신형 Start, 낮은/높은 score, 생략·초기화·오류, 저장 실패 보존, 반복 no-op과 실제 변경을 자동검사합니다.',
 '사용자가 기존 Web에서 자연 실행의 표시·알람·썸네일·클립을 확인합니다. AI가 별도 Start·mock Event·과거 Event 재전송으로 알람을 유도하지 않습니다.',
 '현재 샘플 수용을 전체 채널 정확도·장시간 안정성·라이브10FPS 보장으로 확대하지 않습니다. 군중/가림/미탐과 입력 차이에 따른 한계는 남습니다.'])
 evidence='FIGHT는 camera24의 짧은 실제 알람·미디어 사용자 확인 근거가 있습니다. 후속 군중 고도화는11/3 시연 이후 별도 요청까지 보류입니다.' if fight else 'FALL R2는36입력20,145표시관측의 현재 판정·기여/유지·Event 위치 재현이 일치했습니다. 운영 모델 재추론 시험·브라우저 수용·장시간 일반화는 이번 검증에 포함되지 않습니다. 높은 저장 관절 score0.30은 그대로 유지됐습니다.'
 body+=section('check','6. 동료와 확인할 순서',f'<p class="status">{evidence}</p>'+check)
 body+=f'<footer><strong>문서 원문</strong><p>{escape(source)} · 구현: app/{block}/ 및 app/{scenario.lower()}_runtime.py</p><p>에이전트 전달: {scenario}_{version}_WEB_HANDOFF.md · 검토 {now} · 이 HTML은 원문 계약의 표시용 snapshot입니다. Web 코드·DB·브라우저·미디어 변경이나 사용자 브라우저 수용을 대신하지 않습니다.</p></footer>'
 html='<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'+escape(title)+'</title><style>'+CSS+'</style></head><body><main>'+body+'</main></body></html>'
 name=f'{scenario}_{version}_WEB_GUIDE.html';(PRESENT/name).write_text(html)
 md=f'{scenario}_{version}_WEB_HANDOFF.md';shutil.copy2(REPO/'docs/integration'/md,PRESENT/md)
 for name in (name,md):shutil.copy2(PRESENT/name,EXPORT/name)
manifest={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in EXPORT.iterdir()}
(ROOT/'web-packet-manifest.json').write_text(json.dumps(dict(created=now,presentation=str(PRESENT),export=str(EXPORT),files=manifest),indent=2))
print(EXPORT)
