유대선
프로젝트로
·기술 회고·5 ·리뷰 필요

Phase 6.1 — Vision OCR + ElementMatcher: LLM 추정 ~70% → deterministic 99%

사용자 dogfooding이 명확히 보여줬다: LLM target_text는 perfect (CLAUDE.md 정확), coordinates는 ~70% 빗나감. Phase 6.1이 그 gap을 OCR + fuzzy matching으로 메움. 정책 swap back lock-in — 본질 '99% 좌표는 OCR이 source' 일관.

AI 버전

사용자 두 번 dogfooding 자료가 명확:

  1. 첫 시도 (Phase 4.2): "정확한 위치 못 찾았어요" 에러 — LLM이 SYSTEM_PROMPT "fallback only" 룰 너무 잘 따라 coordinates 생략. → v0.1 임시 fix (강제).
  2. 두 번째 시도 (Phase 4.2 fix 후): 박스 떴음 + 빗나감target_text="CLAUDE.md" perfect, coords ~70%.

→ Phase 6.1의 exactly 그 gap을 메움.

흐름 — OCR + ElementMatcher

capture (PNG) → DisplayGeometry (geometry + sentSize)

   async let 병렬:
     - dispatcher: Gemini → AnalysisResult (target_text="CLAUDE.md")
     - OCR: VNRecognizeTextRequest .accurate ko-KR+en-US → [OCRBox]

   ElementMatcher.match(targetText, candidates, geometry):
     1. case-insensitive substring (가장 짧은 = 가장 specific)
     2. fail → Levenshtein normalized similarity ≥ 0.7

   matchedRect: CGRect? (screen-local logical pt)

   AppDelegate 3-tier fallback:
     1. matchedRect (OCR 99%)
     2. result.coordinates LLM 추정 (~70% fallback)
     3. 한국어 에러 "찾지 못했어요"

Vision OCR Y-flip (R8 — Tauri Layer 6 유사 함정)

VNRecognizedTextObservation.boundingBox: normalized 0..1, bottom-left origin (Core Graphics 표준). sent image: top-left origin (DisplayGeometry 좌표계).

→ 변환 한 곳:

let bb = obs.boundingBox
let rect = CGRect(
    x: bb.minX * w,
    y: (1.0 - bb.maxY) * h,    // ← Y-flip 핵심
    width: bb.width * w,
    height: bb.height * h
)

bb.maxY 사용 이유: bottom-left bbox는 위쪽 모서리maxY (높은 y). top-left에선 위쪽 모서리가 작은 y. 1 - bb.maxY → top-left y.

여러 곳에 흩어지면 top-left/bottom-left mix 함정 — Tauri 시절 Layer 6 유사. OCRService 안 한 곳에서.

ElementMatcher — substring → Levenshtein escalate

Substring 매칭 우선 (실측 "CLAUDE.md" 대부분 hit) + fuzzy fallback:

// 1. substring (가장 짧은 = 가장 specific)
let substringMatches = candidates.filter { normalize($0.text).contains(normalizedTarget) }
if let best = substringMatches.min(by: { normalize($0.text).count < normalize($1.text).count }) {
    return geometry.logicalRectFromSentBox(toIntBox(best.rectInSentImage))
}
 
// 2. fuzzy
let scored = candidates.compactMap { (box, similarity(normalize(box.text), normalizedTarget)) }
    .filter { $0.1 >= threshold }
guard let best = scored.max(by: { $0.1 < $1.1 }) else { return nil }

Specific 우선 (test로 lock): "Sign in" 검색 시 "Sign in" 자체가 "Sign in with Google"보다 우선. 가장 짧은 매칭 박스 = 가장 specific.

Levenshtein 0.7: "CLAUDE.md""CLAUDE.txt" = 0.78 매칭 (OCR이 .txt로 잘못 인식해도). "CLAUDE.md""totally different" = 0.0 거부.

11 unit tests로 lock.

R9 lock-in swap back — 본질 일관성

Phase 4.2 fix에서 v0.1 임시 coordinates 강제. DECISIONS에 명시:

Phase 6.1 commit 시점에 반드시 swap 다시 — 안 그러면 본질 "99% 좌표는 OCR이 source" 자기 모순.

지킴:

  • Prompts.swift coordinates 룰: "반드시 줘 (v0.1)" → "fallback only — 평소엔 키 생략. backend OCR가 99% deterministic"
  • GeminiDispatcher responseSchema required에서 coordinates 제거
  • GeminiDispatcherTests 2 tests required set swap back

verify workflow가 자기 모순 잡을 패턴 사전 차단. 본질 정확히 일관.

AppDelegate 3-tier fallback

case .done(let result, let geometry, let matchedRect):
    if let rect = matchedRect {
        hud.presentAnnotation(rect, on: screen)   // 1. OCR 99%
        return
    }
    if let coords = result.coordinates,
       let local = geometry.logicalRectFromSentBox(coords) {
        hud.presentAnnotation(local, on: screen)  // 2. LLM ~70% fallback
        return
    }
    hud.presentError("\"\(result.targetText)\"을(를) 화면에서 못 찾았어요", on: screen)  // 3.

LLM coords fallback 유지 이유: OCR이 visible text 못 잡는 경우 (아이콘만 / 가려진 텍스트 / OCR 인식 실패) 마지막 보루.

68 tests, build 3.86s, test 0.214s

6 AnalysisResult + 4 Prompts + 8 Env + 6 GeminiDispatcher
+ 9 DisplayGeometry + 8 HUDOverlayWindow + 2 HUDController
+ 6 AnalyzeCoordinator (OCR matched / no-match 추가)
+ 8 UserMessage + 11 ElementMatcher = 68/68 pass

ElementMatcher tests:

  1. substring 매칭 — CLAUDE.md
  2. case-insensitive — claude.md → CLAUDE.md
  3. partial — Settings in "Open Settings"
  4. specific 우선 — Sign in vs Sign in with Google
  5. fuzzy — CLAUDE.md vs CLAUDE.txt (Levenshtein 0.78)
  6. threshold 미만 → nil
  7. custom threshold 0.5 vs 0.7
  8. 빈 target → nil
  9. 빈 candidates → nil
  10. whitespace normalize
  11. levenshtein basics + similarity bounds

Build warning (non-fatal)

VNRecognizeTextRequest.supportedRecognitionLanguages(for:revision:) macOS 12+ deprecated — instance method request.supportedRecognitionLanguages() 권장. Phase 6.1 cleanup으로 분리 (functional 영향 X).

사용자 검증 — 이번엔 정확해야

./dev.sh 새 binary로:

1. ⌥+Space → trigger panel
2. "CLAUDE.md 찾아줘" + Analyze
3. 빨간 박스가 *정확히* CLAUDE.md 사이드바 항목 위에  ← OCR deterministic
4. log: [match] substring hit — target="CLAUDE.md" box="CLAUDE.md"
        [analyze] complete N.Ns OCR-matched
5. ⌥+Space → dismiss

빗나가면 — Levenshtein threshold 튜닝 또는 OCR fixture 자료로 디버그.

다음

Phase 5.x — HUDOverlayView bubble (한글 next_action 박스 옆). target_text ("CLAUDE.md") + next_action ("여기 CLAUDE.md 보이죠? 누르세요") 둘 다 visible. 화면 가장자리 clamping (박스가 화면 우측 끝이면 bubble 좌측에).

Phase 6.1 commit 후에도 adversarial verify workflow 권장 — Y-flip 정확성 / threshold 튜닝 / OCR 다국어 edge case / 매칭 알고리즘 false positive.

리뷰 필요

내 시각이 아직 안 들어간 entry.