feat(supporters): 글 생성기 §7-4 — brief+근거 컨텍스트 → GPT-4.1 → 소스 게이트·수치 대조·근거 대조·수정 2회, 정답지 18편 회귀 18/18 통과

- scripts/generate_posts.mjs + gen/{evidence,prompt,serialize}.mjs, briefs/README.md, 새 문항 brief 예시
- 게이트 COMPARISON_CLAIM(타 병원·업계 평균 비교 수치) 신설 + 픽스처, 금칙어 부정문 예외, 직렬화 왕복 테스트
- 회귀 리포트·샘플 3편 docs/reports/viewclinic/04_supporters, v2 §11-5 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-07 16:37:56 +09:00
parent 76ec63bc1e
commit 64a3bce9a3
17 changed files with 1540 additions and 4 deletions

View File

@ -236,7 +236,7 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
| 단계 | 상태 | 막는 것 |
|---|---|---|
| 3 근거 수집기 (`evidence/<clinic>/`) | **완료** (커밋 c92ecc8, §11-4) | 없음 |
| 4 글 생성 프롬프트 | 미착수 | 모델은 GPT-4로 결정(§12-1). 단가 상한 미정 |
| 4 글 생성기 | **완료** (§11-5) | 편당 약 $0.09 실측. 단가 상한은 --max-cost-usd 로 걸 수 있고 값은 미정 |
| 5 승인 UI (§6 입력 폼) | 미착수 | 폼은 INFINITH 랜딩으로 결정(§12-1). 법적 주체는 §12-2 제안 확인 |
| 6 워커·배포·랜딩 연결 | 미착수 | §12-3 추천안 확인, `discovery_leads` 마이그레이션 미적용 |
| 7 두 번째 병원 회귀 | 미착수 | §9-4 병원 선정 |
@ -252,6 +252,17 @@ INFINITH 저장소(브랜치 feature/discovery-url-cta)에서 "AI Discovery 자
- 게이트 연결: `node scripts/check.mjs --home-text=../evidence/viewclinic/home_text.txt` → 18편이 전체 사이트 29만 자 기준 40자 연속 일치 0건.
- 한계: 홈 본문은 이미지·슬라이더라 0자(사실 후보는 전체 body에서 뽑아 정상). 주의사항 페이지는 탭 구조라 표가 0개이고 부위별 문장은 문단으로만 들어온다. 표 형태로 재구성하는 것은 글 생성기의 일.
### 11-5. 글 생성기 (§7-4) · 2026-09-07 세션 3
- `supporters/scripts/generate_posts.mjs` + `scripts/gen/evidence.mjs`(§4 순서 컨텍스트) · `gen/prompt.mjs`(규칙·스키마·근거 대조 프롬프트) · `gen/serialize.mjs`(js-yaml 직렬화 + 빌드 없는 소스 게이트). 입력 형식은 `supporters/briefs/README.md`.
- 흐름: brief → 컨텍스트(병원 페이지 → 쇼츠 정리 답/자막 → 기사 제목 → 규제 원문 → 팩트 시트 → 의료진 목록) → GPT-4.1 구조화 출력(요약 3·본문·FAQ·태그·pending·usedRefs) → 후처리(근거 태그·본문 FAQ 제거) → 검사 3종 → 수정 최대 2회 → md + report.
- 검사 3종: (1) 소스 게이트 = rules.mjs 전부 + 금칙어 + 홈페이지 40자 중복 + **COMPARISON_CLAIM 신설**(타 병원·업계 평균 비교 수치. 안전 페이지의 "병원급 평균 23.1% vs 43%"를 모델이 실제로 옮겨 써서 추가) (2) 수치 대조 = 글의 숫자+단위 토큰이 근거 텍스트에 그대로 있어야 함(NUMBER_NOT_IN_EVIDENCE, 1차 출력에서 18편 중 19건 검출) (3) 모델 근거 대조 = unsupported/distorted 목록, 항목별 supported 판정으로 오탐("근거에 명시되어 있음"이라며 올리는 것) 제거.
- sources·videos·검토자 후보는 brief에서 결정론적으로 조립한다. 모델은 사실의 출처를 고르지 않는다. reviewStatus 는 항상 pending, reviewedAt 은 쓰지 않는다(§3-7).
- 정답지 18편 회귀(같은 문항·같은 근거 URL): **18/18 게이트 오류 0**(1차 실행 12편 통과 + 검사 오탐 수정 후 6편 재실행 통과). 편당 약 $0.09(호출 2~6회, 목록가 기준), 22초. 리포트 `docs/reports/viewclinic/04_supporters/Viewclinic_Supporters_Generator_Regression_2026-09-07_run1.md` · `…_run2_retry6.md`, 샘플 3편 `generated_samples/`.
- 새 문항 검증: `briefs/viewclinic/eye-double-eyelid-methods.json`(쌍꺼풀 자연유착·절개·콤비매몰 비교, D2-02/03/06) → 수정 호출 없이 통과, $0.10.
- 정답지와의 판단 차이(사람 확인 항목): 모델은 애프터케어의 "5~7일 후 일상생활 가능"을 출근 시점으로 읽고, 사람 편집자는 "출근 기준은 공개 자료에 없음"으로 썼다. 근거에는 있으므로 게이트는 통과한다. 이런 해석 차이는 검토 원장 게이트에서 잡는다.
- 한계: 본문이 이미지 글자인 페이지(코 메인·가슴재수술·진료분야)는 근거 0자로 들어가 pending 에 쌓인다. 자막 없는 롱폼은 인용하지 않으므로 그 글은 정답지보다 얇다(보형물 표면 글 650~900자). 자막 파이프라인(v1 §6)이 붙으면 `evidence.transcripts` 로 넣는다.
- 미결: 단가 상한값(§9-5). 실측 편당 $0.05~0.19, 첫 배치 10편이면 약 $1.
---
## 12. haewon 결정 반영과 제안 (2026-09-07 세션 2)

View File

