Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,076 changes: 2,076 additions & 0 deletions catalog/ar-notion-20261020/manifest.json

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions docs/hairstyle-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,39 @@
- 카탈로그 호환성·점수·정렬 조건도 입력 해시에 포함한다. 운영 메타데이터 버전을 빠뜨린
수정도 이전 결과를 재사용하지 않는다. `/recommendations/{id}`는 과거 실행 조회를 유지한다.
- `force_refresh=true`는 새 실행을 만들지만 같은 입력의 순위를 무작위로 섞지는 않는다.

## 추천 → AR 연결 (2026-10-20)

AR은 `O2-DGSW/AR-hairstyle` GPU 서버가 GLB를 렌더링해 WebRTC 영상으로 전달한다.
추천 UUID와 AR `/grooms`의 `name`을 `hairstyle_assets.ar_groom_name`으로 연결한다.
추천 응답은 `items[].hairstyle.ar_groom_name`과 `ar_mode=THREE_D`를 반환하며,
앱은 `/ar?groom=<이름>`으로 이동해 목록 로딩 후 정확히 일치하는 스타일을 선택한다.
매핑이 없으면 버튼을 비활성화하고 GPU 목록에서 사라진 경우 원본 화면과 안내를 표시한다.

`catalog/ar-notion-20261020/manifest.json`에는 운영 GPU 서버의 56개 중 시험용
`short-swept`, `side-sweep`, `long-wave`를 제외한 53개를 기록했다.
미변환 스타일(버스트 페이드, 보울컷, 여자 애즈펌)은 등록하지 않는다.
GLB는 GPU 서버에 유지하고 모델의 PNG 썸네일을 비공개 S3의
`HAIRSTYLE_THUMBNAIL/ar-notion-20261020/`에 저장한다. 조회에는 기존 만료 URL을 쓴다.

길이·관리 시간·난이도·약제 부담은 원본 길이/시술/관리쉬움 태그 기반의 **초기 추정값**이며
전문가 검증을 받은 개인 적합성 정보가 아니다. 모질·굵기·상태 호환성은 근거 없이 채우지
않으며 펌은 손상 모발을 보수적으로 제외한다. 성별은 원본 메타데이터로 보존하고 현재
추천기에 성별 필터를 새로 도입하지 않는다. 선호 태그는 기존 ID에 명시적으로 연결한다.

운영 적용 순서:

1. V37 및 `ar_groom_name` 응답 서버와 groom 전달 앱을 배포한다.
2. `/grooms`의 53개 GLB 헤더·썸네일을 검증하고 manifest와 `images/<groom>.png`를
bundle로 준비한다. 이미지 SHA-256은 커밋된 manifest와 일치해야 한다.
3. `python3 scripts/ar_catalog_test.py <bundle>`로 임시 PostgreSQL 검증을 수행한다.
4. EC2의 기존 boto3 환경으로 `python scripts/ar_catalog.py <bundle> --apply`를 실행한다.
자격증명은 기존 앱 컨테이너에서 메모리로 읽으며 출력하거나 별도 파일로 복사하지 않는다.
5. S3 바이트를 검증한 뒤 한 트랜잭션으로 등록하고 새 53개만 추천 활성화한다.
기존 카탈로그·저장 스타일·추천 이력은 삭제하지 않는다. 기존 추천은 STALE 처리하며
`/recommendations/latest` 재방문 때 새 카탈로그를 반영한다.

