유대선
프로젝트로
·기술 회고·6

Intent-Aware AX Role Inference — instruction "켜기" → AXDockItem keyword inference

Chrome이 화면 여러 위치 (Dock + MenuBar)에 동시 존재 시 substring tiebreaker 비결정적. 사용자 instruction "켜기/열기"에서 intent 추출 → AXDockItem 우선 sort. 16 keyword pattern (한국어 + 영어) ElementMatcher.inferPreferredRole() 추가로 dispatch 시점에 disambiguate.

문제 — 무엇이 막혔나

사용자 dogfooding "chrome 켜서 네이버 들어가래" 시 버그 발견:

[gemini] ok 5.3s — target_text="Chrome"        ← LLM 정상 작동
[match] hit — element="Chrome" source=AX:AXMenuBarItem  ← 🔴 wrong (메뉴바)

진단:

사용자가 원한 건 Dock의 Chrome 아이콘 클릭 (앱 켜기), 받은 건 MenuBar의 Chrome 메뉴 (이미 열려있음).

결정 분기

3 옵션:

A. 후보 배열로 사용자에게 고르게 함 (Multi-Target Overlay 방식)

B. Schema 확장 — SYSTEM_PROMPT에 target_role 추가

C. ⭐ Backend keyword pattern → intent inference

선택 C — Phase 6.1의 pragmatic ship mode.

박힌 거

ElementMatcher.inferPreferredRole(from instruction:)

static func inferPreferredRole(from instruction: String) -> String? {
    let lower = instruction.lowercased()
    // 앱 켜기 / 열기 / 실행 → Dock 아이콘
    let launchKeywords = ["켜", "열어", "열기", "실행", "시작", "launch", "open app"]
    for kw in launchKeywords where lower.contains(kw) {
        return "AXDockItem"
    }
    // 메뉴 / 설정 → menu item
    let menuKeywords = ["설정", "환경설정", "메뉴", "settings", "preferences", "menu"]
    for kw in menuKeywords where lower.contains(kw) {
        return "AXMenuItem"
    }
    // 닫기 / 종료 → menu (Quit 항목)
    let quitKeywords = ["닫기", "종료", "quit", "close"]
    for kw in quitKeywords where lower.contains(kw) {
        return "AXMenuItem"
    }
    return nil
}

3 intent bucket:

  1. Launch ("켜기", "열어", "열기", "실행", "시작", "launch", "open app") → AXDockItem
  2. Menu/Settings ("설정", "환경설정", "메뉴", "settings", "preferences", "menu") → AXMenuItem
  3. Quit ("닫기", "종료", "quit", "close") → AXMenuItem

keyword matching은 plain substring (case-insensitive). "켜"는 "켜기", "켜달라", "켜줘"에 모두 매칭 가능.

AnalyzeCoordinator — dispatch 시점에 inference

let preferredRole = ElementMatcher.inferPreferredRole(from: req.instruction)
if let role = preferredRole {
    Log.dispatcher.info("[match] preferred role from instruction: \(role, privacy: .public)")
}
let matched = ElementMatcher.match(
    targetText: result.targetText,
    candidates: allCandidates,
    llmHintRect: llmHintLogical,
    preferredRole: preferredRole  // ← 새로 추가
)

ElementMatcher.match — preferredRole 우선 tiebreaker

static func match(
    targetText: String,
    candidates: [MatchCandidate],
    llmHintRect: CGRect? = nil,
    proximityRadius: CGFloat = 100,
    preferredRole: String? = nil,  // ← 새로 추가
    threshold: Double = defaultThreshold
) -> MatchResult?

substring 정렬 로직에서 preferredRole을 최우선 tiebreaker로:

let substringMatches = effective.filter { normalize($0.text).contains(normalizedTarget) }
if let best = substringMatches.sorted(by: { lhs, rhs in
    let lhsLen = normalize(lhs.text).count
    let rhsLen = normalize(rhs.text).count
    if lhsLen != rhsLen { return lhsLen < rhsLen }
    // ↓ preferredRole 우선 (length 동일 시)
    if let role = preferredRole {
        let lhsMatches = roleMatches(lhs.source, preferred: role)
        let rhsMatches = roleMatches(rhs.source, preferred: role)
        if lhsMatches != rhsMatches { return lhsMatches }
    }
    // AX 우선, confidence tiebreaker
    let lhsAX = isAX(lhs.source)
    let rhsAX = isAX(rhs.source)
    if lhsAX != rhsAX { return lhsAX }
    return lhs.confidence > rhs.confidence
}).first { ... }

결과: "Chrome 켜기" → inferPreferredRole 반환 "AXDockItem" → substring 정렬에서 AXDockItem 우선 → Dock의 Chrome 선택.

비용

패턴 — Phase 5.x 강화 대비

이 fix는 임시 pragmatic 결정. Phase 5.x late (f5a1261)에서는:

현재 (Phase 6.1 fbf9116)

미래 (Phase 5.x late f5a1261)

우선순위: 여기서는 keyword 결과 있으면 LLM call 스킵 (비용 절감) — f5a1261가 병합되면 두 결과 merge.

Transferable lesson

Commit

fbf9116 (2026-05-30 14:33:21 -0400)