@ -0,0 +1,172 @@
# 글 생성 리포트 · viewclinic · gpt-4.1 (대조 gpt-4.1) · 2026-09-07
통과 12 · 근거 플래그 남음 2 · 게이트 실패 4 · 오류 0 / 18 · 수정 호출 10편 · 총 $1.55 · 편당 $0.086 · 편당 22.256s
가격 기준 USD/1M tokens 입력 2 · 출력 8 (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): UNSUPPORTED_CLAIM×3, DISTORTED_CLAIM×4, BANNED_PHRASE×1, HOME_DUPLICATE×1, NUMBER_NOT_IN_EVIDENCE×19, COMPARISON_CLAIM×2
최종 경고 분포: NUMERIC_NO_QUALIFIER×2
최종 오류 분포: BANNED_PHRASE×1, HOME_DUPLICATE×1, NUMBER_NOT_IN_EVIDENCE×3
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| anesthesia-choice | E | ok | 0 | 0 | 0 | 1121 | 3 | 7 | 5 | 2 | 27244/1678 | 0.068 | 16.1 |
| breast-recovery-timeline | D | ok | 0 | 0 | 2 | 1283 | 1 | 8 | 7 | 2 | 29203/2105 | 0.075 | 16.2 |
| consult-questions | C | ok | 0 | 0 | 0 | 1151 | 2 | 8 | 8 | 2 | 37625/1943 | 0.091 | 16.1 |
| contour-recovery | D | ok | 0 | 0 | 0 | 960 | 3 | 7 | 3 | 4+ | 61864/4038 | 0.156 | 27.3 |
| cost-breakdown | F | ok | 0 | 0 | 0 | 1140 | 2 | 7 | 4 | 2 | 19878/1729 | 0.054 | 13 |
| eye-canthoplasty-types | D | ok | 0 | 0 | 0 | 1119 | 2 | 8 | 5 | 4+ | 53451/4251 | 0.141 | 29.8 |
| functional-rhinoplasty-ent | D | ok | 0 | 0 | 0 | 770 | 1 | 8 | 3 | 2 | 21704/1319 | 0.054 | 13.2 |
| implant-feel-capsule | D | ok | 0 | 0 | 0 | 696 | 1 | 7 | 5 | 2 | 9320/1271 | 0.029 | 10.8 |
| implant-replacement-checkup | D | ok_needs_review | 0 | 2 | 0 | 1155 | 1 | 7 | 5 | 4+ | 42843/4531 | 0.122 | 36.6 |
| implant-surface-types | D | gate_failed | 1 | 0 | 0 | 797 | 1 | 8 | 5 | 4+ | 33876/3262 | 0.094 | 20.8 |
| motiva-preserve | D | gate_failed | 1 | 0 | 0 | 1242 | 1 | 8 | 7 | 4+ | 56334/3950 | 0.144 | 37.6 |
| reviews-where | G | gate_failed | 2 | 0 | 0 | 849 | 1 | 7 | 5 | 4+ | 20283/3075 | 0.065 | 22.3 |
| revision-checklist | D | ok | 0 | 0 | 0 | 946 | 2 | 7 | 5 | 2 | 9281/1404 | 0.030 | 13.7 |
| revision-consult-other-clinic | D | ok | 0 | 0 | 0 | 815 | 2 | 7 | 3 | 2 | 9267/1275 | 0.029 | 12 |
| safety-system | E | ok | 0 | 0 | 0 | 1203 | 1 | 7 | 6 | 4+ | 38184/3502 | 0.104 | 25.8 |
| thread-lift-vs-facelift | D | ok_needs_review | 0 | 1 | 0 | 950 | 1 | 7 | 5 | 4+ | 43256/3322 | 0.113 | 25.5 |
| visit-guide | B | ok | 0 | 0 | 0 | 1038 | 3 | 8 | 3 | 4+ | 27190/3653 | 0.084 | 27.7 |
| what-is-viewclinic | A | gate_failed | 1 | 3 | 0 | 1523 | 2 | 8 | 5 | 4+ | 28383/5104 | 0.098 | 36.1 |
## 근거 대조 플래그 (사람이 확인할 것)
### implant-replacement-checkup
- [UNSUPPORTED_CLAIM] 근거 없음: "정기 검진은 유방외과, 영상의학과 전문의가 담당하며, 초음파, X-ray, 맘모그래피 등 다양한 장비를 활용합니다." (정기 검진을 두 전문의가 모두 담당한다는 식의 일반화는 근거에 없음. 각 검진별로 담당자가 다르며, 모든 정)
- [DISTORTED_CLAIM] 근거와 다름: "수술 후 6개월~1년에 한 번, 이후에는 매년 유방 검진이 가능합니다." ← 근거 "홈페이지에는 '수술 후 6개월~1년: 경과 체크, 유방초음파 검진', '1년 이후: 매년 " ('6개월~1년에 한 번'이라는 표현은 근거에 없음. 근거에는 6개월~1년 사이에 한 번, )
### thread-lift-vs-facelift
- [UNSUPPORTED_CLAIM] 근거 없음: "실리프팅은 비교적 간단한 처치로 일상 복귀가 빠르지만, 효과와 적응증에 제한이 있습니다." (실리프팅의 시술 난이도, 회복 속도, 효과, 적응증에 대한 공식 안내는 뷰성형외과 공식 페이지에 없음. '간)
### what-is-viewclinic
- [UNSUPPORTED_CLAIM] 근거 없음: "뷰성형외과는 지하 1층부터 15층까지 총 19층 규모의 자체 사옥을 사용합니다." (공식 팩트 시트와 홈페이지에는 '지하 4층~지상 15층, 19층 규모 자체 사옥'이라고 되어 있으나, '지하)
- [UNSUPPORTED_CLAIM] 근거 없음: "성형외과 전문의(최순우, 윤창운, 김정민, 김도형, 정재현, 조진우, 손유성, 이동규, 김소영, 오원석, 박건욱, 박진우, 오정석, 오준석), " (김정민, 김도형, 이동규, 박건욱, 박진우, 오준석, 성보현, 송규영, 김유나, 안예나, 김경림 등 일부 의)
- [DISTORTED_CLAIM] 근거와 다름: "뷰성형외과는 지하 1층부터 15층까지 총 19층 규모의 자체 사옥을 사용합니다." ← 근거 "팩트 시트 및 공식 홈페이지: '지하 4층~지상 15층, 19층 규모 자체 사옥'" (지하 1층부터 15층까지라고 했으나, 실제로는 지하 4층부터 지상 15층까지임. 층수 범위)
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
### anesthesia-choice
- 수술별로 적용되는 마취 방식(수면마취/전신마취) 세부 기준
- 마취 방식별 회복 시간 및 주의사항
- 환자별 마취 부작용 발생 가능성 및 사전 안내
- 특정 수술에서 마취 방식 선택 시 장단점
- 마취 관련 응급상황 발생 시 대응 프로토콜
### breast-recovery-timeline
- 직업별(육체노동, 서비스직 등) 복귀 가능 시점
- 절개 부위별(유륜, 밑선 등) 샤워 및 복귀 일정 상세
- 운동 종류별(걷기, 달리기, 웨이트 등) 재개 시점
- 수술 방법(보형물 종류, 삽입 위치 등)별 회복 차이
- 수술 후 운전 가능 시점
- 회복 지연 시 추가 관리 방법
- 애프터케어 프로그램의 세부 비용 및 포함 항목
### consult-questions
- 보형물 종류별 장단점 및 권장 기준
- 수술 비용 상세 항목 및 결제 방식
- 수술 후 통증, 감각 변화의 구체적 빈도와 예후
- 절개 방식별 회복 기간, 흉터 차이
- 수술 후 운동, 샤워, 운전 가능 시점의 상세 안내
- 정기 검진의 구체적 항목 및 비용
- 부작용 발생 시 보상 또는 책임 범위
- 수술 전 금식, 약물 중단 등 개인별 예외 상황 안내
### contour-recovery
- 수술 범위별(광대, 턱 등) 붓기 감소 속도 차이
- 개인별 금주·금연 권장 기간의 세부 조정
- 딱딱한 음식 섭취 시기 예외 상황(교정 등)
### cost-breakdown
- 마취비, 입원비, 추가 검사비의 포함 여부
- 수술별·개인별로 달라지는 견적 항목의 상세 내역
- 구체적인 수술 비용 금액
- 수술 전·후 추가 검사 발생 시 비용 안내
### eye-canthoplasty-types
- 트임 성형별 수술 시간 및 마취 방법의 구체적 차이
- 눈꼬리 각도·흰자 노출별 트임 적합성 세부 기준
- 트임 수술별 흉터 발생 위치와 관리법의 상세 안내
- 트임 성형별 부작용 발생률 및 통계
- 트임 수술 후 재수술 가능 시점 및 조건
### functional-rhinoplasty-ent
- 협진 시 진료비 차이
- 수술 후 경과 관찰 담당자
- 협진 세부 진료 분담 방식
### implant-feel-capsule
- 상담 때 만지는 보형물과 수술 후 가슴 촉감 차이의 구체적 원인
- 보형물 종류별 촉감 차이
- 수술 후 촉감 변화 시점
- 피부 두께·조직 상태가 촉감에 미치는 영향
- 수술 후 촉감 불만족 시 대처 방법
### implant-replacement-checkup
- 보형물 종류별(텍스처, 스무스 등) 교체 권장 주기
- 정기 검진 시 MRI 시행 여부와 주기
- 보형물 제조사별 구체적 보증 내용(모티바, 멘토 등)
- 정기 검진 비용 상세 안내
- 보형물별 합병증 발생률 및 통계
### implant-surface-types
- 스무스·텍스처 표면의 상세 특징(촉감, 조직 반응 등)
- 각 표면별 적응증·권장 대상
- 스무스·텍스처 표면의 부작용 발생률
- 표면별 FDA·KFDA 허가 현황 및 적응증
- 표면별 실제 사용 비율 및 선호도(병원 자체 기준)
### motiva-preserve
- 수술 후 일상 복귀 시점의 구체적 안내
- 수술 비용 및 견적 범위
- 315cc 초과 보형물 사용 시 워런티 외 보장 내용
- 재수술 시 적용 가능한 예외 사례
- 특정 체형별 적용 제한
- 수술 후 운동 재개 시점
- 수술 후 흉터 관리의 구체적 방법
### reviews-where
- 네이버플레이스 평점 수치
- 후기별 실제 환자 후기 여부
- 후기 관리 및 삭제 기준
- 후기 신뢰도 확보 방법
- 시술별 후기 현황
### revision-checklist
- 재수술 전 필요한 구체적 검사 항목
- 진단 방법 및 절차
- 이전 수술 기록 미지참 시 상담 가능 여부
- 수술 전 준비 서류 목록
- 재수술 결정의 세부 기준
### revision-consult-other-clinic
- 첫 수술 후 재수술이 가능한 최소 대기 기간(부위별)
- 타 병원 수술 이력 환자에 대한 추가 준비 사항
- 재수술 상담 시 필요한 검사 및 진단 절차
### safety-system
- CCTV 촬영 범위와 위치의 상세 안내
- CCTV 영상 보관 기간 및 열람 절차
- 마취통증의학과 전문의 실제 상주 시간
- 응급 장비 사용 및 응급 대응 프로토콜 상세
- 정밀 검진 항목별 실제 검사 범위
- 애프터케어 서비스 제공 조건 및 범위
### thread-lift-vs-facelift
- 실리프팅의 적용 대상, 효과, 회복 기간, 주의사항
- 실리프팅과 안면거상의 적응증 비교
- 실리프팅 시술 후 절개·흉터 여부
- 실리프팅의 부작용 및 주의사항
- 실리프팅과 안면거상 효과 지속 기간
### visit-guide
- 주차장 무료 이용 여부 및 주차 가능 대수
- 예약 후 대기 시간 및 당일 예약 가능 여부
- 진료과별 세부 위치 및 층별 이동 동선
### what-is-viewclinic
- 수술별 담당 의료진과 실제 진료 일정
- 협진 시스템의 구체적 운영 방식
- CCTV 설치 위치 및 실제 운영 범위
- 사후관리의 구체적 내용과 기간
- 수상 경력의 선정 기준 및 세부 내역

View File

@ -0,0 +1,65 @@
# 글 생성 리포트 · viewclinic · gpt-4.1 (대조 gpt-4.1) · 2026-09-07
통과 6 · 근거 플래그 남음 0 · 게이트 실패 0 · 오류 0 / 6 · 수정 호출 3편 · 총 $0.561 · 편당 $0.093 · 편당 23.467s
가격 기준 USD/1M tokens 입력 2 · 출력 8 (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): DISTORTED_CLAIM×3, BANNED_PHRASE×1, UNSUPPORTED_CLAIM×2
최종 경고 분포: 없음
최종 오류 분포: 없음
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| implant-replacement-checkup | D | ok | 0 | 0 | 0 | 1179 | 1 | 7 | 5 | 2 | 20299/1823 | 0.055 | 13.2 |
| implant-surface-types | D | ok | 0 | 0 | 0 | 865 | 1 | 7 | 3 | 4+ | 33942/3464 | 0.096 | 22.3 |
| motiva-preserve | D | ok | 0 | 0 | 0 | 1324 | 2 | 8 | 7 | 4+ | 56828/4359 | 0.149 | 33 |
| reviews-where | G | ok | 0 | 0 | 0 | 891 | 1 | 7 | 3 | 2 | 9197/1603 | 0.031 | 11.5 |
| thread-lift-vs-facelift | D | ok | 0 | 0 | 0 | 1052 | 1 | 7 | 7 | 6+ | 69632/5847 | 0.186 | 36.4 |
| what-is-viewclinic | A | ok | 0 | 0 | 0 | 1150 | 1 | 7 | 3 | 2 | 12531/2363 | 0.044 | 24.4 |
## 근거 대조 플래그 (사람이 확인할 것)
(없음)
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
### implant-replacement-checkup
- 보형물 종류별(예: 모티바, 멘토 등) 보증 기간 및 조건
- 정기 검진·애프터케어의 실제 비용 및 보험 적용 여부
- 수술 방법(절개 위치 등)에 따른 검진 일정 차이
- 보형물 파열 시 교체 비용 및 절차
- 정기 검진 미이행 시 보증 조건 변동 여부
### implant-surface-types
- 스무스·텍스처 표면의 상세 설명 및 장단점
- 각 표면별 부작용(구형구축, 이물감 등) 실제 차이
- 실제 수술에 사용되는 표면 종류별 비율 및 선택 기준
### motiva-preserve
- 모티바 프리저베의 가격(항목별, 적용 조건)
- 재수술 시 적용 가능 조건의 구체적 예시
- 수술 후 운동 및 일상 복귀 시점의 상세 안내
- 315cc 초과 보형물 사용 시 실제 사례와 제한점
- 수술 후 통증 및 마취별 회복 차이
- 특정 연령대나 체형에 따른 적합성
- 유방암 가족력 등 특수 상황에서의 적용 가능성
### reviews-where
- 시술별·의료진별 후기 공개 범위와 실제 상담 시 제공 여부
- 네이버플레이스의 최신 평점 및 후기 수
- 병원 내 공식 후기와 외부 플랫폼 후기의 차이점
### thread-lift-vs-facelift
- 실리프팅과 안면거상 효과 지속 기간 비교
- 실리프팅 흉터 관리 방법
- 실리프팅과 안면거상 적응증 및 선택 기준
- 시술별 부작용 발생률 및 종류
- 실리프팅과 안면거상 가격 범위
- 시술별 마취 방법
- 실리프팅 후 세안, 화장, 실밥 제거 시점
### what-is-viewclinic
- 수술별 담당 의료진과 세부 협진 범위
- CCTV 설치 위치와 실제 운영 방식
- 시술별 진료 가능 여부 및 대기 기간

View File

@ -0,0 +1,136 @@
---
title: 가슴성형 후 언제 출근하고 운동은 언제부터 하나요? 회복 일정표
category: D
categoryLabel: 시술 정보
qbIds:
- D1-05
- D1-06
- D1-07
summary:
- 가슴성형 후 일상 복귀는 수술 5~7일 후부터 가능하다고 안내합니다. 홈페이지 안내 기준입니다.
- 과도한 운동, 사우나, 수영 등은 수술 2개월 후부터 권장합니다. 개인차와 수술 방식에 따라 달라질 수 있습니다.
- 운동, 출근, 샤워, 속옷 착용 등 세부 일정은 절개 부위·개인별 회복 속도에 따라 다르니 상담에서 본인 상황을 꼭 확인해야 합니다.
description: 가슴성형 후 출근, 운동, 일상 복귀 시점과 회복 일정을 뷰성형외과 공식 안내를 바탕으로 표로 정리했습니다. 절개 부위, 관리 방법, 개인차 등 상담에서 확인해야 할 점도 안내합니다.
author: ansm
reviewer: dr-chung
reviewStatus: pending
datePublished: "2026-09-07"
dateModified: "2026-09-07"
hero:
src: /img/clinic/ward-1.jpg
alt: 뷰성형외과 입원실. 창가의 1인 침대
caption: "8·10F 입원센터. 출처: 공식 홈페이지 병원둘러보기"
faq:
- q: 가슴성형 후 언제 출근할 수 있나요?
a: 홈페이지 안내 기준으로 수술 후 5~7일이 지나면 일상생활과 출근이 가능합니다. 단, 개인차와 직업 특성에 따라 달라질 수 있으니 상담에서 본인 상황을 확인해야 합니다.
- q: 운동은 언제부터 할 수 있나요?
a: 과도한 운동, 사우나, 수영 등은 수술 후 2개월 이후부터 권장합니다. 재수술의 경우 3개월 이후로 안내합니다. 개인차가 있으니 상담에서 확인하세요.
- q: 샤워는 언제부터 가능합니까?
a: 절개 부위 실밥 제거 후 다음날부터 가벼운 샤워가 가능합니다. 더마본드 사용 시 2주 후부터 샤워가 가능합니다. 절개 부위별로 차이가 있으니 상담에서 확인해야 합니다.
- q: 스포츠브라와 일반 속옷은 언제 착용할 수 있나요?
a: 스포츠브라는 수술 직후부터 1~3개월 착용하며, 3개월 후에는 일반 속옷 착용이 가능합니다. 보형물을 누르지 않는 디자인을 권장합니다. 개인차가 있습니다.
- q: 음주와 흡연은 언제부터 가능한가요?
a: 수술 후 음주와 흡연은 최소 1개월 이후부터 권장합니다. 회복 지연이나 염증 위험이 있으니 주의해야 합니다.
- q: 부기와 통증은 얼마나 지속되나요?
a: 수술 후 2~3일째 부기가 가장 심하고, 1개월 정도 지나면 부기의 80%가 빠집니다. 잔 붓기는 6개월까지 점차 감소합니다. 통증과 감각 저하는 평균 6개월에 걸쳐 회복됩니다. 개인차가 있습니다.
- q: 애프터케어 프로그램은 어떻게 진행되나요?
a: 수술 후 3~7일, 2주, 1~3개월, 6개월~1년, 1년 이후로 단계별 경과 체크와 관리가 진행됩니다. 개인별 상태에 따라 적용 내용이 달라질 수 있습니다.
- q: 회복 일정이 절개 부위나 수술 방법에 따라 달라지나요?
a: 네, 절개 부위(유륜, 밑선 등)와 수술 방법, 개인 건강 상태에 따라 회복 일정이 달라질 수 있습니다. 본인에게 해당하는 일정을 상담에서 꼭 확인해야 합니다.
sources:
- label: 뷰성형외과 홈페이지 · 수술 전·후 주의사항 (가슴)
url: https://www.viewclinic.com/system/precautions/
accessed: "2026-09-07"
type: clinic
- label: 뷰성형외과 홈페이지 · 애프터케어 (가슴수술 3개월 관리, 6개월~1년 유방 검진)
url: https://www.viewclinic.com/system/after-care/
accessed: "2026-09-07"
type: clinic
- label: 에듀동아 · 수술 후 회복관리 강화… 뷰성형외과, 좌식형 고압산소치료기 도입 (2026-09-02)
url: https://edu.donga.com/news/articleView.html?idxno=111487
accessed: "2026-09-07"
type: clinic
- label: 유튜브 · [모티바 프리저베] 전신마취없이 볼륨을 살릴 수 있다? (정재현 원장)
url: https://www.youtube.com/watch?v=kxNFX7ihPaA
accessed: "2026-09-07"
type: doctor
tags:
- 가슴성형
- 회복일정
- 출근시점
- 운동재개
- 애프터케어
history:
- date: "2026-09-07"
note: 자동 생성 초안 (gpt-4.1). 의학 검토 대기. 병원 확인 대기 7건
videos:
- id: kxNFX7ihPaA
title: "[모티바 프리저베] 전신마취없이 볼륨을 살릴 수 있다?"
speaker: 정재현 원장
published: "2026-08-04"
note: 회복 사례 언급
gallery: []
thumbnail: /img/clinic/ward-1.jpg
---
가슴성형을 고려하는 분들이 가장 궁금해하는 회복 일정, 출근과 운동 재개 시점에 대해 뷰성형외과 공식 페이지 안내를 바탕으로 정리했습니다. 수술 후 부기, 통증, 일상생활 복귀, 운동, 속옷 착용 등 주요 관리 포인트도 함께 표로 정리합니다. 본문은 병원 공식 안내 기준이며, 개인별 회복 속도와 절개 부위, 수술 방법에 따라 달라질 수 있습니다.
## 가슴성형 후 회복·일상 복귀 일정표
| 구분 | 시점(홈페이지 안내 기준) | 조건/비고 |
|----------------|-----------------------------|---------------------------------|
| 일상생활 복귀 | 수술 후 5~7일 | 부기 감소 후, 개인차 있음 |
| 출근 | 수술 후 5~7일 | 사무직 기준, 무리한 활동 제외 |
| 가벼운 샤워 | 절개 부위 실밥 제거 후 다음날 | 더마본드 사용 시 2주 후 가능 |
| 스포츠브라 착용 | 수술 직후~1~3개월 | 3개월 후 일반 속옷 가능 |
| 일반 속옷 착용 | 수술 후 3개월 | 보형물 누르지 않는 디자인 권장 |
| 음주/흡연 재개 | 수술 후 1개월 이후 | 회복 지연·염증 위험 주의 |
| 과도한 운동 | 수술 후 2개월 이후 | 사우나, 수영 포함 |
| 재수술 후 운동 | 수술 후 3개월 이후 | 홈페이지 안내 기준 |
표의 일정은 뷰성형외과 공식 홈페이지 안내 기준입니다. 실제 회복 속도, 절개 부위(유륜, 밑선 등), 수술 방법, 개인 건강 상태에 따라 달라질 수 있습니다. 본인의 정확한 일정은 상담에서 확인해야 합니다.
## 부기, 통증, 흉터 등 회복 과정 안내
- 수술 후 2~3일째 부기가 가장 많고, 3일 이후 붕대 제거가 가능합니다.
- 전체적인 부기는 1개월 정도 지나면서 자연스러워지고, 잔 붓기는 6개월까지 점차 감소합니다.
- 통증, 감각 저하는 평균 6개월에 걸쳐 서서히 회복됩니다.
- 흉터 관리는 절개 부위에 따라 실밥 제거 후 2주 뒤 또는 더마본드가 떨어진 후부터 시작합니다.
- 보정 속옷(스포츠브라)은 1~3개월 착용, 이후 일반 속옷 착용이 가능합니다.
- 수술 후 음주, 흡연은 최소 1개월 이후부터 권장합니다.
## 뷰성형외과 애프터케어 프로그램
- 수술 후 3~7일: 경과 체크, 드레싱, 샤워 및 일상생활(일부 절개 방식 제외) 가능
- 2주: 경과 체크, 가슴관리 교육, 고주파 또는 캡슐러티스 관리
- 1~3개월: 정기 경과 체크, 흉터관리(주사·레이저), 고주파/캡슐러티스 무제한 관리
- 6개월~1년: 유방·보형물 초음파 검진
- 1년 이후: 연 1회 유방 검진(별도 비용)
애프터케어 일정과 서비스 내용은 개인별 상태, 수술 방법, 절개 부위에 따라 달라질 수 있습니다. 모든 일정은 의료진 상담을 통해 조정될 수 있습니다.
## 운동, 사우나, 수영, 속옷 착용 등 주의사항
- 과도한 운동, 사우나, 수영장 이용은 수술 후 2개월 이후부터 권장합니다.
- 재수술의 경우 운동 재개는 3개월 이후로 안내합니다.
- 운동 시 가슴이 흔들리지 않게 스포츠브라 착용을 권장합니다.
- 일반 속옷은 3개월 이후, 보형물을 누르지 않는 디자인을 선택해야 합니다.
- 수술 후 음주, 흡연은 최소 1개월 이후부터 가능합니다.
## 개인차, 상담 필요성
- 회복 및 복귀 일정은 개인별 건강 상태, 절개 부위, 수술 방법, 직업 특성에 따라 달라질 수 있습니다.
- 본인의 정확한 복귀 시점, 운동 가능 시기, 샤워·속옷 착용 등은 상담에서 반드시 확인해야 합니다.
- 홈페이지, 애프터케어 안내와 실제 진료 시 안내가 다를 수 있으니, 상담 시 본인 상황을 구체적으로 질문하는 것이 필요합니다.
## 상담에서 확인할 것
- [ ] 내 직업/생활 패턴에 맞는 출근·운동 복귀 시점
- [ ] 절개 부위별 샤워·속옷 착용 가능 시점
- [ ] 애프터케어 프로그램 적용 범위와 추가 비용 여부
## 병원 확인 대기
이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.
- 직업별(육체노동, 서비스직 등) 복귀 가능 시점
- 절개 부위별(유륜, 밑선 등) 샤워 및 복귀 일정 상세
- 운동 종류별(걷기, 달리기, 웨이트 등) 재개 시점
- 수술 방법(보형물 종류, 삽입 위치 등)별 회복 차이
- 수술 후 운전 가능 시점
- 회복 지연 시 추가 관리 방법
- 애프터케어 프로그램의 세부 비용 및 포함 항목

View File

@ -0,0 +1,126 @@
---
title: 쌍꺼풀 수술은 자연유착·절개·콤비매몰 중 무엇을 어떤 기준으로 고르나요?
category: D
categoryLabel: 시술 정보
qbIds:
- D2-02
- D2-03
- D2-06
summary:
- 쌍꺼풀 수술 방식은 자연유착, 절개, 콤비매몰로 나뉘며, 절개 여부·흉터·회복 속도·풀림 가능성 등에서 차이가 있습니다. 홈페이지 안내 기준.
- 눈꺼풀 피부 두께, 지방량, 원하는 라인에 따라 적합한 방식이 달라집니다. 진료에서 결정합니다.
- 회복 일정과 관리 방법, 소시지눈과 단순 부기 구별 등은 개인차가 있으므로 상담에서 확인해야 합니다. 주의사항 기준.
description: 쌍꺼풀 수술의 자연유착, 절개, 콤비매몰 방식의 차이와 선택 기준을 뷰성형외과 공식 자료와 원장 영상 근거로 정리했습니다. 회복 일정, 부기 관리, 소시지눈 구별 등 상담 시 확인할 점을 안내합니다.
author: ansm
reviewer: dr-cho
reviewStatus: pending
datePublished: "2026-09-07"
dateModified: "2026-09-07"
faq:
- q: 자연유착, 절개, 콤비매몰 방식은 어떻게 다른가요?
a: 자연유착은 절개 없이 미세한 구멍만 내어 흉터가 거의 없고 회복이 빠르며, 절개는 피부를 절개해 불필요한 조직을 제거하므로 피부가 두껍거나 지방이 많은 눈에 적합합니다. 콤비매몰은 매몰법의 장점과 절개법의 고정력을 결합한 방식입니다. 방식별 적합성은 상담에서 확인해야 합니다.
- q: 수술 후 실밥 제거와 세안은 언제 가능한가요?
a: 자연유착은 실밥 제거가 없고, 절개는 7일 후, 콤비매몰은 3일 후 실밥을 제거합니다. 실밥 제거 후 다음날부터 가벼운 세안과 연한 화장이 가능합니다. 개인차가 있으니 상담에서 확인하세요.
- q: 부기는 얼마나 지속되나요?
a: 수술 후 2~3일째 부기가 가장 많고, 1개월 정도 지나면 대부분 자연스러워집니다. 잔 붓기는 6개월까지 점차 감소합니다. 개인차가 있습니다.
- q: 소시지눈과 단순 부기는 어떻게 구별하나요?
a: 수술 직후 붓기는 시간이 지나면 빠지지만, 소시지눈은 절개선 위쪽 피부가 두껍게 접혀 보이는 상태입니다. 회복 후 담당의 진찰로 구별합니다. 상담에서 확인하세요.
- q: 수술 방식은 어떻게 선택하나요?
a: 피부 두께, 지방량, 원하는 라인 등 여러 요소를 종합해 진료에서 결정합니다. 본인에게 적합한 방식은 상담에서 확인해야 합니다.
- q: 수술 후 진한 화장이나 미용렌즈는 언제부터 가능한가요?
a: 실밥 제거 후 다음날부터 연한 화장은 가능하지만, 진한 눈화장과 미용렌즈는 1개월간 삼가야 합니다. 주의사항 기준입니다.
- q: 각 방식의 수술 시간과 마취 방법은 어떻게 되나요?
a: 자연유착과 콤비매몰은 약 30분, 절개는 30분~1시간 소요되며 모두 수면마취로 진행됩니다. 홈페이지 안내 기준입니다.
- q: 흉터 관리나 부기 관리는 어떻게 하나요?
a: 눈 부위는 실밥 제거 후 상처 관리와 냉찜질, 인공눈물 사용 등으로 회복을 돕습니다. 흉터 연고는 눈 부위에는 사용하지 않습니다. 자세한 관리는 상담에서 안내받으세요.
sources:
- label: 뷰성형외과 홈페이지 · 뷰성형외과 자연유착 쌍꺼풀 수술
url: https://www.viewclinic.com/eye/double-eyelid/natural-fold/
accessed: "2026-09-07"
type: clinic
- label: 뷰성형외과 홈페이지 · 뷰성형외과 절개 쌍꺼풀 수술
url: https://www.viewclinic.com/eye/double-eyelid/incisional-method/
accessed: "2026-09-07"
type: clinic
- label: 뷰성형외과 홈페이지 · 뷰성형외과 콤비매몰, 절개법+자연유착
url: https://www.viewclinic.com/eye/double-eyelid/combo-non-incisional/
accessed: "2026-09-07"
type: clinic
- label: 뷰성형외과 홈페이지 · 뷰성형외과 수술 전·후 주의사항
url: https://www.viewclinic.com/system/precautions/
accessed: "2026-09-07"
type: clinic
- label: 뷰성형외과 홈페이지 · 뷰성형외과 애프터케어
url: https://www.viewclinic.com/system/after-care/
accessed: "2026-09-07"
type: clinic
- label: 유튜브 · [눈성형] 소시지눈과 단순부기, 어떻게 구별하나요?
url: https://www.youtube.com/watch?v=1OxjYxwciXw
accessed: "2026-09-04"
type: doctor
tags:
- 쌍꺼풀수술
- 자연유착
- 절개법
- 콤비매몰
- 회복일정
- 부기관리
history:
- date: "2026-09-07"
note: 자동 생성 초안 (gpt-4.1). 의학 검토 대기. 병원 확인 대기 7건
videos:
- id: 1OxjYxwciXw
title: "[눈성형] 소시지눈과 단순부기, 어떻게 구별하나요?"
published: "2025-02-20"
note: 붓기 구별 설명
gallery: []
---
쌍꺼풀 수술을 앞두고 자연유착, 절개, 콤비매몰 중 어떤 방식을 선택해야 할지 고민하는 분들을 위해, 뷰성형외과 공식 페이지와 원장 영상 근거만으로 각 방식의 차이와 선택 기준, 회복 일정, 주의사항을 정리했습니다. 본문에 없는 정보는 병원 공개 자료에 없으니 상담에서 반드시 확인하시기 바랍니다.
## 쌍꺼풀 수술 방식별 비교
| 구분 | 절개 여부 | 흉터 | 실밥 제거 | 일상 복귀 | 풀림 가능성 | 적합한 눈 조건 |
|-------------|------------------|--------------|-----------|-----------|--------------|-------------------------|
| 자연유착 | 절개 없음 | 거의 없음 | 없음 | 1일 후 | 있음 | 피부 얇고 지방 적은 눈 |
| 절개 | 절개함 | 있음(최소화) | 7일 후 | 7일 후 | 적음 | 피부 두껍고 지방 많은 눈 |
| 콤비매몰 | 절개 없음(매몰) | 거의 없음 | 3일 후 | 3일 후 | 적음(보완) | 다양한 눈, 중간 조건 |
*표의 내용은 뷰성형외과 공식 안내 기준입니다. 회복 속도, 흉터, 풀림 가능성 등은 개인차가 있으며, 적합한 눈 조건은 진료에서 최종 결정합니다. 병원 공개 자료에 없는 세부 조건은 상담에서 확인하세요.*
## 수술 후 회복 및 주의사항 (눈 기준)
- 자연유착: 실밥 제거 없음, 수술 다음날부터 일상생활 가능(홈페이지 안내 기준)
- 절개: 실밥 제거 7일 후, 일상 복귀 7일 후(홈페이지 안내 기준)
- 콤비매몰: 실밥 제거 3일 후, 일상 복귀 3일 후(홈페이지 안내 기준)
- 부기는 2~3일째 가장 많고, 1개월 정도 지나면 자연스러워집니다. 잔 붓기는 6개월까지 점차 감소합니다. (주의사항 기준)
- 실밥 제거 후 다음날부터 가벼운 세안, 연한 화장이 가능합니다. 진한 눈화장, 미용렌즈는 1개월간 삼가야 합니다. (주의사항 기준)
*회복 일정과 부기, 세안 가능 시점 등은 개인차가 있으며, 수술 방식과 개인 상태에 따라 달라질 수 있습니다. 반드시 상담에서 본인에게 맞는 일정을 확인하세요.*
## 소시지눈과 단순 부기 구별
수술 직후의 붓기는 시간이 지나면서 자연스럽게 빠지지만, 소시지눈은 절개선 위쪽 피부가 두껍게 접혀 보이는 상태를 말합니다. 라인이 너무 높게 잡혔거나 조직량 차이가 원인일 수 있습니다. 붓기가 충분히 빠지기 전에는 구별이 어렵기 때문에, 회복 후 담당의 진찰로 판단합니다. (영상 사례)
## 방식 선택 기준
- 피부 두께, 지방량, 원하는 쌍꺼풀 라인, 눈꺼풀 상태 등 다양한 요소를 종합해 진료에서 결정합니다. (홈페이지 안내)
- 모든 방식은 1:1 맞춤 디자인과 상담을 통해 결정되며, 동일한 눈에도 여러 방식이 적용될 수 있습니다.
- 수술 결과와 회복 과정, 부작용 가능성 등은 개인별로 다르므로, 상담 시 본인 눈 상태에 맞는 방식과 주의사항을 반드시 확인해야 합니다.
## 상담에서 확인할 것
- 내 눈에 가장 적합한 수술 방식과 그 이유
- 회복 일정(실밥, 세안, 화장, 렌즈 착용 등)과 개인별 주의사항
- 소시지눈 등 부작용 발생 시 대처 방법 및 진료 일정
## 병원 확인 대기
이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.
- 매몰법(일반)과 자연유착, 콤비매몰의 정확한 차이
- 방식별 풀림률, 재수술률, 장기 유지율
- 방식별 통증, 마취 방법의 세부 차이
- 눈꺼풀 근육 상태에 따른 방식 선택 기준
- 방식별 가격 정보
- 수술 후 일상 복귀까지 평균 소요 기간(직업별)
- 방식별 부작용(감각저하, 비대칭 등) 발생률

View File

@ -0,0 +1,168 @@
---
title: 뷰성형외과는 어떤 병원인가요?
category: A
categoryLabel: 병원 소개
qbIds:
- A-02
- A-05
- A-07
- A-08
summary:
- 뷰성형외과는 서울 강남 신논현역 인근에 위치한 19층 규모의 성형외과입니다. 홈페이지 안내 기준.
- 성형외과, 이비인후과, 외과, 피부과, 마취통증의학과 등 다양한 진료과와 협진 시스템을 갖추고 있습니다. 진료과 및 협진 범위는 홈페이지 안내 기준, 상담에서 확인 필요.
- 수술 전후 안전 시스템, 마취통증의학과 전문의 상주, 수술 실명제 등 안전 관리에 중점을 두고 있습니다. 홈페이지 안내 기준, 세부 운영 방식은 상담에서 확인하세요.
description: 뷰성형외과의 위치, 진료과, 의료진, 시설, 안전 시스템, 수상 이력 등 공식 홈페이지와 팩트 시트 근거로 정리했습니다.
author: ansm
reviewer: dr-choi
reviewStatus: pending
datePublished: "2026-09-07"
dateModified: "2026-09-07"
hero:
src: /img/clinic/building-clean.jpg
alt: 뷰성형외과 빌딩 외관. 유리 외벽 상단에 VIEW 로고가 붙은 고층 건물
caption: "뷰성형외과 빌딩. 출처: 공식 홈페이지 병원둘러보기 페이지 화면 캡처 (2026-09-04)"
faq:
- q: 뷰성형외과는 어디에 있나요?
a: 서울 강남구 봉은사로 107, 뷰성형외과빌딩에 위치하며, 신논현역 3번 출구에서 도보 50m 이내입니다. 공식 홈페이지 및 팩트 시트 기준입니다.
- q: 진료과는 어떤 과가 있나요?
a: 성형외과, 이비인후과, 외과(유방검진·맘모톰), 피부과, 마취통증의학과, 영상의학과가 있습니다. 홈페이지 안내 기준, 세부 진료 범위는 상담에서 확인하세요.
- q: 주요 의료진은 누구인가요?
a: 대표원장은 최순우 성형외과 전문의이며, 성형외과, 이비인후과, 외과, 마취통증의학과 등 각 분야 전문의가 상주합니다. 의료진 상세 명단은 공식 홈페이지에서 확인할 수 있습니다.
- q: 병원 건물 규모와 시설은 어떻게 되나요?
a: 지하 1층부터 15층까지 19층 규모의 자체 사옥을 사용하며, 수술센터, 입원센터, 상담센터, 치과, 피부과, VIP실 등 층별로 기능이 나뉩니다. 공식 홈페이지 기준입니다.
- q: 안전 시스템은 어떻게 운영되나요?
a: 마취통증의학과 전문의 상주, 1:1 전담 모니터링, 수술 실명제, CCTV 설치 등 안전 관리 체계를 갖추고 있습니다. 세부 운영 방식은 상담에서 확인하세요.
- q: 외부 평가나 수상 이력이 있나요?
a: 모티바 그랜드 어워드, 멘토 보형물 사용량 1위, '렛미인' 닥터스 참여 등 공식 수상 및 외부 활동 이력이 있습니다. 공식 홈페이지 기준입니다.
- q: 진료 시간과 주차 안내는 어떻게 되나요?
a: 월~목 10:00~19:00, 금 10:00~21:00, 토 10:00~17:00 진료하며, 일·공휴일은 휴진입니다. 주차장은 봉은사로 1길 4로 네비게이션 설정이 필요합니다. 2026-09-04 확인 기준입니다.
sources:
- label: 뷰성형외과 홈페이지 · 병원소개(뷰 특별함)
url: https://www.viewclinic.com/viewis/specialties/
accessed: "2026-09-04"
type: clinic
- label: 뷰성형외과 홈페이지 · 병원둘러보기(층별 안내)
url: https://www.viewclinic.com/viewis/facilities/
accessed: "2026-09-04"
type: clinic
- label: 뷰성형외과 홈페이지 · 의료진 소개
url: https://www.viewclinic.com/viewis/doctors/
accessed: "2026-09-04"
type: clinic
- label: 강남언니 · 뷰성형외과의원 병원 페이지
url: https://www.gangnamunni.com/hospitals/189
accessed: "2026-09-04"
type: platform
- label: 유튜브 @ViewclinicKR 채널 설명
url: https://www.youtube.com/@ViewclinicKR
accessed: "2026-09-04"
type: doctor
tags:
- 성형외과
- 강남
- 진료과
- 의료진
- 병원시설
history:
- date: "2026-09-07"
note: 자동 생성 초안 (gpt-4.1). 의학 검토 대기. 병원 확인 대기 3건
videos:
- id: Nbemu6mL_uY
title: "가장 VIEW다운 가치 : 안전과 신뢰의 약속"
published: "2026-05-19"
- id: 24of08iA8iE
title: 모티바가 뷰성형외과에 보낸 깜짝 선물
published: "2026-08-01"
note: 모티바 No.1 Champion 감사패
- id: _OZRSJIDWgs
title: 스포르자가 또 방문한 이유, 모티바 프리저베 교육 현장
published: "2026-08-20"
gallery:
- src: /img/breast/dream-team.jpg
alt: 뷰성형외과 가슴성형 의료진 단체 사진. 흰 가운을 입은 의사 12명
caption: "가슴성형 의료진. 출처: 홈페이지 가슴성형 페이지"
- src: /img/clinic/news-2.jpg
alt: 이탈리아 공영방송 RAI News 취재 장면. 의료진 3명이 카메라 앞에 서 있음
caption: "이탈리아 공영방송 RAI News 취재. 출처: 홈페이지 병원소개"
- src: /img/clinic/news-6.jpg
alt: 싱가포르 The Straits Times 인터뷰 촬영 장면
caption: "싱가포르 The Straits Times 인터뷰. 출처: 홈페이지 병원소개"
- src: /img/clinic/news-5.jpg
alt: 뉴질랜드 신문 Stuff의 K-뷰티 관광 기사 화면
caption: "뉴질랜드 Stuff 취재. 출처: 홈페이지 병원소개"
- src: /img/clinic/news-3.jpg
alt: 키르기즈스탄 방송 취재 장면. 원장이 카메라 앞에서 상담 시연
caption: "키르기즈스탄 Channel 취재. 출처: 홈페이지 병원소개"
- src: /img/clinic/news-7.jpg
alt: 수술 실명제 인증 시스템 도입 안내 화면
caption: "수술 실명제 인증 시스템. 출처: 홈페이지 병원소개"
thumbnail: /img/clinic/building-clean.jpg
---
뷰성형외과는 서울 강남구 신논현역 3번 출구 인근에 위치한 19층 규모의 성형외과 전문 병원입니다. 병원 공식 자료와 팩트 시트를 바탕으로, 진료과 구성, 의료진, 주요 시설, 안전 시스템, 수상 이력 등 상담 전 확인할 만한 핵심 정보를 정리했습니다.
## 주요 진료과 및 의료진 구성
뷰성형외과는 성형외과, 이비인후과, 외과(유방검진·맘모톰), 피부과, 마취통증의학과, 영상의학과 등 다양한 진료과를 운영합니다. 성형외과 전문의가 다수 상주하며, 협진 체계를 통해 양악·안면윤곽 등 복합 수술 시 치과와의 연계 진료가 가능합니다. 대표원장은 최순우 성형외과 전문의입니다. [홈페이지 안내 기준]
### 의료진(2026-09-07 기준)
- 성형외과 전문의: 최순우, 윤창운, 김정민, 김도형, 정재현, 조진우, 손유성, 이동규, 김소영, 오원석, 박건욱, 박진우, 오정석, 오준석
- 이비인후과 전문의: 고은석
- 외과 전문의(유방검진): 김민수
- 영상의학과 전문의: 김영민, 이승현, 이지현, 오혜미
- 마취통증의학과 전문의: 성보현, 최진화, 송규영, 김유나, 안예나, 김경림
## 병원 건물 및 주요 시설
뷰성형외과는 지하 1층부터 15층까지 자체 사옥을 사용하며, 각 층별로 진료와 수술, 입원, 상담, 검진, 교육 등 기능이 분리되어 있습니다. 주요 시설은 아래와 같습니다.
| 층수 | 주요 용도 |
|---------|----------------------------|
| 15F | 치과(양악·안면윤곽 협진) |
| 11~14F | 수술센터, 치료센터 |
| 8·10F | 입원센터 |
| 9F | 대강당(교육장) |
| 7F | VIP 입원실 |
| 4~5F | 상담센터 |
| 3F | 피부과, VIP 상담실 |
| 1F | 안내데스크 |
| B1F | 유방외과, 검진센터 |
표의 시설 분류와 용도는 공식 홈페이지 기준입니다. 실제 이용 시 층별 배치나 세부 운영 방식은 상담에서 확인이 필요합니다.
## 안전 시스템 및 관리
- 마취통증의학과 전문의가 상주하며, 수술 전후 1:1 전담 모니터링을 진행합니다.
- 수술실 내부와 주요 공간에 CCTV가 설치되어 대리수술 방지 구역을 운영합니다.
- 수술 전 검진센터에서 원스톱 검진 후 수술 여부를 결정합니다.
- 수술 실명제를 시행하고 있습니다.
- 입원센터에는 24시간 간호사가 상주하여 환자 상태를 실시간으로 체크합니다.
세부 안전 시스템의 실제 운영 방식, CCTV 설치 범위, 모니터링 방식 등은 병원 공개 자료에 없습니다. 상담에서 직접 확인하시기 바랍니다.
## 진료과목 및 전문 분야
뷰성형외과의 주요 진료 분야는 가슴성형, 안면윤곽, 양악수술, 눈성형, 코성형, 지방성형, 안티에이징·리프팅, 남자성형, 피부과, 모발이식 등입니다. 세부 시술별 담당 의료진, 수술 가능 범위 등은 상담에서 확인할 수 있습니다.
## 수상 이력 및 외부 평가
- 모티바 그랜드 어워드 4회 수상(2020, 2021, 2024, 2025)
- 모티바 글로벌 KOL 4년 연속 위촉(2023~2026)
- 모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)
- 멘토 보형물 사용량 1위(2016, 2017)
- 메이크오버 쇼 '렛미인' 시즌 2~5 닥터스 참여
외부 플랫폼 평점(2026-09-04 확인 기준):
- 강남언니 9.5점(19,373건), 구글 4.9점(901건)
## 진료 시간 및 위치
- 위치: 서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩(신논현역 3번 출구 50m)
- 진료시간: 월~목 10:00~19:00, 금 10:00~21:00, 토 10:00~17:00, 일·공휴일 휴진(2026-09-04 기준)
- 주차장 진입은 봉은사로 1길 4로 네비게이션 설정 필요
## 상담에서 확인할 것
- [ ] 수술별 담당 의료진 및 협진 범위
- [ ] 안전 시스템의 실제 운영 방식(CCTV, 모니터링 등)
- [ ] 시술별 세부 진료 가능 여부 및 대기 기간
## 병원 확인 대기
이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.
- 수술별 담당 의료진과 세부 협진 범위
- CCTV 설치 위치와 실제 운영 방식
- 시술별 진료 가능 여부 및 대기 기간

View File

@ -23,3 +23,6 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
.vercel
# 글 생성기 산출물 (리포트는 docs 로 옮겨 커밋)
generated/

View File

@ -0,0 +1,54 @@
# 기획 목록(brief) 형식
글 생성기(`scripts/generate_posts.mjs`)의 입력이다. v2 §3-11의 "기획 목록 승인"은 이 파일을 사람이 확인하는 것으로 한다. 생성기는 brief 밖의 근거를 쓰지 않는다.
```
node scripts/generate_posts.mjs --clinic <id> --brief briefs/<id>/<name>.json [--out DIR] [--dry-run] [--no-verify]
node scripts/generate_posts.mjs --clinic viewclinic --from-golden # 정답지 18편에서 brief 를 자동 추출해 회귀
```
## 파일
```json
{
"posts": [
{
"id": "slug", // 파일명. posts/<id>.md
"title": "질문형 한 문장?", // H1. 물음표로 끝난다
"category": "D", "categoryLabel": "시술 정보",
"qbIds": ["D2-02"], // 질문 뱅크 문항 ID (화면에는 안 나옴)
"intent": "무엇을 어떤 형식으로 정리할지 한 문단", // 선택
"mustCover": ["..."], "avoid": ["..."], // 선택
"reviewerCandidate": "dr-cho", // authors.json physicians 키. 후보일 뿐, reviewStatus 는 항상 pending
"evidence": {
"pages": ["https://.../system/precautions/"], // 병원 페이지. evidence/<clinic>/pages 에 있어야 함
"shorts": ["videoId"], // videos.json shortsInfo 에 정리 답이 있는 영상
"transcripts": [{ "id": "", "title": "", "speaker": "", "text": "" }], // 선택. 자막이 있으면 여기로
"news": ["https://..."], // news.json 에 있는 기사 URL (제목·매체·날짜만 쓴다)
"regulation": [{ "label": "", "url": "", "note": "확인된 내용 한 줄" }], // note 가 없으면 링크로만 안내
"platform": false // true 면 팩트 시트 surfaces 를 sources 에 붙인다
},
"videos": [{ "id": "", "title": "", "speaker": "", "published": "", "note": "" }], // 글에 붙는 영상 (제목·화자만)
"hero": { "src": "/img/...", "alt": "", "caption": "" }, // 선택
"gallery": [], "thumbnail": "", // 선택
"sources": [] // 주면 evidence 로부터의 자동 조립 대신 이것을 쓴다 (정답지 회귀용)
}
]
}
```
## 생성 흐름
1. 컨텍스트: evidence 를 §4 순서(병원 페이지 → 영상 정리 답 → 기사 → 규제 원문 → 팩트 시트)로 붙인다. 없는 근거는 `missingEvidence` 로 리포트에 남긴다.
2. 생성(1회): 요약 3·본문·FAQ·태그·pending(근거에 없어 못 쓴 것)·usedRefs.
3. 후처리: 근거 태그(`[C1]`) 제거, 본문의 FAQ·참고 자료·연락처 섹션 제거(템플릿이 붙인다).
4. 검사: 소스 게이트(`gate/rules.mjs` 전부 + 금칙어 + 비교 수치 + 홈페이지 40자 중복) + 수치 대조(글의 숫자+단위가 근거에 그대로 있어야 함) + 모델 근거 대조(`--no-verify` 로 생략).
5. 오류가 있거나 경고가 `--warn-threshold`(기본 4) 이상이면 오류 목록을 되돌려 수정 1회. 그래도 남은 근거 플래그는 `ok_needs_review` 로 표시해 사람이 본다.
6. 저장: `generated/<clinic>/<시각>/posts/*.md` + `report.md`(편당 비용·시간·오류·확인 대기 목록). `generated/` 는 커밋하지 않는다. 채택할 글은 사람이 `src/content/posts/` 로 옮긴다.
## 생성기가 하지 않는 것
- `reviewedAt`·`reviewStatus: reviewed` 를 쓰지 않는다 (§3-7 사람 게이트).
- 자막 없는 영상의 발언을 만들지 않는다. 영상 근거는 `shortsInfo[].answer` 또는 `transcripts` 뿐이다.
- 기사 본문을 추정하지 않는다. 제목·매체·날짜만.
- 근거에 없는 수치를 쓰지 않는다. 쓰면 `NUMBER_NOT_IN_EVIDENCE` 로 수정 대상이 된다.

View File

@ -0,0 +1,33 @@
{
"_comment": "기획 목록(brief) 예시. 정답지에 없는 새 문항. evidence 의 URL·영상 ID는 근거 수집 결과(evidence/viewclinic)와 videos.json 에 있어야 한다. sources 는 여기서 주지 않으면 evidence 로부터 자동 조립된다. 검토자는 후보일 뿐이고 reviewStatus 는 항상 pending 으로 나온다.",
"posts": [
{
"id": "eye-double-eyelid-methods",
"title": "쌍꺼풀 수술은 자연유착·절개·콤비매몰 중 무엇을 어떤 기준으로 고르나요?",
"category": "D",
"categoryLabel": "시술 정보",
"qbIds": ["D2-02", "D2-03", "D2-06"],
"intent": "세 방식의 차이(절개 여부·붓기·회복·풀림 가능성·적합한 눈 조건)를 조건 표로 정리하고, 회복 일정은 주의사항 페이지의 눈 부분 문장만 근거로 쓴다. 방식 선택은 진료에서 정한다고 닫는다.",
"mustCover": ["방식 비교 표(절개 여부·흉터·붓기·회복·풀림)", "수술 후 주의사항의 눈 부분(실밥·세안·렌즈·붓기)", "소시지눈과 단순 부기의 구별(쇼츠 정리 답 범위 안에서)"],
"avoid": ["전후 사진", "가격", "다른 병원 비교", "효과 보장"],
"reviewerCandidate": "dr-cho",
"evidence": {
"pages": [
"https://www.viewclinic.com/eye/double-eyelid/natural-fold/",
"https://www.viewclinic.com/eye/double-eyelid/incisional-method/",
"https://www.viewclinic.com/eye/double-eyelid/combo-non-incisional/",
"https://www.viewclinic.com/system/precautions/",
"https://www.viewclinic.com/system/after-care/"
],
"shorts": ["1OxjYxwciXw"],
"news": [],
"regulation": [],
"platform": false
},
"videos": [
{ "id": "1OxjYxwciXw", "title": "[눈성형] 소시지눈과 단순부기, 어떻게 구별하나요?", "published": "2025-02-20", "note": "붓기 구별 설명" }
],
"gallery": []
}
]
}

View File

@ -12,7 +12,9 @@
"astro": "astro",
"check": "node scripts/gate/test.mjs && node scripts/check.mjs",
"gate:test": "node scripts/gate/test.mjs",
"export:template": "node scripts/export_template.mjs"
"export:template": "node scripts/export_template.mjs",
"generate": "node scripts/generate_posts.mjs",
"generate:regress": "node scripts/generate_posts.mjs --clinic viewclinic --from-golden"
},
"dependencies": {
"astro": "^7.3.1"

View File

@ -0,0 +1,8 @@
<!-- expect: COMPARISON_CLAIM -->
<html><body>
<h1>안전 체계는 어떻게 되어 있나요?</h1>
<table><tr><th>항목</th><th>안내</th></tr>
<tr><td>마취과 전문의 상주</td><td>병원급 이상 평균 23.1%, 뷰성형외과 43% 상주</td></tr>
</table>
<p>다른 병원에서 한 수술도 재수술 상담이 됩니다. 상담에서 확인하세요.</p>
</body></html>

View File

@ -227,11 +227,25 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
];
export function checkBannedBody(text) {
return BANNED_BODY.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'BANNED_PHRASE', `금칙 표현: "${m[0]}"`));
return [
...BANNED_BODY.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'BANNED_PHRASE', `금칙 표현: "${m[0]}"`)),
...checkComparison(text),
];
}
/** 타 병원·업계 평균과의 수치 비교 (§3-8 비교 광고, 의료법 56조 2항 4호). "다른 병원에서 한 수술" 같은 서술은 수치가 없어 통과. */
const CMP_SUBJECT = '(병원급|의원급|업계|타\\s?병원|다른\\s?병원|타원|경쟁\\s?병원)';
export const COMPARISON_CLAIM = [
new RegExp(`${CMP_SUBJECT}[^.\\n]{0,30}(평균|대비|보다)[^.\\n]{0,30}\\d+(\\.\\d+)?\\s?%`),
new RegExp(`\\d+(\\.\\d+)?\\s?%[^.\\n]{0,30}${CMP_SUBJECT}`),
/평균\s*\d+(\.\d+)?\s?%[^.\n]{0,30}(상주|보유|운영)/,
];
export function checkComparison(text) {
return COMPARISON_CLAIM.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'COMPARISON_CLAIM', `타 병원·업계 비교 수치: "${m[0].slice(0, 70)}"`));
}
// ---------- §3-11 원천 콘텐츠 ----------

