qcast-front/docs/superpowers/plans/2026-06-24-roof-shape-B-gemini-descriptor.md
sangwook yoo 4ebe02186f docs(roof-shape): 지붕형상 자동판별 B·C 구현 계획서 추가
[작업내용] :
- B(2 task): normalizeRoofShape 순수함수(TDD, roofShapePattern.js 동거) + route.js 스키마/프롬프트/반환 배선
- C(2 task): pdfRoofShapeDescriptorState atom 저장/리셋 + useRoofShapeSetting 마운트 consume-once pre-select
- B→C 순차, 각 독립 커밋

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:40:12 +09:00

16 KiB

B — Gemini 지붕형상 descriptor 산출 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Gemini floor-plan API 가 屋根伏図의 棟/軒先 + 立面図 보조로 판독한 지붕형상을 RoofShapeDescriptor 로 정규화해 응답에 포함하도록 확장한다.

Architecture: (1) src/common/roofShapePattern.js 에 순수 정규화 함수 normalizeRoofShape(raw) 추가(카탈로그·매처와 동거 — 무import 모듈이라 .mjs 하니스로 TDD 가능, route.js 는 Next/Gemini import 가 있어 직접 테스트 불가하므로 여기 둔다). (2) route.js 의 응답 스키마에 roofShape 필드, BASE_PROMPT[지붕형상 판독] 섹션, buildPrompt 의 입면도 활용 확대, 반환 JSON 에 normalizeRoofShape(parsed.roofShape) 를 배선한다.

Tech Stack: JavaScript (ESM), Next.js 14 App Router route handler, @google/generative-ai SchemaType. 신규 의존성 없음.

설계 근거 스펙: docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md (§2·§3)