서버 `~/heddy-catalog-backups/ar-notion-20261020/`에 최초 활성 상태와 복원 SQL을 저장한다.
카탈로그가 백업 후 바뀌었으면 적용을 거부한다. 재시도에서도 최초 백업을 덮어쓰지 않는다.
복구하려면 그 경로의 `restore.sql`로 이전 활성 상태를 복원한다. 신규 객체와 행은
참조 보존을 위해 삭제하지 않고 비활성화한다.
188 changes: 188 additions & 0 deletions scripts/ar_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Validate/apply the AR catalogue on the existing Heddy EC2 host. Never print credentials."""
import argparse
import collections
import hashlib
import json
import os
from pathlib import Path
import re
import struct
import subprocess
import uuid

RELEASE = 'ar-notion-20261020'
EXCLUDED = {'short-swept', 'side-sweep', 'long-wave'}


def validate(bundle):
bundle = Path(bundle).resolve()
data = json.loads((bundle / 'manifest.json').read_text())
if data['release'] != RELEASE or len(data['entries']) != 53:
raise ValueError('Expected the 53 converted Notion styles')
entries = data['entries']
if collections.Counter((e['gender'], e['type']) for e in entries) != {
('남자', '컷'): 18, ('남자', '펌'): 10, ('여자', '컷'): 11, ('여자', '펌'): 14}:
raise ValueError('Unexpected catalogue composition')
for field in ['name', 'hairstyle_id', 'file_id', 'upload_id', 'object_key']:
if len({e[field] for e in entries}) != 53:
raise ValueError('Duplicate identity: ' + field)
for e in entries:
name = e['name']
if not re.fullmatch('[a-z0-9][a-z0-9-]{0,79}', name) or name in EXCLUDED:
raise ValueError('Invalid AR groom identity')
for field, kind in [('hairstyle_id', 'hairstyle'), ('file_id', 'thumbnail'), ('upload_id', 'upload')]:
expected = str(uuid.uuid5(uuid.NAMESPACE_URL, f'https://heddy.site/ar-catalog/{name}/{kind}'))
if e[field] != expected:
raise ValueError('Unexpected stable identity')
if e['object_key'] != f'HAIRSTYLE_THUMBNAIL/{RELEASE}/{name}.png':
raise ValueError('Unexpected S3 key')
if e['image_file'] != f'images/{name}.png':
raise ValueError('Invalid image path')
content = (bundle / e['image_file']).read_bytes()
if content[:8] != b'\x89PNG\r\n\x1a\n' or hashlib.sha256(content).hexdigest() != e['sha256']:
raise ValueError('Thumbnail integrity mismatch: ' + name)
if len(content) != e['file_size'] or struct.unpack('>II', content[16:24]) != (e['width'], e['height']):
raise ValueError('Thumbnail metadata mismatch')
levels = ['SHORT', 'BELOW_CHIN', 'BELOW_SHOULDER', 'BELOW_CHEST']
if e['compatible_hair_lengths'] != levels[levels.index(e['minimum_hair_length']):]:
raise ValueError('Invalid minimum length')
return data


def quote(value):
return "'" + str(value).replace("'", "''") + "'"


def js(value):
return quote(json.dumps(value, ensure_ascii=False)) + '::jsonb'


def apply_sql(data, previous):
statements = ['BEGIN;', 'LOCK TABLE hairstyle_assets IN SHARE ROW EXCLUSIVE MODE;']
# Fail on concurrent catalogue edits; this script is a reviewed replacement, not an open-ended import.
expected = js(previous)
statements.append(f"""DO $$ BEGIN IF (SELECT coalesce(jsonb_agg(jsonb_build_object(
'hairstyle_id', hairstyle_id, 'active', active) ORDER BY hairstyle_id), '[]'::jsonb)
FROM hairstyle_assets) <> {expected} THEN
RAISE EXCEPTION 'Catalogue changed after snapshot'; END IF; END $$;""")
for e in data['entries']:
fid, hid = quote(e['file_id']), quote(e['hairstyle_id'])
statements.append(f"""INSERT INTO files (file_id, upload_id, owner_type, user_id, purpose,
status, object_key, content_type, file_size, sha256, width, height, expires_at, file_name)
VALUES ({fid},{quote(e['upload_id'])},'SYSTEM',NULL,'HAIRSTYLE_THUMBNAIL','READY',
{quote(e['object_key'])},'image/png',{e['file_size']},{quote(e['sha256'])},
{e['width']},{e['height']},now(),{quote(e['name']+'.png')})
ON CONFLICT (file_id) DO NOTHING;""")
anchor = {'groom_name': e['name'], 'user_fit': e['user_fit'], 'gender': e['gender'],
'source_tags': e['tags'], 'catalog_release': RELEASE,
'metadata_policy': 'tag-derived-estimates-v1'}
statements.append(f"""INSERT INTO hairstyle_assets (hairstyle_id,style_name,category,
thumbnail_file_id,ar_mode,ar_groom_name,anchor_config,active,asset_version)
VALUES ({hid},{quote(e['label'])},{quote(e['category'])},{fid},'THREE_D',
{quote(e['name'])},{js(anchor)},true,'ar-notion-v1')
ON CONFLICT (hairstyle_id) DO NOTHING;""")
# Do not invent hair-type/thickness compatibility. Empty lists mean not assessed.
contraindications = ['DAMAGED', 'SEVERELY_DAMAGED'] if e['type'] == '펌' else []
statements.append(f"""INSERT INTO hairstyle_recommendation_profiles
(hairstyle_id,service_types,compatible_hair_lengths,minimum_hair_length,
contraindicated_hair_conditions,estimated_daily_care_minutes,management_difficulty,
chemical_stress_level,editorial_priority,metadata_version)
VALUES ({hid},{js(e['service_types'])},{js(e['compatible_hair_lengths'])},
{quote(e['minimum_hair_length'])},{js(contraindications)},
{e['estimated_daily_care_minutes']},{quote(e['management_difficulty'])},
{quote(e['chemical_stress_level'])},0,'ar-tags-estimate-v1')
ON CONFLICT (hairstyle_id) DO NOTHING;""")
# Keep existing preference IDs: map by exact style name or a small explicit alias table.
aliases = {'bob-cut': ['클래식 보브','쇼트 보브'], 'hippie-perm': ['히피펌'],
'layered-cut': ['미디엄 레이어드','롱 레이어드'], 'glossy-straight': ['긴 생머리'],
'hush-cut': ['중단발 허쉬컷'], 'danbal-cut': ['레이어드 단발'],
'c-curl-perm': ['C컬 단발'], 'pixie-cut': ['픽시컷']}
names = [e['label'], *aliases.get(e['name'], [])]
statements.append(f"""INSERT INTO hairstyle_style_tags (hairstyle_id,style_tag_id)
SELECT {hid},style_tag_id FROM style_tags WHERE tag_name IN ({','.join(map(quote,names))})
ON CONFLICT DO NOTHING;""")
statements.append(f"""INSERT INTO hairstyle_colors (hairstyle_id,color_id,name,hex_code,sort_order)
SELECT {hid},color_id,name,hex_code,sort_order FROM hair_colors ON CONFLICT DO NOTHING;""")
ids = ','.join(quote(e['hairstyle_id']) for e in data['entries'])
statements.append(f"UPDATE hairstyle_assets SET active = (hairstyle_id IN ({ids})), updated_at=now();")
statements.append("UPDATE recommendation_runs SET status='STALE' WHERE status='ACTIVE';")
statements.append('COMMIT;')
return '\n'.join(statements)


def psql(sql):
result = subprocess.run(['docker','exec','-i','heddy-v2-postgres','sh','-c',
'exec psql -X -U "$POSTGRES_USER" -d heddy_v2 -qAt -v ON_ERROR_STOP=1'],
input=sql, text=True, capture_output=True)
if result.returncode:
raise RuntimeError('Catalogue SQL failed: ' + result.stderr[-1500:])
return result.stdout


def apply(bundle, data):
import boto3
from botocore.exceptions import ClientError
# Check that the new API is deployed before changing candidate selection.
psql('SELECT ar_groom_name FROM hairstyle_assets LIMIT 0;')
previous = json.loads(psql("SELECT coalesce(json_agg(row_to_json(t)), '[]') FROM "
"(SELECT hairstyle_id,active FROM hairstyle_assets ORDER BY hairstyle_id) t;"))
new_ids = {e['hairstyle_id'] for e in data['entries']}
if any(r['hairstyle_id'] in new_ids for r in previous):
raise ValueError('Release already present; inspect it instead of overwriting it')
backup = Path.home() / 'heddy-catalog-backups' / RELEASE
backup.mkdir(parents=True, exist_ok=True, mode=0o700)
# Write once; never replace the original restore point.
snapshot = backup/'before.json'
if snapshot.exists():
if json.loads(snapshot.read_text()) != previous:
raise ValueError('Catalogue differs from the retained backup')
else:
with snapshot.open('x') as f:
os.chmod(f.name, 0o600)
json.dump(previous, f)
restore = ['BEGIN;', "UPDATE hairstyle_assets SET active=false WHERE ar_groom_name IS NOT NULL;"]
restore += [f"UPDATE hairstyle_assets SET active={'true' if r['active'] else 'false'} WHERE hairstyle_id={quote(r['hairstyle_id'])};" for r in previous]
restore += ["UPDATE recommendation_runs SET status='STALE' WHERE status='ACTIVE';", 'COMMIT;']
(backup/'restore.sql').write_text('\n'.join(restore))
sql = apply_sql(data, previous)
(backup/'apply.sql').write_text(sql)
env = dict(v.split('=',1) for v in json.loads(subprocess.check_output(
['docker','inspect','heddy-v2-app']))[0]['Config']['Env'] if '=' in v)
options = {'region_name': env.get('AWS_S3_REGION','ap-northeast-2')}
if env.get('AWS_S3_ACCESS_KEY'):
options.update(aws_access_key_id=env['AWS_S3_ACCESS_KEY'],aws_secret_access_key=env['AWS_S3_SECRET_KEY'])
s3 = boto3.client('s3', **options)
bucket = env['AWS_S3_BUCKET']
for e in data['entries']:
content = (Path(bundle)/e['image_file']).read_bytes()
try:
head = s3.head_object(Bucket=bucket, Key=e['object_key'])
except ClientError as error:
if error.response['Error']['Code'] not in {'404','NoSuchKey','NotFound'}:
raise
head = None
if head is None:
s3.put_object(Bucket=bucket, Key=e['object_key'], Body=content, ContentType='image/png',
Metadata={'sha256':e['sha256'],'ar-groom':e['name'],'catalog-release':RELEASE}, IfNoneMatch='*')
remote = s3.get_object(Bucket=bucket, Key=e['object_key'])['Body'].read()
if hashlib.sha256(remote).hexdigest() != e['sha256']:
raise ValueError('S3 content conflict: '+e['name'])
psql(sql)
counts = json.loads(psql("SELECT json_build_object('active',count(*) FILTER (WHERE active),"
"'ar_mapped',count(*) FILTER (WHERE active AND ar_mode='THREE_D' AND ar_groom_name IS NOT NULL),"
"'total_preserved',count(*)) FROM hairstyle_assets;"))
if counts['active'] != 53 or counts['ar_mapped'] != 53:
raise RuntimeError('Post-apply count mismatch')
print(json.dumps(counts))


if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('bundle',type=Path)
parser.add_argument('--apply',action='store_true')
args = parser.parse_args()
data = validate(args.bundle)
print('Validated 53 AR styles')
if args.apply:
apply(args.bundle, data)
52 changes: 52 additions & 0 deletions scripts/ar_catalog_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Run against a disposable PostgreSQL database, including every production migration."""
import json
from pathlib import Path
import subprocess
import sys
import time
import uuid
from ar_catalog import apply_sql, validate