View File

@ -63,5 +63,31 @@ const negatives = [
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
// 5) 생성기 직렬화 왕복: 정답지 frontmatter → js-yaml dump → 다시 파싱해도 값이 같고, 소스 게이트(금칙어·비교 수치·홈페이지 중복 포함) error 0
{
const { toMarkdown, gatePost } = await import('../gen/serialize.mjs');
const homeText = readFileSync(here('../home_text.txt'), 'utf8');
const KEYS = ['title', 'summary', 'description', 'faq', 'sources', 'videos', 'qbIds', 'tags', 'hero', 'thumbnail', 'reviewer', 'reviewedAt', 'datePublished'];
let rt = 0;
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const g = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const md = toMarkdown(g.data, g.body);
const m = R.parseMarkdown(md);
for (const k of KEYS) if (JSON.stringify(g.data[k] ?? null) !== JSON.stringify(m.data[k] ?? null)) { rt++; bad(`왕복 ${f}`, `${k} 값이 바뀜`); }
if (typeof m.data.datePublished !== 'string') { rt++; bad(`왕복 ${f}`, 'datePublished 가 문자열이 아님 (YAML 날짜 자동 변환)'); }
const errs = gatePost(md, { ...ctx, homeText }).errors;
if (errs.length) { rt++; errs.forEach((e) => bad(`생성기 게이트 ${f}`, `[${e.code}] ${e.msg}`)); }
}
if (!rt) ok('생성기 직렬화 왕복·소스 게이트', '18편 값 동일, error 0');
}
// 6) 수치 대조·비교 수치 (생성기 전용 규칙)
{
const cmp = R.checkComparison('마취과 전문의 상주 비율은 병원급 이상 평균 23.1%인데 뷰성형외과는 43%입니다.');
cmp.length ? ok('비교 수치 검출', cmp[0].code) : bad('비교 수치 검출', '병원급 평균 23.1% vs 43% 를 못 잡음');
const okCmp = R.checkComparison('다른 병원에서 한 수술도 재수술 상담이 되나요? 상담에서 확인하세요.');
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
}
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);