Global Constraints

  • Prettier: 싱글쿼트, 세미콜론 없음, tabWidth 2, printWidth 150, trailingComma all (.prettierrc). 커밋 전 npx prettier --check 로 확인.
  • 테스트 러너 없음: normalizeRoofShape 는 scratchpad .mjs 하니스로 검증. 프롬프트/스키마/반환은 자동 테스트 불가 → build 회귀 가드 + 수동 런타임 검증(별도 테스트 프레임워크 도입 금지).
  • eslint 주의: eslint 미설치 시 next lint 가 셋업 요구하면 임시 설치 금지·원복. (메모리: eslint-build-skips-lint)
  • Path alias: @/* → ./src/*.
  • 로깅: console.log 금지 — route.js 는 @/util/logger 사용(기존 패턴 유지).
  • RoofShapeDescriptor 계약(스펙 §2): { type:'hip'|'gable'|'shed'|'manual', ridge:'hip'|'horizontal'|'vertical'|'shed'|'none', flows:Array<'top'|'bottom'|'left'|'right'>, confidence?:number }. direction 은 산출하지 않는다(매처 미사용).
  • 외곽선 추출 제약 유지: 立面図 에서 외곽선/정점 추출 금지 — 입면도는 棟방향·片流れ 경사방향 판별 보조로만.
  • <SCRATCH> = /private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad

Task 1: normalizeRoofShape 순수 정규화 함수

Files:

  • Modify: src/common/roofShapePattern.js (현재 matchRoofShapePattern 까지 정의됨. 끝에 export 추가)
  • Verify(scratchpad, 비커밋): <SCRATCH>/check-normalizeRoofShape.mjs, <SCRATCH>/roofShapePattern.mjs

Interfaces:

  • Consumes (동일 모듈 기존 export): ROOF_FLOW, ROOF_SHAPE_TYPE, ROOF_RIDGE, matchRoofShapePattern.

  • Produces (Task 2 가 import):

    • normalizeRoofShape(raw) => { type:string, ridge:string, flows:string[], confidence?:number } | null
      • 어휘 밖 type/ridge 는 ''(매처가 결측 관대 처리), flows 는 ROOF_FLOW 어휘만 통과·소문자화·중복제거.
      • type·ridge·flows 가 모두 비면 null.
  • Step 1: 검증 하니스 작성 (failing test)

<SCRATCH>/check-normalizeRoofShape.mjs 생성:

import { normalizeRoofShape, matchRoofShapePattern } from './roofShapePattern.mjs'

const fail = (msg) => {
  console.error('FAIL:', msg)
  process.exit(1)
}
const eqJson = (got, want, label) => {
  if (JSON.stringify(got) !== JSON.stringify(want)) fail(`${label}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`)
}

// 1. 대소문자/공백 정규화 — 유효 full descriptor
eqJson(normalizeRoofShape({ type: ' GABLE ', ridge: 'Horizontal', flows: ['TOP', 'bottom'] }), { type: 'gable', ridge: 'horizontal', flows: ['top', 'bottom'] }, 'normalize valid')

// 2. 어휘 밖 type 은 '' (ridge/flows 는 유지) — 매처 결측 관대와 연동
eqJson(normalizeRoofShape({ type: '寄棟', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] }), { type: '', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] }, 'unknown type -> empty')

// 3. flows 어휘 밖 값 제거 + 중복 제거
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top', 'up', 'top', 'left'] }), { type: 'hip', ridge: 'hip', flows: ['top', 'left'] }, 'flows filter+dedup')

// 4. flows 비배열 → []
eqJson(normalizeRoofShape({ type: 'gable', ridge: 'horizontal', flows: 'x' }), { type: 'gable', ridge: 'horizontal', flows: [] }, 'flows not array')

// 5. 무효 입력 → null
eqJson(normalizeRoofShape(null), null, 'null input')
eqJson(normalizeRoofShape(undefined), null, 'undefined input')
eqJson(normalizeRoofShape('x'), null, 'string input')
eqJson(normalizeRoofShape(42), null, 'number input')

// 6. type·ridge·flows 모두 비면 → null
eqJson(normalizeRoofShape({ type: 'zzz', ridge: 'yyy', flows: ['up'] }), null, 'all empty -> null')
eqJson(normalizeRoofShape({}), null, 'empty object -> null')

// 7. confidence: 숫자면 보존, 아니면 생략
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top'], confidence: 0.8 }), { type: 'hip', ridge: 'hip', flows: ['top'], confidence: 0.8 }, 'confidence kept')
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top'], confidence: 'high' }), { type: 'hip', ridge: 'hip', flows: ['top'] }, 'confidence non-number dropped')

// 8. 정규화 출력이 매처와 정합 — end-to-end (B→C 다리)
if (matchRoofShapePattern(normalizeRoofShape({ type: 'GABLE', ridge: 'HORIZONTAL', flows: ['TOP', 'BOTTOM'] })) !== 2) fail('e2e normalize->match should be 2')
if (matchRoofShapePattern(normalizeRoofShape({ type: '寄棟', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] })) !== 1) fail('e2e unknown-type->match should be 1 (flows)')

console.log('OK: all normalizeRoofShape invariants hold')
  • Step 2: 실패 확인 (red)

Run:

SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-normalizeRoofShape.mjs"

Expected: FAIL — normalizeRoofShape is not a function.

  • Step 3: normalizeRoofShape 추가 (minimal implementation)

src/common/roofShapePattern.js 끝(matchRoofShapePattern 다음 줄)에 추가:


// Gemini 자유 출력(raw) → 매처 strict 어휘로 정규화한 RoofShapeDescriptor | null.
// 어휘 밖 type/ridge 는 ''(매처가 결측 관대), flows 는 ROOF_FLOW 어휘만 소문자화·중복제거.
// type·ridge·flows 가 모두 비면 null. direction 은 산출하지 않는다(매처 미사용).
export const normalizeRoofShape = (raw) => {
  if (!raw || typeof raw !== 'object') return null

  const norm = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : '')
  const TYPE_VALUES = Object.values(ROOF_SHAPE_TYPE)
  const RIDGE_VALUES = Object.values(ROOF_RIDGE)
  const FLOW_VALUES = Object.values(ROOF_FLOW)

  const type = TYPE_VALUES.includes(norm(raw.type)) ? norm(raw.type) : ''
  const ridge = RIDGE_VALUES.includes(norm(raw.ridge)) ? norm(raw.ridge) : ''
  const flows = Array.isArray(raw.flows) ? [...new Set(raw.flows.map(norm).filter((flow) => FLOW_VALUES.includes(flow)))] : []

  if (!type && !ridge && flows.length === 0) return null

  const result = { type, ridge, flows }
  if (typeof raw.confidence === 'number') result.confidence = raw.confidence
  return result
}
  • Step 4: 통과 확인 (green)

Run:

SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-normalizeRoofShape.mjs"

Expected: OK: all normalizeRoofShape invariants hold

  • Step 5: lint / build 검증
npx prettier --check src/common/roofShapePattern.js   # 통과 확인
yarn build                                             # 회귀 가드

Expected: prettier 통과, build 통과(이 함수는 아직 route.js 에서 import 안 됨 → 다음 task).

  • Step 6: 커밋
git add src/common/roofShapePattern.js
git commit -m "feat(roof-shape): Gemini 출력 정규화 normalizeRoofShape 추가

[작업내용] :
- raw 지붕형상 출력 → RoofShapeDescriptor(type/ridge/flows/confidence?) 정규화
- 어휘 밖 type/ridge 는 ''(매처 결측 관대), flows 는 어휘만 소문자화·중복제거
- 모두 비면 null, direction 비산출(매처 미사용)
- route.js 배선은 다음 task

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: route.js 스키마·프롬프트·반환 배선

Files:

  • Modify: src/app/api/gemini/floor-plan/route.js
    • import 추가(상단 import 블록, :9 근처)
    • FLOOR_PLAN_SCHEMA.properties (:53 뒤)
    • BASE_PROMPT (:84 [출력] 앞 + :90-97 스키마 예시)
    • buildPrompt 입면도 문구 (:106, :112)
    • 반환 JSON (:324-332)

Interfaces:

  • Consumes (Task 1): normalizeRoofShape(raw) => RoofShapeDescriptor | null

  • Produces (플랜 C 가 소비): 응답 JSON 에 roofShape: RoofShapeDescriptor | null 필드 추가.

  • Step 1: import 추가

route.js 상단 import 블록(@/util/logger 다음, :9 뒤)에 추가:

import { normalizeRoofShape } from '@/common/roofShapePattern'
  • Step 2: 응답 스키마에 roofShape 추가

FLOOR_PLAN_SCHEMA.propertiesselectedDrawingType 다음(:53 뒤, }, 닫기 전)에 추가:

    selectedDrawingType: { type: SchemaType.STRING },
    roofShape: {
      type: SchemaType.OBJECT,
      properties: {
        type: { type: SchemaType.STRING },
        ridge: { type: SchemaType.STRING },
        flows: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
        confidence: { type: SchemaType.NUMBER },
      },
      required: ['type', 'ridge', 'flows'],
    },

top-level required(:55 ['outerline', 'unit'])는 변경하지 않는다(roofShape 선택 반환 허용).

  • Step 3: BASE_PROMPT 에 [지붕형상 판독] 섹션 + 스키마 예시 추가

BASE_PROMPT[출력] 블록(:84) 에 새 섹션 삽입:

[지붕형상 판독 — 屋根伏図 棟·軒先 + 立面図 보조]
- 屋根伏図의 棟(용마루) 위치·개수와 軒先(처마)가 흘러내리는 방향으로 지붕 유형을 판별합니다.
- type: 寄棟→'hip', 切妻→'gable', 片流れ(단일경사)→'shed', 판별 불가/복합형→'manual'.
- ridge(용마루 방향): 寄棟→'hip', 切妻 가로 용마루→'horizontal', 切妻 세로 용마루→'vertical', 片流れ→'shed', 없음/복합→'none'.
- flows: 처마가 흘러내리는 방향을 화면축 집합으로 기재합니다. 좌표계는 외곽선과 동일(좌상단 원점, 위=top·아래=bottom·왼쪽=left·오른쪽=right).
  · 寄棟: ['top','bottom','left','right'] (사방 흐름)
  · 切妻 가로 용마루(용마루가 좌우로 길게): ['top','bottom']
  · 切妻 세로 용마루(용마루가 상하로 길게): ['left','right']
  · 片流れ: 처마가 향하는 단일 방향 1개만, 예: ['bottom']
  · 복합형/판별 불가: [] (이 경우 type='manual', ridge='none')
- 立面図(입면도)가 있으면 棟방향·경사 방향 판별의 보조로만 활용하세요(외곽선/정점 추출에는 절대 사용 금지).
- 확신이 없으면 roofShape.confidence 를 낮게 주거나 roofShape 자체를 생략하세요(틀린 추정보다 누락이 낫습니다).

그리고 같은 BASE_PROMPT 안 스키마 예시(:90-97)의 닫는 } 직전에 roofShape 항목 추가:

  "selectedDrawingType": string (선택),
  "roofShape": { "type": "hip|gable|shed|manual", "ridge": "hip|horizontal|vertical|shed|none", "flows": ["top|bottom|left|right", ...], "confidence": number } (선택)
}

(기존 "selectedDrawingType": string (선택) 줄 뒤에 콤마 추가 + roofShape 줄 추가.)

  • Step 4: buildPrompt 입면도 활용 문구 확대

buildPrompt 의 splitInfo 분기(:106) 반환 문자열 끝부분을 교체:

기존:

    return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.`

변경:

    return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`

specify 분기(:112) 반환 문자열도 교체:

기존:

    return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`

변경:

    return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
  • Step 5: 반환 JSON 에 roofShape 추가

NextResponse.json({...}) 반환(:324-332)의 selectedDrawingType 다음 줄에 추가:

      selectedDrawingType: parsed.selectedDrawingType ?? null,
      roofShape: normalizeRoofShape(parsed.roofShape),
      empty: validation.empty,
  • Step 6: lint / build 검증
npx prettier --check src/app/api/gemini/floor-plan/route.js   # 통과 확인
yarn build                                                     # 회귀 가드

Expected: prettier 통과, build 통과.

  • Step 7: 수동 런타임 검증 (best-effort, 보고용)

자동 테스트 불가 — 실제 Gemini 호출은 수동 확인 항목으로 보고한다(구현자는 build 통과까지만 보장하고, 아래를 "수동 검증 잔여"로 기록):

  • .envNEXT_PUBLIC_ENABLE_LOGGING=true 설정 후 yarn dev, 배치면 초기설정 모달 PDF 모드로 屋根伏図(+立面図) 샘플 업로드.

  • 서버 로그 [gemini/floor-plan] raw responseroofShape:{type,ridge,flows,...} 가 포함되는지, 切妻 도면이 flows:['top','bottom']/['left','right'], 寄棟이 4방, 片流れ가 단일방향으로 오는지 확인.

  • 응답 네트워크 탭에서 최종 JSON roofShape 가 정규화된 형태(소문자 어휘)인지 확인.

  • Step 8: 커밋

git add src/app/api/gemini/floor-plan/route.js
git commit -m "feat(roof-shape): Gemini API 에 지붕형상 descriptor 산출 추가

[작업내용] :
- 응답 스키마에 roofShape{type,ridge,flows,confidence?} 필드
- BASE_PROMPT 에 [지붕형상 판독] 섹션(棟/軒先 → type/ridge/flows, 화면축)
- buildPrompt 입면도 활용을 棟방향·경사 판별 보조로 확대(외곽선 추출은 여전히 평면도만)
- 반환에 normalizeRoofShape(parsed.roofShape) 배선
- 런타임(실제 Gemini roofShape 응답) 수동 검증 잔여

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-Review

1. Spec coverage:

  • §3.1 스키마 roofShape → Task 2 Step 2
  • §3.2 프롬프트 [지붕형상 판독] → Task 2 Step 3
  • §3.3 立面図 활용 확대 → Task 2 Step 4
  • §3.4 normalizeRoofShape(어휘 정규화·null 규칙) → Task 1 Step 3 + 하니스 1~8
  • §3.5 반환 배선 → Task 2 Step 5
  • §3.6 confidence 별개(게이팅 없음) → 산출만, route 는 게이팅 안 함
  • §2 direction 비산출 → 스키마·normalize 모두 direction 없음
  • §7 B 검증(normalize TDD + 수동 런타임) → Task 1 하니스 + Task 2 Step 7

2. Placeholder scan: TBD/TODO 없음. 모든 코드 step 실제 코드 포함. Step 7 은 자동화 불가 항목을 명시적 수동 검증으로 분리(placeholder 아님).

3. Type consistency: normalizeRoofShape(raw) => {type,ridge,flows,confidence?}|null 시그니처가 Task 1 Interfaces·Step 3·하니스, Task 2 Step 5 사용처에서 동일. 반환 필드명 roofShape 가 스키마(Step 2)·반환(Step 5)·플랜 C 소비 계약에서 일치. 어휘 상수명 ROOF_FLOW/ROOF_SHAPE_TYPE/ROOF_RIDGE 가 기존 모듈 export 와 일치.