def main():
data = validate(sys.argv[1])
name = 'heddy-ar-catalog-test-' + uuid.uuid4().hex[:8]
subprocess.run(['docker','run','--rm','-d','--name',name,
'-e','POSTGRES_PASSWORD=local-catalog-test','postgres:16-alpine'], check=True,capture_output=True)
def sql(statement):
return subprocess.run(['docker','exec','-i',name,'psql','-X','-qAt','-U','postgres',
'-v','ON_ERROR_STOP=1'],input=statement,text=True,capture_output=True,check=True).stdout
try:
for _ in range(40):
if subprocess.run(['docker','exec',name,'pg_isready','-U','postgres'],capture_output=True).returncode == 0:
break
time.sleep(.25)
root = Path(__file__).resolve().parent.parent
paths = sorted((root/'src/main/resources/db/migration').glob('V*.sql'),key=lambda p:int(p.name.split('__')[0][1:]))
for path in paths:
sql(path.read_text())
# Include a legacy active style to exercise retirement without deleting saved references.
old_id = '33333333-3333-4333-8333-333333333333'
sql(f"INSERT INTO hairstyle_assets(hairstyle_id,style_name,category,active,asset_version) VALUES ('{old_id}','기존 스타일','SHORT',true,'v1');")
previous = [{'hairstyle_id':old_id,'active':True}]
sql(apply_sql(data, previous))
assert sql('SELECT count(*) FROM hairstyle_assets WHERE active;').strip() == '53'
assert sql('SELECT count(*) FROM hairstyle_assets;').strip() == '54'
assert sql("SELECT count(*) FROM hairstyle_assets a JOIN files f ON f.file_id=a.thumbnail_file_id JOIN hairstyle_recommendation_profiles p USING(hairstyle_id) WHERE a.active AND f.status='READY' AND f.owner_type='SYSTEM' AND a.ar_groom_name IS NOT NULL;").strip() == '53'
assert sql("SELECT count(*) FROM hairstyle_assets WHERE ar_groom_name IN ('short-swept','side-sweep','long-wave');").strip() == '0'
assert sql('SELECT count(*) FROM hairstyle_colors;').strip() != '0'
# A stale reviewed snapshot must reject a second application atomically.
try:
sql(apply_sql(data, previous))
except subprocess.CalledProcessError:
pass
else:
raise AssertionError('Concurrent change guard did not reject stale snapshot')
assert sql('SELECT count(*) FROM hairstyle_assets WHERE active;').strip() == '53'
print('PASS: migrations, 53 ready S3 references, groom mapping, colors, legacy retention, concurrent-change rollback')
finally:
subprocess.run(['docker','rm','-f',name],capture_output=True,check=True)


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ static Item from(RecommendationResult.Item value) {
return new Item(item.displayRank(), item.score(), new Hairstyle(
value.hairstyle().hairstyleId(), value.hairstyle().styleName(),
value.thumbnailUrl() == null ? null : value.thumbnailUrl().toString(),
value.hairstyle().arMode(), value.hairstyle().assetVersion()), item.colorId(),
value.hairstyle().arMode(), value.hairstyle().assetVersion(),
value.hairstyle().arGroomName()), item.colorId(),
item.managementDifficulty().name(), item.estimatedDailyCareMinutes(),
item.reasons().isEmpty() ? "기본 추천 스타일입니다." : Reason.message(item.reasons().getFirst()),
item.reasons().stream().map(Reason::from).toList(),
Expand All @@ -119,7 +120,8 @@ public record Hairstyle(
@JsonProperty("style_name") String styleName,
@JsonProperty("thumbnail_url") String thumbnailUrl,
@JsonProperty("ar_mode") String arMode,
@JsonProperty("asset_version") String assetVersion
@JsonProperty("asset_version") String assetVersion,
@JsonProperty("ar_groom_name") String arGroomName
) { }

public record Reason(String code, String message, Map<String, String> params) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class HairstyleAssetEntity extends BaseEntity {
@Column(name = "ar_mode", nullable = false, length = 30)
private String arMode;

@Column(name = "ar_groom_name", length = 80)
private String arGroomName;

@Column(nullable = false)
private boolean active;

Expand All @@ -40,6 +43,7 @@ protected HairstyleAssetEntity() { }
String category() { return category; }
UUID thumbnailFileId() { return thumbnailFileId; }
String arMode() { return arMode; }
String arGroomName() { return arGroomName; }
boolean active() { return active; }
String assetVersion() { return assetVersion; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private HairstyleCandidate toDomain(
profile.estimatedDailyCareMinutes(),
ManagementDifficulty.valueOf(profile.managementDifficulty()),
ChemicalStressLevel.valueOf(profile.chemicalStressLevel()),
profile.editorialPriority(), profile.metadataVersion(), tags);
profile.editorialPriority(), profile.metadataVersion(), tags, asset.arGroomName());
}

private static <E extends Enum<E>> Set<E> parse(Set<String> values, Class<E> type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ private String canonicalSnapshot(
.forEach(candidate -> value.append('|').append(candidate.hairstyleId()).append(':')
.append(candidate.styleName()).append(':').append(candidate.assetVersion()).append(':').append(candidate.metadataVersion())
.append(':').append(candidate.arMode())
.append(':').append(candidate.arGroomName())
.append(':').append(candidate.category())
.append(':').append(candidate.thumbnailFileId())
.append(':').append(candidate.active())
Expand Down
Loading
Loading