View File

@ -0,0 +1,142 @@
// 글 생성기의 근거 컨텍스트 조립. v2 §4 근거 프로토콜의 순서를 그대로 입력 구조로 만든다.
// 1 병원 공식 페이지 원문 (evidence/<clinic>/pages) → [C1..]
// 2 쇼츠 정리 답 (videos.json.shortsInfo[].answer) → [S1..]
// (선택) 자막 전사 brief.evidence.transcripts → [T1..]
// 3 원장 기고·인터뷰 기사 (news.json, 제목·매체·날짜만) → [N1..]
// 4 규제·연구 원문 (brief.evidence.regulation) → [R1..]
// 5 플랫폼 집계·팩트 시트 (factSheet.json) → [F]
// 없는 근거는 missing 으로 돌려주고, 모델에게는 "없다"고 알린다. 사실을 만들어 채우지 않는다.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
export function normUrl(u) {
try {
const x = new URL(u);
let s = x.host.replace(/^www\./, '') + x.pathname;
try { s = decodeURIComponent(s); } catch { /* 그대로 */ }
return s.replace(/\/+$/, '').toLowerCase();
} catch { return String(u).replace(/\/+$/, '').toLowerCase(); }
}
export function loadEvidence(evDir) {
const index = JSON.parse(readFileSync(join(evDir, 'index.json'), 'utf8'));
const byUrl = new Map();
const pages = [];
const pagesDir = join(evDir, 'pages');
for (const f of readdirSync(pagesDir).filter((x) => x.endsWith('.json'))) {
const p = JSON.parse(readFileSync(join(pagesDir, f), 'utf8'));
pages.push(p);
for (const u of [p.url, p.requestedUrl]) if (u) byUrl.set(normUrl(u), p);
}
const doctorsPath = join(evDir, 'doctors.json');
const doctors = existsSync(doctorsPath) ? JSON.parse(readFileSync(doctorsPath, 'utf8')).doctors ?? [] : [];
const homePath = join(evDir, 'home_text.txt');
const homeText = existsSync(homePath) ? readFileSync(homePath, 'utf8') : '';
return { clinic: index.clinic, start: index.start, fetchedAt: index.fetchedAt, pages, byUrl, doctors, homeText };
}
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. */
export function pageToText(p, { maxChars = 9000 } = {}) {
const lines = [];
for (const it of p.items ?? []) {
if (!it?.text && it?.kind !== 'table') continue;
if (it.kind === 'h') lines.push(`${'#'.repeat(Math.min(Math.max(it.level ?? 2, 2), 4))} ${it.text}`);
else if (it.kind === 'li') lines.push(`- ${it.text}`);
else if (it.kind === 'table') for (const row of it.rows ?? []) lines.push('| ' + row.map((c) => String(c).replace(/\|/g, '/')).join(' | ') + ' |');
else lines.push(it.text);
}
let t = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
if (t.length > maxChars) t = t.slice(0, maxChars) + `\n…(이 페이지는 ${t.length - maxChars}자가 더 있으나 생략됨. 생략된 부분의 사실은 쓰지 않는다)`;
return t;
}
const SOURCE_TYPE_LABEL = { clinic: '병원 제공', doctor: '원장 설명', product: '제품 정보', regulation: '규제·연구', platform: '플랫폼 집계', secondary: '2차 자료' };
/**
* brief 하나에 대한 컨텍스트 문자열과 sources 배열을 만든다.
* brief.evidence = { pages: [url], shorts: [videoId], transcripts: [{id,title,speaker,text}], news: [url], regulation: [{label,url,note}], platform: bool }
*/
export function buildContext(brief, { ev, fact, authors, videos, news, today }) {
const parts = [];
const missing = [];
const sources = [];
const evd = brief.evidence ?? {};
const clinicName = fact?.shortName ?? fact?.name ?? ev.clinic;
const seenSrc = new Set();
const pushSource = (s) => { const k = normUrl(s.url); if (seenSrc.has(k)) return; seenSrc.add(k); sources.push(s); };
// 1. 병원 공식 페이지
const cparts = [];
(evd.pages ?? []).forEach((url, i) => {
const p = ev.byUrl.get(normUrl(url));
if (!p) { missing.push(`병원 페이지가 근거 수집 결과에 없음: ${url}`); return; }
const imgText = Array.isArray(p.flags) ? p.flags.includes('textInImages') : Boolean(p.flags?.textInImages);
if (!p.items?.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자)' : ''}: ${url}`); return; }
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}\n\n${pageToText(p)}`);
pushSource({ label: `${clinicName} 홈페이지 · ${cleanTitle(p.title, clinicName)}`, url: p.url, accessed: p.fetchedAt ?? today, type: 'clinic' });
});
parts.push(`## 1. 병원 공식 페이지 원문 (문장을 그대로 옮기지 말 것. 40자 이상 연속 일치는 발행 차단)\n\n${cparts.length ? cparts.join('\n\n') : '(제공된 페이지 없음)'}`);
// 2. 쇼츠 정리 답 + 자막
const sparts = [];
(evd.shorts ?? []).forEach((id) => {
const s = (videos?.shortsInfo ?? []).find((x) => x.id === id);
if (!s?.answer) { missing.push(`쇼츠 정리 답 없음: ${id}`); return; }
sparts.push(`### [S${sparts.length + 1}] 영상 「${s.title}」 (게시 ${s.published ?? '?'}, 조회수 ${fmtNum(s.views)} · 확인 ${videos.fetchedAt})\n편집자가 정리한 한 문단(이 문단의 범위 안에서만 영상 내용을 쓴다):\n${s.answer}`);
pushSource({ label: `유튜브 · ${s.title}`, url: `https://www.youtube.com/watch?v=${s.id}`, accessed: videos.fetchedAt ?? today, type: 'doctor' });
});
(evd.transcripts ?? []).forEach((t) => {
if (!t?.text) return;
sparts.push(`### [T${sparts.length + 1}] 영상 「${t.title ?? t.id}」 자막 전사${t.speaker ? ` (화자: ${t.speaker})` : ''}\n${String(t.text).slice(0, 12000)}`);
pushSource({ label: `유튜브 · ${t.title ?? t.id}${t.speaker ? ` (${t.speaker})` : ''}`, url: `https://www.youtube.com/watch?v=${t.id}`, accessed: today, type: 'doctor' });
});
parts.push(`## 2. 원장 영상 근거 (여기 없는 발언은 만들지 않는다)\n\n${sparts.length ? sparts.join('\n\n') : '(영상 근거 없음. 영상 발언을 인용하지 않는다)'}`);
// 3. 기사
const nparts = [];
(evd.news ?? []).forEach((url) => {
const n = (news?.items ?? []).find((x) => normUrl(x.url) === normUrl(url));
if (!n) { missing.push(`뉴스 항목 없음: ${url}`); return; }
nparts.push(`### [N${nparts.length + 1}] ${n.outlet} · ${n.date} · 「${n.title}」 (${n.kind === 'release' ? '병원 보도자료' : n.kind === 'column' ? '원장 기고·인터뷰' : '언론 언급'})\n본문은 제공되지 않는다. 제목·매체·날짜 범위에서만 언급할 수 있다.`);
pushSource({ label: `${n.outlet} · ${n.title} (${n.date})`, url: n.url, accessed: today, type: n.kind === 'release' ? 'clinic' : n.kind === 'column' ? 'doctor' : 'secondary' });
});
parts.push(`## 3. 기사\n\n${nparts.length ? nparts.join('\n\n') : '(기사 근거 없음)'}`);
// 4. 규제·연구 원문
const rparts = [];
(evd.regulation ?? []).forEach((r) => {
if (!r?.url) return;
rparts.push(`### [R${rparts.length + 1}] ${r.label}\nURL: ${r.url}\n확인된 내용: ${r.note ?? '요약 없음. 이 자료의 수치·결론을 인용하지 말고, 「원문에서 확인」으로 링크만 안내한다.'}`);
pushSource({ label: r.label, url: r.url, accessed: r.accessed ?? today, type: 'regulation' });
});
parts.push(`## 4. 규제·연구 원문\n\n${rparts.length ? rparts.join('\n\n') : '(규제·연구 근거 없음. FDA·ISO·승인·연구를 언급하지 않는다)'}`);
// 5. 팩트 시트 (플랫폼 집계 포함)
if (fact) {
const slim = { ...fact };
delete slim._comment;
parts.push(`## 5. 병원 팩트 시트 (확인일 ${fact.checkedAt ?? '?'}, 다음 확인 ${fact.nextCheck ?? '?'}). 플랫폼 집계 수치를 쓰면 같은 문장에 확인일을 붙인다\n\n${JSON.stringify(slim, null, 1).slice(0, 7000)}`);
const surf = fact.surfaces ?? {};
for (const [k, s] of Object.entries(surf)) if (s?.url && evd.platform) pushSource({ label: `${s.label ?? k} · ${clinicName}`, url: s.url, accessed: s.checkedAt ?? fact.checkedAt ?? today, type: 'platform' });
}
// 6. 의료진 (이름·직함만. 약력은 authors.json 에 있는 것만)
const phys = Object.entries(authors?.physicians ?? {}).map(([id, p]) => `- ${id}: ${p.name} · ${p.title}${p.specialty ? ` · ${p.specialty}` : ''}`);
parts.push(`## 6. 의료진 (이 목록 밖의 원장 이름을 쓰지 않는다)\n\n${phys.join('\n') || '(등록된 의료진 없음)'}`);
// 7. 관련 영상 목록 (제목·화자만)
const vids = (brief.videos ?? []).map((v) => `- ${v.id} 「${v.title}」${v.speaker ? ` · ${v.speaker}` : ''}${v.published ? ` · ${v.published}` : ''}${v.note ? ` · ${v.note}` : ''}`);
parts.push(`## 7. 글에 붙는 영상 목록 (제목·화자만 안다. 내용은 2절에 있는 것만 쓴다)\n\n${vids.join('\n') || '(없음)'}`);
// brief 가 sources 를 직접 주면 그것을 우선(정답지 회귀용). type 없는 항목은 규칙상 실패이므로 secondary 로 채우고 경고.
const finalSources = brief.sources?.length
? brief.sources.map((s) => ({ ...s, accessed: s.accessed ?? today, type: s.type ?? 'secondary' }))
: sources;
return { text: parts.join('\n\n'), sources: finalSources, missing, typeLabel: SOURCE_TYPE_LABEL };
}
function cleanTitle(t, clinicName) {
return String(t ?? '').replace(new RegExp(`\\s*\\|\\s*${clinicName}.*$`), '').replace(/\s*\|\s*뷰성형외과.*$/, '').trim() || '페이지';
}
function fmtNum(n) { return typeof n === 'number' ? n.toLocaleString('ko-KR') : String(n ?? '?'); }

View File

@ -0,0 +1,151 @@
// 글 생성 프롬프트와 출력 스키마. 규칙의 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3·§4·§12-2.
// 여기 적힌 규칙은 대부분 scripts/gate/rules.mjs 와 generate_posts.mjs 의 수치 대조·검증 단계가 다시 검사한다. 프롬프트는 1차 방어, 게이트가 최종.
export const SYSTEM_PROMPT = `당신은 성형외과 서포터즈 매체의 편집자입니다. 병원이 지원하는 매체이므로 광고로 읽히지 않으려면 사실만, 근거가 있는 것만 씁니다.
독자는 상담을 앞둔 사람입니다. 독자가 상담에서 무엇을 묻고 무엇을 확인해야 하는지 알게 하는 것이 글의 목적입니다.
# 사실의 출처 (이 순서로만, 밖의 사실은 쓰지 않는다)
1. 병원 공식 페이지 원문 [C]. 문장을 옮기되 40자 이상 연속으로 같으면 안 됩니다. 반드시 다시 씁니다.
2. 원장 영상 근거 [S]/[T]. 정리 답 한 문단의 범위 안에서만 씁니다. 자막이 없으면 원장 발언을 만들지 않습니다. 화자는 "OO 원장이 영상에서 설명합니다"로 밝힙니다.
3. 기사 [N]. 제목·매체·날짜만 압니다. 본문 내용을 추정해 쓰지 않습니다.
4. 규제·연구 원문 [R]. "확인된 내용"에 적힌 범위에서만 인용합니다. 요약이 없으면 수치를 쓰지 않고 링크로만 안내합니다. [R]이 하나도 없으면 FDA·ISO·승인·연구라는 말을 쓰지 않습니다.
5. 팩트 시트 [F]. 플랫폼 집계 수치(후기 수·평점 등)를 쓰면 같은 문장에 확인일을 붙이고, 글 안에서 같은 수치는 항상 같은 확인일로 씁니다.
수치 규칙. 근거 문장에 있는 수치를 있는 그대로 씁니다. 범위(3~7일)를 바꾸거나(5~7일), 단위를 바꾸거나, 근거의 수치를 근거에 없는 행위에 붙이지 않습니다(예: 근거에 "운동은 2개월 후"만 있으면 "걷기는 1개월 후"라고 쓰지 않습니다). 근거가 어떤 조건(절개 방식·부위)에 한정한 수치는 그 조건을 같이 씁니다.
근거에 없는 것은 없다고 씁니다. "병원 공개 자료에 없습니다. 상담에서 확인하세요." 또는 "병원 확인 대기"로 표기합니다. 페이지 간 표기가 다르면 둘 다 적고 다르다고 밝힙니다.
# 글 구조
- 세 줄 요약(summary) 3개. 각 1~3문장. 질문에 대한 직접 답. 첫 줄에 핵심 답, 둘째 줄에 조건·예외, 셋째 줄에 독자가 할 일. 수치가 들어간 줄에는 같은 줄 안에 "주의사항 기준", "홈페이지 안내 기준", "영상 사례", "개인차" 중 하나를 씁니다.
- 본문(body)은 마크다운. H1(#)은 쓰지 않습니다(제목은 별도). H2(##)는 질문형 또는 명사구. "~합니다"로 끝나는 제목은 금지.
- 본문 첫 H2 앞에 도입 문단 2~3문장: 이 글이 무엇을 어떤 자료로 정리했는지.
- 표(마크다운 table)를 1개 이상 넣습니다. 회복 일정·마취 선택·검진 주기·비교처럼 조건이 붙는 답은 반드시 표로 씁니다. 표 바로 아래 문단에 표의 한계(개인차, 병원 확인 대기)를 씁니다. 근거에 없는 칸은 "확인"이라고 씁니다.
- 체크리스트("- [ ] 항목")는 기획 의도에 체크리스트가 있거나 분류가 C·F 일 때만 씁니다.
- 글 끝은 "## 상담에서 확인할 것" 아래 목록 3개로 닫습니다.
- 본문에 쓰지 않는 것: FAQ 섹션(faq 필드가 화면에 따로 붙습니다), 참고 자료 목록, 병원 연락처·예약 안내 블록, 작성자·검토자 표기, 갱신 기록(전부 템플릿이 자동으로 붙입니다). 근거 태그([C1], [S2] 같은 표기)는 본문·요약·FAQ·description 어디에도 쓰지 않고 usedRefs 에만 적습니다.
- FAQ 5~8개. 질문은 독자 말투, 답은 2~4문장. 본문과 같은 근거만. 수치가 있는 답에는 "개인차" 또는 "상담에서 확인"을 같은 답 안에 씁니다.
- 태그 3~6개(명사).
- description 은 90~160자. 글이 무엇을 어떤 근거로 정리했는지.
- 분량: 본문 1,200~2,200자(한글 기준). 근거가 적으면 짧게. 근거 없이 늘리지 않습니다.
# 화법
- 존댓말 "~입니다/~합니다"만. 반말 서술("~한다", "~있다")은 금지. 개조식 명사 종결("~없음")은 표 안에서만.
- 엠대시(—)와 은유·비유 금지. 직설 문장. 한 문장에 한 가지.
- 기자·전문가 화법 금지: "본지", "기자", "전문가에 따르면", "업계에서는".
- 병원 이름은 팩트 시트의 shortName 으로.
# 넣지 않는 것 (발행 차단)
- 전후 사진, 환자 후기·경험담 인용, 효과·안전 보장, 가격 금액(항목과 확인처만), "완치", "100%", "부작용 없음", "최고의 병원".
- 다른 병원·업계 평균과의 비교. 근거 페이지에 "병원급 평균 23.1% vs 43%" 같은 비교 수치가 있어도 옮기지 않습니다(비교 광고). 병원 자체의 사실(마취과 전문의 상주)만 씁니다.
- 운영자 어휘: 답변엔진, AI 인용, AEO, GEO, 질문 뱅크, 실측, 단일 원본, 수집 기준, 임베드, 롱폼, 쇼츠 분류, 의료법 제N조.
- 결론 단정: "안정성이 확인", "안전이 보장", "안전하다고 볼 수 있", "장점을 합친", "비교우위"(원장 의견으로 표기하면 가능), "다음 날 출근할 수 있을 정도".
# 근거 범위 표현
- 제품 허가는 "특정 제품·해당 국가 적응증"으로 한정하고 "위험이 없다는 뜻은 아닙니다"를 붙입니다.
- 원장의 제품 선호·비교는 "원장의 임상 의견"으로 표기합니다. 특정 제품 권유는 "진료에서 정합니다"로 닫습니다.
- 수술 시간·회복·복귀 시점 같은 수치(분·일·주·개월)가 있는 문단에는 반드시 "개인차", "상담에서 확인", "영상 사례", "주의사항 기준", "홈페이지 안내" 중 하나를 같은 문단에 씁니다.
# 출력
JSON 스키마대로만 출력합니다. pending 에는 독자가 물었을 법한데 근거에 없어 쓰지 못한 사실을 한 줄씩 적습니다(병원 확인 요청 목록이 됩니다). usedRefs 에는 실제로 인용한 근거 태그(C1, S2 형식)만 적습니다.`;
export function userPrompt({ brief, context, fact, authorName, reviewerName, today }) {
const cat = `${brief.category} ${brief.categoryLabel ?? ''}`.trim();
return `# 이번 글
- 제목(H1, 질문형): ${brief.title}
- 분류: ${cat} · 문항 ID: ${(brief.qbIds ?? []).join(', ') || '(없음)'}
- 작성자: ${authorName ?? '확인 대기'} · 검토 예정 원장: ${reviewerName ?? '미정 (의학 검토 대기)'}
- 오늘: ${today}
${brief.intent ? `- 기획 의도: ${brief.intent}\n` : ''}${brief.mustCover?.length ? `- 반드시 다룰 것: ${brief.mustCover.join(' / ')}\n` : ''}${brief.avoid?.length ? `- 이 글에서 피할 것: ${brief.avoid.join(' / ')}\n` : ''}
# 근거
${context}
# 작성 지시
위 근거만으로 "${brief.title}"에 답하는 글을 JSON 스키마대로 작성하세요. 근거에 없는 사실은 pending 에 적고 본문에는 "병원 공개 자료에 없습니다"로 씁니다. 병원 이름은 "${fact?.shortName ?? ''}"으로 씁니다.`;
}
export function repairPrompt(errors, warns) {
const e = errors.map((x) => `- [${x.code}] ${x.msg}`).join('\n');
const w = warns.slice(0, 12).map((x) => `- [${x.code}] ${x.msg}`).join('\n');
return `발행 게이트에서 다음 오류가 나왔습니다. 오류 항목만 고치고 나머지 내용·구조는 유지한 채 같은 JSON 스키마로 전체를 다시 출력하세요. 사실을 새로 추가하지 마세요.
## 오류 (반드시 고칠 것)
${e || '(없음)'}
## 경고 (가능하면 고칠 것)
${w || '(없음)'}
고치는 방법:
- UNSUPPORTED_CLAIM / NUMBER_NOT_IN_EVIDENCE: 해당 문장을 삭제하거나 "병원 공개 자료에 없습니다. 상담에서 확인하세요."로 바꿉니다. 수치는 근거 문장 그대로만 씁니다.
- HOME_DUPLICATE: 해당 구절을 다른 어순·어휘로 다시 씁니다.
- CONCLUSION_PHRASE: 단정 표현을 삭제하거나 "원장의 임상 의견입니다"로 한정합니다.
- COMPARISON_CLAIM: 다른 병원·업계 평균과 비교하는 수치를 삭제합니다. 병원 자체 사실만 남깁니다.
- REGULATION_SOURCE_MISSING: FDA·ISO·승인·연구 언급을 삭제합니다(근거 자료가 없습니다).
- NUMBER_DATE_MISMATCH: 같은 수치에는 같은 확인일을 씁니다.
- BANNED_PHRASE / OPERATOR_VOCAB: 해당 표현을 삭제합니다.
- NUMERIC_NO_QUALIFIER: 수치가 있는 문단·요약 줄·FAQ 답에 "개인차가 있습니다" 또는 "상담에서 확인하세요"를 붙입니다.
- PLAIN_ENDING: 반말 문장을 "~입니다/~합니다"로 바꿉니다.
- HEADING_VERB: 제목을 명사구 또는 질문형으로 바꿉니다.
- BODY_H1: 본문의 # 제목을 ## 으로 바꿉니다.`;
}
/** OpenAI structured output 스키마 (strict). */
export const OUTPUT_SCHEMA = {
name: 'supporters_post',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
summary: { type: 'array', items: { type: 'string' }, description: '세 줄 요약 3개' },
description: { type: 'string' },
body: { type: 'string', description: '마크다운 본문. H1 없음, FAQ 없음, 근거 태그 없음' },
faq: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' }, a: { type: 'string' } }, required: ['q', 'a'] } },
tags: { type: 'array', items: { type: 'string' } },
pending: { type: 'array', items: { type: 'string' }, description: '근거에 없어 쓰지 못한 사실. 병원 확인 요청 목록' },
usedRefs: { type: 'array', items: { type: 'string' }, description: '실제 인용한 근거 태그 (C1, S2 형식)' },
},
required: ['summary', 'description', 'body', 'faq', 'tags', 'pending', 'usedRefs'],
},
};
// ---------- 근거 대조 검증 (2차 호출) ----------
export const VERIFY_SYSTEM = `당신은 사실 확인 편집자입니다. 글의 모든 사실 주장을 아래 근거와 대조합니다.
찾을 것:
1. unsupported: 근거 어디에도 없는 사실 주장. 특히 수치(시간·기간·비율·개수), 시점, 행위("걷기는 1개월 후"처럼 근거에 없는 행위에 수치를 붙인 것), 제품·장비·프로그램 이름, 원장 발언, 병원 정책.
2. distorted: 근거에 있지만 다르게 옮긴 것. 범위가 바뀜(3~7일 → 5~7일), 조건이 빠짐(특정 절개법 제외 조건 누락), 다른 부위의 수치를 이 부위에 씀, 한 사례를 일반화.
검사 대상이 아닌 것: "병원 공개 자료에 없습니다", "상담에서 확인하세요", "개인차가 있습니다" 같은 안내 문장, 독자에게 권하는 행동(메모해 가세요), 표 구조 자체, 일반 상식 수준의 설명이 아닌 것만 골라내되 의학적 일반론("부기는 시간이 지나며 빠집니다")은 unsupported 로 보지 않습니다.
근거에 있는 것을 unsupported 라고 하지 않도록 근거를 끝까지 읽습니다. 확신이 없으면 넣지 않습니다. claim 에는 글의 문장을 그대로 짧게 인용합니다.
각 항목에 supported 를 적습니다. 검토해 보니 근거에 있는 주장이면 supported=true (이 항목은 무시됩니다), 근거에 없거나 다르게 옮긴 것만 supported=false. reason 에 "명시되어 있다", "근거에 있다"라고 쓰게 되는 항목은 supported=true 입니다.`;
export function verifyPrompt({ context, post }) {
return `# 근거
${context}
# 검사할 글
## 요약
${post.summary.map((s) => `- ${s}`).join('\n')}
## 본문
${post.body}
## FAQ
${post.faq.map((f) => `Q. ${f.q}\nA. ${f.a}`).join('\n\n')}
# 지시
위 글에서 근거에 없는 주장(unsupported)과 근거와 다르게 옮긴 문장(distorted)을 JSON 스키마대로 모두 나열하세요. 없으면 빈 배열.`;
}
export const VERIFY_SCHEMA = {
name: 'claim_verification',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
unsupported: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'reason', 'supported'] } },
distorted: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, evidence: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'evidence', 'reason', 'supported'] } },
},
required: ['unsupported', 'distorted'],
},
};

View File

@ -0,0 +1,69 @@
// 모델 출력(JSON) → 글 파일(md) 직렬화와, 빌드 없이 도는 소스 수준 게이트.
// frontmatter 는 js-yaml dump 로만 만든다(v2 §8: 큰따옴표 속 큰따옴표·날짜 문자열 함정을 라이브러리가 처리).
import yaml from 'js-yaml';
import * as R from '../gate/rules.mjs';
export function toMarkdown(data, body) {
const fm = yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false, sortKeys: false });
return `---\n${fm}---\n${String(body).trim()}\n`;
}
/** 기자·전문가 화법 (v2 §12-2 (3), 의료법 56조 2항 10호 대응). 경고 수준. */
export const PRESS_VOICE = /본지|기자(?!회견)|전문가에 따르면|업계에서는|업계 관계자/;
/**
* 글 한 편의 소스 수준 게이트. dist 없이 frontmatter·본문만 본다.
* 반환 { errors, warns, stats }. errors 가 있으면 발행 불가.
*/
export function gatePost(md, { physicians = {}, supporters = {}, homeText = '' } = {}) {
const parsed = R.parseMarkdown(md);
const { data, body } = parsed;
const findings = [...R.checkPostSource(parsed, { physicians, supporters })];
const fields = R.postTextFields(data, body);
const fullText = [data.title ?? '', ...fields.map((f) => f.text)].join('\n');
findings.push(...R.checkBannedBody(fullText));
findings.push(...R.checkOperatorVocab(fullText));
findings.push(...R.checkHomeDuplicate(fullText, homeText));
// 마크다운 제목 화법 (dist 에서는 h1~h3 로 검사하는 것을 소스에서 미리)
for (const m of body.matchAll(/^(#{1,3})\s+(.+?)\s*$/gm)) {
if (m[1] === '#') findings.push({ level: R.E, code: 'BODY_H1', msg: `본문에 H1: "${m[2].slice(0, 40)}" (제목은 frontmatter title 하나)` });
if (R.HEADING_VERB_END.test(m[2])) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `제목이 동사 종결 (명사구로): "${m[2]}"` });
}
if (R.HEADING_VERB_END.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `title 이 동사 종결: "${data.title}"` });
// 구조 규격
const summary = data.summary ?? [];
if (summary.length < 2 || summary.length > 4) findings.push({ level: R.E, code: 'SUMMARY_COUNT', msg: `세 줄 요약이 ${summary.length}개 (2~4개)` });
const faq = data.faq ?? [];
if (faq.length < 5) findings.push({ level: R.W, code: 'FAQ_FEW', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
if (faq.length > 8) findings.push({ level: R.W, code: 'FAQ_MANY', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
// 표 수 = 헤더 구분선(|---|) 수
const tableCount = (body.match(/^\|\s*:?-+/gm) ?? []).length;
if (!tableCount) findings.push({ level: R.W, code: 'NO_TABLE', msg: '본문에 표가 없음 (1개 이상 권장)' });
if (!/\?\s*$/.test(String(data.title ?? '')) && !/(표|목록|체크리스트|기록)\s*$/.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'TITLE_NOT_QUESTION', msg: `제목이 질문형이 아님: "${data.title}"` });
const press = fullText.match(PRESS_VOICE);
if (press) findings.push({ level: R.W, code: 'PRESS_VOICE', msg: `기자·전문가 화법: "${press[0]}"` });
const desc = String(data.description ?? '');
if (desc.length < 60 || desc.length > 200) findings.push({ level: R.W, code: 'DESCRIPTION_LENGTH', msg: `description ${desc.length}자 (90~160자 권장)` });
const stats = {
hangul: (body.match(/[가-힣]/g) ?? []).length,
bodyChars: body.length,
tables: tableCount,
checklist: (body.match(/^\s*- \[ \]/gm) ?? []).length,
h2: (body.match(/^##\s/gm) ?? []).length,
faq: faq.length,
summary: summary.length,
sources: (data.sources ?? []).length,
sourceTypes: [...new Set((data.sources ?? []).map((s) => s.type).filter(Boolean))],
};
return {
errors: findings.filter((x) => x.level === R.E),
warns: findings.filter((x) => x.level === R.W),
stats,
data,
body,
};
}

View File

@ -0,0 +1,356 @@
// 글 생성기 (v2 §7-4). 기획 목록(brief) + 근거(evidence) → GPT-4 계열 → frontmatter 규격 md + 소스 게이트 + 근거 대조 + 비용 리포트.
//
// node scripts/generate_posts.mjs --clinic viewclinic --from-golden 정답지 18편의 문항·근거로 재생성 (회귀)
// node scripts/generate_posts.mjs --clinic viewclinic --brief briefs/x.json 기획 목록으로 생성
// 옵션: --only a,b --limit N --model gpt-4.1 --out DIR --dry-run(프롬프트만 저장) --mock(정답지를 모델 출력으로 대체, API 없이 파이프라인 검증)
// --no-verify(근거 대조 2차 호출 생략) --verify-model gpt-4.1 --warn-threshold 4(경고가 이 수 이상이면 수정 호출)
// --max-cost-usd 3 --price-in 2 --price-out 8 (USD / 1M tokens) --today YYYY-MM-DD --temperature 0.3
//
// 흐름: 컨텍스트(§4 순서) → 생성 → 후처리(근거 태그·중복 섹션 제거) → 소스 게이트 + 수치 대조 → 근거 대조(모델) → 오류면 수정 1회 → 저장 → 리포트
// 원칙: sources·videos·검토자 후보는 brief 에서 결정론적으로 조립하고 모델은 요약·본문·FAQ·태그만 쓴다.
// 검토 상태는 항상 pending (§3-7 사람 게이트). reviewedAt 은 생성기가 절대 쓰지 않는다.
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
import { join, dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as R from './gate/rules.mjs';
import { loadEvidence, buildContext } from './gen/evidence.mjs';
import { SYSTEM_PROMPT, userPrompt, repairPrompt, OUTPUT_SCHEMA, VERIFY_SYSTEM, verifyPrompt, VERIFY_SCHEMA } from './gen/prompt.mjs';
import { toMarkdown, gatePost } from './gen/serialize.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); if (i >= 0 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; const eq = args.find((a) => a.startsWith(`--${k}=`)); return eq ? eq.slice(k.length + 3) : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic', 'viewclinic');
const today = opt('today', new Date().toISOString().slice(0, 10));
const model = opt('model', 'gpt-4.1');
const verifyModel = opt('verify-model', model);
const temperature = Number(opt('temperature', '0.3'));
const limit = Number(opt('limit', '0')) || Infinity;
const only = opt('only', '') ? opt('only').split(',').map((s) => s.trim()).filter(Boolean) : null;
const dryRun = flag('dry-run');
const mock = flag('mock');
const doVerify = !flag('no-verify') && !mock;
const warnThreshold = Number(opt('warn-threshold', '4'));
const maxRepairs = Number(opt('max-repairs', '2'));
const maxCostUsd = Number(opt('max-cost-usd', '0')) || Infinity;
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).replace('T', '-');
const OUT = resolve(opt('out', join(SUP, 'generated', clinic, stamp)));
// 공개 가격표 (USD / 1M tokens, 2025 목록가). 실제 청구와 다를 수 있다. --price-in/--price-out 으로 덮어쓴다.
const PRICES = { 'gpt-4.1': [2, 8], 'gpt-4.1-mini': [0.4, 1.6], 'gpt-4.1-nano': [0.1, 0.4], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6] };
const priceOf = (m) => PRICES[m] ?? [Number(opt('price-in', 0)), Number(opt('price-out', 0))];
const priceIn = Number(opt('price-in', priceOf(model)[0]));
const priceOut = Number(opt('price-out', priceOf(model)[1]));
// ---------- .env (저장소 루트에서 위로 탐색) ----------
function loadEnv() {
let dir = SUP;
for (let i = 0; i < 4; i++) {
const p = join(dir, '.env');
if (existsSync(p)) {
for (const line of readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
dir = dirname(dir);
}
}
loadEnv();
// ---------- 데이터 ----------
const DATA = join(SUP, 'src/data');
const POSTS = join(SUP, 'src/content/posts');
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const fact = readJson(join(DATA, 'factSheet.json'));
const authors = readJson(join(DATA, 'authors.json'));
const videos = readJson(join(DATA, 'videos.json'));
const news = existsSync(join(DATA, 'news.json')) ? readJson(join(DATA, 'news.json')) : { items: [] };
if (!existsSync(join(EV, 'index.json'))) { console.error(`근거 없음: ${EV}. 먼저 node scripts/collect_evidence.mjs --clinic ${clinic} --url <홈>`); process.exit(1); }
const ev = loadEvidence(EV);
const authorId = opt('author', Object.keys(authors.supporters ?? {})[0]);
const clinicHost = (() => { try { return new URL(fact.url).host.replace(/^www\./, ''); } catch { return ''; } })();
const CATEGORY_LABEL = { A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·마취', F: '비용·이벤트', G: '후기·평판' };
// ---------- brief ----------
function briefsFromGolden() {
const shortsIds = new Set((videos.shortsInfo ?? []).map((s) => s.id));
return readdirSync(POSTS).filter((f) => f.endsWith('.md')).sort().map((f) => {
const { data } = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const srcs = data.sources ?? [];
const isClinic = (u) => { try { return new URL(u).host.replace(/^www\./, '') === clinicHost; } catch { return false; } };
const isYt = (u) => /youtube\.com|youtu\.be/.test(u);
const vids = [...(data.videos ?? []), ...(data.video ? [data.video] : [])];
return {
id: basename(f, '.md'),
title: data.title, category: data.category, categoryLabel: data.categoryLabel, qbIds: data.qbIds ?? [],
reviewerCandidate: data.reviewer,
sources: srcs,
videos: data.videos ?? [], hero: data.hero, gallery: data.gallery ?? [], thumbnail: data.thumbnail,
evidence: {
pages: srcs.filter((s) => isClinic(s.url)).map((s) => s.url),
shorts: [...new Set(vids.map((v) => v.id).filter((id) => shortsIds.has(id)))],
news: srcs.filter((s) => !isClinic(s.url) && !isYt(s.url) && ['clinic', 'doctor', 'secondary'].includes(s.type)).map((s) => s.url),
regulation: srcs.filter((s) => s.type === 'regulation').map((s) => ({ label: s.label, url: s.url })),
platform: srcs.some((s) => s.type === 'platform'),
},
golden: f,
};
});
}
function briefsFromFile(p) { const j = readJson(resolve(p)); return Array.isArray(j) ? j : j.posts ?? []; }
let briefs = flag('from-golden') ? briefsFromGolden() : opt('brief') ? briefsFromFile(opt('brief')) : null;
if (!briefs) { console.error('--from-golden 또는 --brief <file> 이 필요합니다'); process.exit(1); }
if (only) briefs = briefs.filter((b) => only.includes(b.id));
briefs = briefs.slice(0, limit);
// ---------- 모델 호출 ----------
async function callModel(messages, { schema = OUTPUT_SCHEMA, useModel = model, temp = temperature } = {}) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY 없음 (.env)');
const body = { model: useModel, temperature: temp, messages, response_format: { type: 'json_schema', json_schema: schema } };
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body) });
if (res.status === 429 || res.status >= 500) { lastErr = new Error(`${res.status} ${await res.text()}`); await new Promise((r) => setTimeout(r, 4000 * (attempt + 1))); continue; }
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const j = await res.json();
const msg = j.choices?.[0]?.message;
if (msg?.refusal) throw new Error(`모델 거부: ${msg.refusal}`);
return { out: JSON.parse(msg.content), raw: msg.content, usage: j.usage ?? {}, model: useModel };
}
throw lastErr;
}
const costOf = (u, m) => { const [i, o] = m === model ? [priceIn, priceOut] : priceOf(m); return ((u.prompt_tokens ?? 0) * i + (u.completion_tokens ?? 0) * o) / 1e6; };
// 단가 상한 정책 (v2 §9-5 미결). 지금은 누적 비용이 --max-cost-usd 를 넘으면 남은 글을 건너뛴다.
function shouldStop({ spentUsd }) { return spentUsd >= maxCostUsd; }
// ---------- 후처리 ----------
const REF_TAG = /\s*\[(?:[CSTNRF]\d*)\](?:\s*\[(?:[CSTNRF]\d*)\])*/g;
const DROP_SECTIONS = /^#{2,3}\s*(FAQ|자주 묻는 질문|자주 하는 질문|참고 자료|참고 링크|참고|상담 예약 및 문의|상담 예약|문의|병원 정보|병원 연락처|작성·검토|갱신 기록)[^\n]*\n[\s\S]*?(?=^##\s|(?![\s\S]))/gm;
function postProcess(out) {
const strip = (s) => String(s ?? '').replace(REF_TAG, '').replace(/[ \t]+$/gm, '');
return {
...out,
summary: (out.summary ?? []).map(strip).filter(Boolean),
description: strip(out.description),
faq: (out.faq ?? []).map((f) => ({ q: strip(f.q), a: strip(f.a) })),
body: strip(out.body).replace(DROP_SECTIONS, '').replace(/\n{3,}/g, '\n\n').trim(),
tags: (out.tags ?? []).map((t) => String(t).trim()).filter(Boolean),
pending: (out.pending ?? []).map((t) => String(t).trim()).filter(Boolean),
usedRefs: (out.usedRefs ?? []).map((x) => String(x).replace(/[\[\]]/g, '').trim()).filter(Boolean),
};
}
// ---------- 수치 대조 (결정론) ----------
// 글의 "숫자+단위" 토큰이 근거 컨텍스트에 그대로 있어야 한다. 범위(3~7일)는 통째로, 없으면 양 끝(3일·7일)이 각각 있으면 경고.
const NUM = '(\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)';
const NUM_UNIT = new RegExp(`(?<![\\d,.])${NUM}(?:\\s*[~\\-–〜]\\s*${NUM})?\\s*(분|일|주|개월|달|년|%|회|cc|㎛|mm|명|층|개|건|점|kg|시간)`, 'g');
const COUNT_UNIT = /^(명|개|건|점|회|층)$/; // 팩트 시트 JSON 에는 단위 없이 숫자만 있을 수 있다
const normNum = (s) => String(s).replace(/\s+/g, '').replace(/[–〜\-]/g, '~').replace(/(\d)달/g, '$1개월').replace(/(\d)(?:,(\d{3}))+/g, (m) => m.replace(/,/g, ''));
function checkNumbersGrounded(texts, contextText) {
const ctx = normNum(contextText);
const bare = (n) => new RegExp(`(?<![\\d.])${normNum(n)}(?![\\d.])`).test(ctx);
const out = [];
const seen = new Set();
for (const { field, text } of texts) {
for (const m of String(text).matchAll(NUM_UNIT)) {
const [raw, a, b, unit] = m;
const before = text.slice(Math.max(0, m.index - 1), m.index);
if (unit === '년' && Number(a) >= 1900) continue; // 연도
if (/월/.test(before) && unit === '일') continue; // 9월 4일
if (unit === '일' && /^\d{4}-\d{2}-/.test(text.slice(Math.max(0, m.index - 8), m.index))) continue;
const token = normNum(raw);
if (seen.has(token)) continue;
seen.add(token);
if (ctx.includes(token)) continue;
if (!b && COUNT_UNIT.test(unit) && bare(a)) continue; // "901건" vs 팩트 시트의 901
if (b && ctx.includes(normNum(a + unit)) && ctx.includes(normNum(b + unit))) { out.push({ level: R.W, code: 'RANGE_RECOMBINED', msg: `${field}: 범위 "${raw}" 는 근거에 통째로 없고 양 끝만 있음` }); continue; }
out.push({ level: R.E, code: 'NUMBER_NOT_IN_EVIDENCE', msg: `${field}: 근거에 없는 수치 "${raw}"` });
}
}
return out;
}
// ---------- frontmatter 조립 ----------
function assemble(brief, out, sources, pending) {
const reviewer = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate] ? brief.reviewerCandidate : undefined;
const note = `자동 생성 초안 (${mock ? '정답지 대체' : model}). 의학 검토 대기${pending.length ? `. 병원 확인 대기 ${pending.length}건` : ''}`;
const data = {
title: brief.title,
category: brief.category,
categoryLabel: brief.categoryLabel ?? CATEGORY_LABEL[brief.category] ?? '',
qbIds: brief.qbIds ?? [],
summary: out.summary,
description: out.description,
author: authorId,
...(reviewer ? { reviewer } : {}),
reviewStatus: 'pending',
datePublished: today,
dateModified: today,
...(brief.hero ? { hero: brief.hero } : {}),
faq: out.faq,
sources,
tags: out.tags,
history: [{ date: today, note }],
videos: brief.videos ?? [],
gallery: brief.gallery ?? [],
...(brief.thumbnail ? { thumbnail: brief.thumbnail } : {}),
};
let body = String(out.body).trim();
if (pending.length) body += `\n\n## 병원 확인 대기\n\n이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.\n\n${pending.map((p) => `- ${p}`).join('\n')}`;
return { data, body };
}
function goldenOutput(brief) {
const { data, body } = R.parseMarkdown(readFileSync(join(POSTS, brief.golden), 'utf8'));
return { summary: data.summary, description: data.description, body, faq: data.faq ?? [], tags: data.tags ?? [], pending: [], usedRefs: [] };
}
/** 한 편을 검사한다: 소스 게이트 + 수치 대조 (+ 모델 근거 대조). */
async function inspect(brief, out, ctx, row) {
const { data, body } = assemble(brief, out, ctx.sources, out.pending ?? []);
const md = toMarkdown(data, body);
const g = gatePost(md, gateCtx);
const texts = [...out.summary.map((s) => ({ field: 'summary', text: s })), { field: 'body', text: out.body }, ...out.faq.flatMap((f) => [{ field: 'faq.q', text: f.q }, { field: 'faq.a', text: f.a }])];
const nums = mock ? [] : checkNumbersGrounded(texts, ctx.text);
let verify = null;
if (doVerify) {
const r = await callModel([{ role: 'system', content: VERIFY_SYSTEM }, { role: 'user', content: verifyPrompt({ context: ctx.text, post: out }) }], { schema: VERIFY_SCHEMA, useModel: verifyModel, temp: 0 });
addUsage(row, r.usage, r.model);
verify = r.out;
}
// 검증기가 "근거에 있다"고 적으면서 올린 항목은 오탐이다. supported=true 또는 그런 사유 문구는 버린다.
const SELF_CONTRADICT = /명시되어 있|근거에 있|일치합니다|일치함|확인됨|부합|있으므로 문제|근거에 명시/;
const real = (list) => (list ?? []).filter((u) => !u.supported && !SELF_CONTRADICT.test(u.reason ?? ''));
const verifyErrors = [
...real(verify?.unsupported).map((u) => ({ level: R.E, code: 'UNSUPPORTED_CLAIM', msg: `근거 없음: "${u.claim.slice(0, 80)}" (${u.reason.slice(0, 60)})` })),
...real(verify?.distorted).map((u) => ({ level: R.E, code: 'DISTORTED_CLAIM', msg: `근거와 다름: "${u.claim.slice(0, 80)}" ← 근거 "${u.evidence.slice(0, 50)}" (${u.reason.slice(0, 50)})` })),
];
if (verify) verify.dropped = (verify.unsupported?.length ?? 0) + (verify.distorted?.length ?? 0) - verifyErrors.length;
const errors = [...g.errors, ...nums.filter((x) => x.level === R.E), ...verifyErrors];
const warns = [...g.warns, ...nums.filter((x) => x.level === R.W)];
return { md, errors, warns, stats: g.stats, verify };
}
// ---------- 실행 ----------
mkdirSync(join(OUT, 'posts'), { recursive: true });
mkdirSync(join(OUT, 'prompts'), { recursive: true });
writeFileSync(join(OUT, 'briefs.json'), JSON.stringify(briefs, null, 1));
const gateCtx = { physicians: authors.physicians, supporters: authors.supporters, homeText: ev.homeText };
const report = { clinic, model: mock ? 'mock' : model, verifyModel: doVerify ? verifyModel : null, today, startedAt: new Date().toISOString(), priceIn, priceOut, warnThreshold, posts: [] };
let spent = 0;
for (const brief of briefs) {
const t0 = Date.now();
const row = { id: brief.id, title: brief.title, category: brief.category, calls: 0, usage: { prompt_tokens: 0, completion_tokens: 0 }, costUsd: 0 };
report.posts.push(row);
if (shouldStop({ spentUsd: spent })) { row.status = 'skipped_cost_cap'; console.log(` – ${brief.id}: 비용 상한 도달, 건너뜀`); continue; }
const ctx = buildContext(brief, { ev, fact, authors, videos, news, today });
row.missingEvidence = ctx.missing;
row.contextChars = ctx.text.length;
const reviewerName = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate]?.name;
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt({ brief, context: ctx.text, fact, authorName: authors.supporters?.[authorId]?.name, reviewerName: reviewerName ? `${reviewerName} 원장` : null, today }) },
];
writeFileSync(join(OUT, 'prompts', `${brief.id}.txt`), messages.map((m) => `### ${m.role}\n${m.content}`).join('\n\n'));
if (dryRun) { row.status = 'dry_run'; console.log(` · ${brief.id}: 프롬프트 ${ctx.text.length}자 저장${ctx.missing.length ? ` · 근거 누락 ${ctx.missing.length}` : ''}`); continue; }
try {
let out, raw;
if (mock) { out = goldenOutput(brief); raw = JSON.stringify(out); }
else { const r = await callModel(messages); out = postProcess(r.out); raw = r.raw; addUsage(row, r.usage, r.model); }
let res = await inspect(brief, out, ctx, row);
row.firstPass = { errors: res.errors.map((e) => e.code), warns: res.warns.length, unsupported: res.verify?.unsupported?.length ?? 0, distorted: res.verify?.distorted?.length ?? 0, verifierDropped: res.verify?.dropped ?? 0 };
// 수정 최대 N회(--max-repairs, 기본 2): 오류가 있거나 경고가 임계 이상이면 오류·경고 목록을 되돌려 같은 스키마로 재출력
let convo = [...messages, { role: 'assistant', content: raw }];
for (let k = 0; k < maxRepairs && !mock && (res.errors.length || res.warns.length >= warnThreshold); k++) {
const fix = { role: 'user', content: repairPrompt(res.errors, res.warns) };
const r2 = await callModel([...convo, fix]);
addUsage(row, r2.usage, r2.model);
convo = [...convo, fix, { role: 'assistant', content: r2.raw }];
out = postProcess(r2.out);
res = await inspect(brief, out, ctx, row);
row.repaired = (row.repaired ?? 0) + 1;
}
writeFileSync(join(OUT, 'posts', `${brief.id}.md`), res.md);
const hard = res.errors.filter((e) => !['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
const soft = res.errors.filter((e) => ['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
row.status = hard.length ? 'gate_failed' : soft.length ? 'ok_needs_review' : 'ok';
row.errors = hard.map((e) => `[${e.code}] ${e.msg}`);
row.claimFlags = soft.map((e) => `[${e.code}] ${e.msg}`);
row.warnings = res.warns.map((w) => `[${w.code}] ${w.msg}`);
row.stats = res.stats;
row.pending = out.pending ?? [];
row.usedRefs = out.usedRefs ?? [];
row.seconds = Math.round((Date.now() - t0) / 100) / 10;
spent += row.costUsd;
const mark = hard.length ? '✗' : soft.length ? '△' : '✓';
console.log(` ${mark} ${brief.id}: 오류 ${hard.length} 근거플래그 ${soft.length} 경고 ${res.warns.length} · 본문 ${res.stats.hangul}자 표 ${res.stats.tables} FAQ ${res.stats.faq} · 확인 대기 ${row.pending.length} · 호출 ${row.calls}${row.repaired ? '(수정)' : ''} · $${row.costUsd.toFixed(3)} · ${row.seconds}s`);
for (const e of hard) console.log(` ✗ [${e.code}] ${e.msg}`);
for (const e of soft) console.log(` △ [${e.code}] ${e.msg}`);
} catch (e) {
row.status = 'error'; row.error = String(e.message ?? e);
console.error(` ✗ ${brief.id}: ${row.error.slice(0, 300)}`);
}
}
function addUsage(row, u, m) { row.calls++; row.usage.prompt_tokens += u.prompt_tokens ?? 0; row.usage.completion_tokens += u.completion_tokens ?? 0; row.costUsd += costOf(u, m); }
// ---------- 리포트 ----------
report.finishedAt = new Date().toISOString();
report.totalCostUsd = Math.round(spent * 1000) / 1000;
const st = (s) => report.posts.filter((p) => p.status === s).length;
report.summary = {
total: report.posts.length, ok: st('ok'), okNeedsReview: st('ok_needs_review'), gateFailed: st('gate_failed'), error: st('error'),
repaired: report.posts.filter((p) => p.repaired).length,
avgCostUsd: avg(report.posts.filter((p) => p.costUsd).map((p) => p.costUsd)),
avgSeconds: avg(report.posts.filter((p) => p.seconds).map((p) => p.seconds)),
firstPassErrorCodes: count(report.posts.flatMap((p) => p.firstPass?.errors ?? [])),
warningCodes: count(report.posts.flatMap((p) => (p.warnings ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
errorCodes: count(report.posts.flatMap((p) => (p.errors ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
claimFlags: report.posts.reduce((n, p) => n + (p.claimFlags?.length ?? 0), 0),
};
writeFileSync(join(OUT, 'report.json'), JSON.stringify(report, null, 1));
writeFileSync(join(OUT, 'report.md'), reportMd(report));
console.log(`\n통과 ${report.summary.ok} · 근거 플래그 남음 ${report.summary.okNeedsReview} · 게이트 실패 ${report.summary.gateFailed} · 오류 ${report.summary.error} / ${report.summary.total} · 수정 호출 ${report.summary.repaired} · 총 $${report.totalCostUsd} (편당 $${report.summary.avgCostUsd}) · ${OUT}`);
if (report.summary.gateFailed || report.summary.error) process.exitCode = 1;
function avg(a) { return a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : 0; }
function count(list) { return list.filter(Boolean).reduce((m, k) => ((m[k] = (m[k] ?? 0) + 1), m), {}); }
function reportMd(r) {
const rows = r.posts.map((p) => `| ${p.id} | ${p.category} | ${p.status} | ${p.errors?.length ?? ''} | ${p.claimFlags?.length ?? ''} | ${p.warnings?.length ?? ''} | ${p.stats?.hangul ?? ''} | ${p.stats?.tables ?? ''} | ${p.stats?.faq ?? ''} | ${p.pending?.length ?? ''} | ${p.calls}${p.repaired ? '+' : ''} | ${p.usage.prompt_tokens}/${p.usage.completion_tokens} | ${p.costUsd ? p.costUsd.toFixed(3) : ''} | ${p.seconds ?? ''} |`);
const flags = r.posts.filter((p) => p.claimFlags?.length).map((p) => `### ${p.id}\n${p.claimFlags.map((x) => `- ${x}`).join('\n')}`);
const pend = r.posts.filter((p) => p.pending?.length).map((p) => `### ${p.id}\n${p.pending.map((x) => `- ${x}`).join('\n')}`);
const fp = Object.entries(r.summary.firstPassErrorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음';
return `# 글 생성 리포트 · ${r.clinic} · ${r.model}${r.verifyModel ? ` (대조 ${r.verifyModel})` : ''} · ${r.today}
통과 ${r.summary.ok} · 근거 플래그 남음 ${r.summary.okNeedsReview} · 게이트 실패 ${r.summary.gateFailed} · 오류 ${r.summary.error} / ${r.summary.total} · 수정 호출 ${r.summary.repaired}편 · 총 $${r.totalCostUsd} · 편당 $${r.summary.avgCostUsd} · 편당 ${r.summary.avgSeconds}s
가격 기준 USD/1M tokens 입력 ${r.priceIn} · 출력 ${r.priceOut} (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): ${fp}
최종 경고 분포: ${Object.entries(r.summary.warningCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
최종 오류 분포: ${Object.entries(r.summary.errorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
${rows.join('\n')}
## 근거 대조 플래그 (사람이 확인할 것)
${flags.join('\n\n') || '(없음)'}
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
${pend.join('\n\n') || '(없음)'}
`;
}