OCR 텍스트 한 뭉치를 구조화된 데이터로 — Foundation Models @Generable로 알림장 파싱하기

아이벤트의 알림장 스캔 기능은 학교·학원 알림장 사진을 찍으면 숙제·준비물·공지·일정을 자동으로 뽑아서 각각의 항목으로 등록해준다. 서버로 이미지를 보내지 않고 iOS 26의 Vision + Foundation Models만으로 기기 안에서 전부 처리한다. 이 글은 OCR 텍스트 한 뭉치가 구조화된 데이터로 바뀌는 파이프라인을 코드 기준으로 정리한다.

파이프라인: OCR → LLM → 도메인 모델

사진(Data)
  → VNRecognizeTextRequest (Vision OCR, iOS 13+)
  → raw text
  → LanguageModelSession.respond(to:generating:) (Foundation Models, iOS 26+)
  → NoticeboardParseResult (Generable)
  → ParsedNoticeItem[] (앱 도메인 모델)

OCR 단계는 NoticeboardAssistantManagerextractText(from:)가 담당한다. 한글·영문 혼용 알림장이 많아서 recognitionLanguages["ko-KR", "en-US"]로 지정하고, 정확도를 위해 recognitionLevel = .accurate를 쓴다.

let request = VNRecognizeTextRequest { request, error in
    let observations = request.results as? [VNRecognizedTextObservation] ?? []
    let text = observations
        .compactMap { $0.topCandidates(1).first?.string }
        .joined(separator: "\n")
    continuation.resume(returning: text)
}
request.recognitionLevel = .accurate
request.recognitionLanguages = ["ko-KR", "en-US"]
request.usesLanguageCorrection = true

여기까지는 순수 Vision 프레임워크라 iOS 13부터 동작한다. 문제는 이 OCR 결과가 줄바꿈만 살아있는 날것의 텍스트라는 점이다. “수학 받아쓰기 숙제”, “체육복 준비”, “학부모 총회 6/30” 같은 문장이 뒤섞여 있는 텍스트에서 항목 단위로 잘라내고 분류하는 작업을 정규식이 아니라 Foundation Models에게 맡긴다.

@Generable로 출력 스키마를 코드로 고정하기

Foundation Models는 @Generable / @Guide로 구조체에 스키마를 붙이면, 모델이 그 스키마를 만족하는 JSON을 생성하도록 강제한다. @Guide(description:)는 사실상 필드 단위 프롬프트다.

@available(iOS 26, *)
@Generable
struct NoticeboardParseResult: Equatable {
    @Guide(description: "알림장에서 추출한 항목 목록 (숙제, 준비물, 공지사항, 일정 등을 모두 포함)")
    var items: [NoticeboardExtractedItem]

    @Guide(description: "알림장 전체 내용을 1~2문장으로 요약")
    var summary: String
}

@available(iOS 26, *)
@Generable
struct NoticeboardExtractedItem: Equatable {
    @Guide(description: "항목 제목 (간결하게, 예: '수학 받아쓰기 숙제', '체육복 준비', '학부모 총회')")
    var title: String

    @Guide(description: "항목 상세 내용")
    var detail: String

    @Guide(description: "분류 코드: homework(숙제) / material(준비물) / notice(공지사항) / schedule(일정)")
    var category: String

    @Guide(description: "마감일 또는 일정 날짜 (yyyy-MM-dd 형식, 없으면 빈 문자열)")
    var dueDate: String
}

덕분에 응답을 파싱할 때 “혹시 필드가 빠지지 않았을까”를 걱정할 필요가 없다. session!.respond(to: prompt, generating: NoticeboardParseResult.self) 호출이 성공하면 response.content는 이미 이 구조체 타입이다. 프리JSON 파싱, 키 누락 방어 코드가 전부 사라진다.

category는 문자열로, 변환은 앱 쪽에서

NoticeboardExtractedItem.category는 enum이 아니라 String이다. Foundation Models 쪽 Generable 타입과 앱의 도메인 enum(ParsedNoticeItem.Category)을 분리해두고, 변환 시점에만 매핑한다.

let items = response.content.items.map { item in
    ParsedNoticeItem(
        id: UUID(),
        title: item.title,
        detail: item.detail,
        category: ParsedNoticeItem.Category(rawValue: item.category) ?? .notice,
        dueDate: item.dueDate.isEmpty ? nil : dateFormatter.date(from: item.dueDate)
    )
}

모델이 스키마에 없는 카테고리 문자열을 뱉어도 ?? .notice로 안전하게 흡수된다. iOS 26 시스템 언어 모델처럼 계속 업데이트되는 모델을 다룰 때는, 모델이 반환하는 원시 값과 앱 내부 타입 사이에 한 겹의 변환 계층을 두는 편이 방어적이다.

날짜는 모델이 추측하게 두지 않는다

가장 공들인 부분은 dueDate 추출 규칙이다. 날짜 환각(hallucination)이 가장 치명적인 필드이기 때문에, 프롬프트에 세 가지 규칙을 명시적으로 박아뒀다.

let prompt = """
오늘 날짜는 \(today)입니다.
다음은 초등학교 알림장에서 OCR로 추출한 텍스트입니다. \
텍스트에서 모든 항목을 추출하여 분류해주세요.

[날짜(dueDate) 추출 규칙]
1. 알림장 항목에 명시적인 마감일, 제출일, 또는 일정 날짜(예: "6월 30일까지 제출", "7/1 수학 시험" 등)가 \
본문에 명확히 기재되어 있는 경우에만 'dueDate'를 해당 날짜로 입력하세요.
2. 날짜 추출 시 연도가 없으면 오늘 기준으로 가장 가까운 미래 날짜로 추정하여 입력하세요. \
(예: 오늘이 2026-06-27이고 본문에 "6/29"가 있다면 2026-06-29로 추정)
3. 만약 항목에 대해 마감일이나 수행 날짜가 본문에 전혀 명시되어 있지 않다면, 절대 임의로 \
추정하거나 오늘 날짜로 채우지 말고, 반드시 빈 문자열("")로 입력해야 합니다.

알림장 내용:
---
\(text)
---
"""

3번 규칙이 특히 중요하다. LLM에게 날짜 필드를 채우라고만 하면, 날짜가 없는 항목에도 오늘 날짜나 그럴듯한 날짜를 지어내는 경우가 있다. “없으면 반드시 빈 문자열”을 명시하고, 앱 쪽에서도 item.dueDate.isEmpty ? nil : ...로 한 번 더 방어한다. 반대로 2번 규칙은 “6/29”처럼 연도가 생략된 표기를 오늘 날짜 기준 미래로 보정하라는 규칙인데, 알림장에 연도까지 적는 경우가 거의 없어서 이 보정 없이는 대부분의 날짜가 비어버렸다.

세션은 actor 안에서 캐시하고, 실패하면 버린다

NoticeboardAssistantManageractor로 선언되어 있고, LanguageModelSession을 매 호출마다 새로 만들지 않고 재사용한다. 다만 저장 프로퍼티 타입은 Any?다.

actor NoticeboardAssistantManager {
    // Any? 로 저장해 stored property에 @available 없이 iOS 26 타입 보관
    private var _session: Any?

    @available(iOS 26, *)
    private var session: LanguageModelSession? {
        get { _session as? LanguageModelSession }
        set { _session = newValue }
    }
    ...
}

@available(iOS 26, *)는 프로퍼티나 메서드 선언에는 붙일 수 있지만, stored property 자체의 타입으로 iOS 26 전용 타입을 직접 쓰면 클래스 전체가 iOS 26 전용이 되어버린다. 이 프로젝트는 iOS 18 이상을 지원해야 하므로, 실제 저장은 Any?로 하고 iOS 26 전용 계산 프로퍼티로 타입을 좁혀서 꺼내 쓰는 방식으로 버전 경계를 피해간다.

세션 재사용에는 리스크도 있다. iOS 26 베타 시점에는 내부 safety 모델이 누락되면 DecodingError가 나는 경우가 있었는데, 이런 에러가 나면 세션 자체가 오염된 상태로 남을 수 있어 즉시 폐기한다.

do {
    let response = try await session!.respond(to: prompt, generating: NoticeboardParseResult.self)
    ...
    return AIParseSuccess(items: items, summary: response.content.summary)
} catch {
    // iOS 26 베타에서 내부 safety 모델 누락 시 DecodingError 발생 — 세션 폐기 후 재전파
    session = nil
    throw error
}

다음 호출에서 session == nil이면 시스템 프롬프트(“당신은 초등학교 알림장 분석 전문가입니다…”)로 새 세션을 다시 만든다. 세션을 캐시하되 실패 시 무조건 새로 시작하는 편이, 매번 새 세션을 만드는 것보다 평균 응답은 빠르면서도 한 번 꼬인 세션 상태가 이후 요청까지 계속 실패시키는 상황은 막아준다.

정리

문제 해결 방식
OCR 결과 구조화 정규식 대신 @Generable/@Guide로 스키마를 코드에 고정
모델 출력과 도메인 타입 불일치 category는 String으로 받고 앱 쪽에서 enum 변환 + 기본값 처리
날짜 환각 방지 프롬프트에 3단계 규칙 명시 + dueDate 빈 문자열은 nil로 이중 방어
iOS 18~25 호환 stored property는 Any?, iOS 26 전용 타입은 계산 프로퍼티로 캐스팅
세션 오염 실패 시 세션 폐기 후 다음 호출에서 재생성

서버 없이 기기 안에서 개인정보가 담긴 알림장을 처리해야 한다는 제약이, 오히려 @Generable 기반 구조화 추출과 방어적인 날짜 규칙을 훨씬 꼼꼼하게 설계하게 만든 셈이다.

프롬프트 하나로 오늘과 내일을 가르다 — 아이벤트 AI 브리핑의 컷오프 로직

아이벤트의 AI 브리핑은 매일 아침과 저녁, 부모가 앱을 열기 전에 자녀의 오늘/내일 일정과 알림장 내용을 한 문단으로 요약해준다. 이 기능을 구현하면서 가장 까다로웠던 부분은 모델 선택이나 API 연동이 아니라 “지금이 오늘 브리핑을 줄 시점인가, 내일 브리핑을 줄 시점인가”를 판단하는 로직과, 그 판단에 맞춰 프롬프트를 정확히 분기하는 일이었다.

고정 시각 cron이 아니라 동적 컷오프

AiBriefingJobcron_expression = "0 3,21 * * *"로 새벽 3시와 밤 9시에 두 번 실행된다. 하지만 이 두 시각이 그대로 “오늘 모드”/“내일 모드”를 결정하지는 않는다. 자녀마다 하교 시각과 학원 스케줄이 다르기 때문에, 그룹별로 그날 일정이 끝나는 시각을 계산해서 컷오프를 정한다.

def _max_schedule_end_minutes(self, db, children, today):
    """그룹 내 모든 자녀의 학교 하교 + 학원 종료 시각 중 MAX (분 단위). 없으면 None."""
    max_mins = None
    for child in children:
        dismissal = getattr(child, DISMISSAL_FIELDS[weekday], None)
        if dismissal:
            h, m = map(int, dismissal.split(":"))
            mins = h * 60 + m
            if max_mins is None or mins > max_mins:
                max_mins = mins
        for slot in academy_slots(child):
            end_mins = slot.start_hour * 60 + slot.start_minute + slot.duration_minutes
            if max_mins is None or end_mins > max_mins:
                max_mins = end_mins
    return max_mins

이렇게 구한 schedule_end와 기본값 18시(DEFAULT_CUTOFF_MINUTES) 중 더 이른 시각을 컷오프로 쓴다.

cutoff = min(schedule_end, DEFAULT_CUTOFF_MINUTES) if schedule_end else DEFAULT_CUTOFF_MINUTES
is_tomorrow = current_mins >= cutoff

학원이 저녁 7시에 끝나는 아이라면 컷오프는 18시(기본값)가 아니라 더 이른 시각으로 당겨질 수 있고, 반대로 학원이 없는 날은 18시가 그대로 컷오프가 된다. 밤 9시 실행분은 대부분 “내일 모드”로 떨어지지만, 하루 일과가 유난히 늦게 끝나는 그룹이면 여전히 “오늘 모드”로 처리될 수 있다는 뜻이다.

시스템 프롬프트를 통째로 두 벌 둔 이유

SYSTEM_PROMPT(오늘용)와 SYSTEM_PROMPT_TOMORROW(내일용)는 조건문으로 문구 몇 개만 바꾸는 대신 처음부터 별개의 상수로 분리되어 있다. 두 프롬프트의 규칙은 9개 중 8개가 사실상 동일하고, 차이는 이 정도다.

  • 특이사항이 없을 때 출력하는 문장: 오늘은 특별한 일정이 없어요 vs 내일은 특별한 준비사항이 없어요
  • 최우선 처리 태그: [오늘마감!] vs [내일마감!]
  • 내일 프롬프트에만 있는 10번째 규칙: "내일"이라는 단어를 사용하여 내일 일정임을 명확히 할 것

한 프롬프트에 {if is_tomorrow} 같은 분기를 넣지 않고 통째로 복제한 이유는, 모델이 조건부 지시보다 확정된 문장을 훨씬 안정적으로 따르기 때문이다. “내일 모드일 때는 다음 문장을 써라” 같은 메타 지시는 프롬프트가 길어질수록 누락되기 쉽지만, 애초에 그 모드용 프롬프트를 통째로 보내면 모델이 헷갈릴 여지가 없다.

DB 로우를 LLM이 읽을 텍스트로 직렬화하기

시스템 프롬프트가 “어떻게 요약할지”를 정한다면, _build_user_message는 “무엇을 요약할지”를 만든다. 일정 테이블과 알림장 테이블을 조인해서 사람이 읽기 쉬운 텍스트 블록으로 펼친다.

오늘 날짜: 2026-07-01 (수요일)
자녀 수: 2명 (민준, 서연)

[오늘 일정]
< 민준 >
  - 하교: 15:20
  - 태권도 (학원): 16:00-17:00 (서초동)

[최근 알림장 (7일 이내)]
* 민준 | 07/01 학교
  [숙제] 수학 받아쓰기: 교과서 42페이지 [오늘마감!]
  [준비물] 찰흙 (~07/03)

알림장은 최근 7일치를 모두 넘기되, due_date가 오늘(또는 내일)과 일치하는 항목에만 [오늘마감!]/[내일마감!] 태그를 붙인다. 시스템 프롬프트의 6번 규칙이 “이 태그가 붙은 항목을 반드시 첫 번째로 언급하라”고 못 박아두었기 때문에, 마감 임박 항목이 요약 중간에 묻히는 일이 없다. 우선순위 판단을 모델의 자체 추론에 맡기지 않고, 데이터 준비 단계에서 태그로 미리 표시해 넘기는 편이 훨씬 안정적이었다.

첫 줄만 골라 푸시로 보낸다

모델 응답은 앱 안에서 전체 브리핑으로 보여주고, 푸시 알림에는 그중 한 줄만 노출한다. 별도로 요약을 다시 요청하지 않고, 프롬프트 규칙 자체에 “최우선 항목을 첫 줄에 써라”를 넣어둔 뒤 파싱 단계에서 첫 줄만 잘라 쓴다.

def _parse_response(self, response: str) -> tuple[str, str]:
    briefing_text = response.strip()
    lines = [line.strip() for line in briefing_text.splitlines() if line.strip()]
    push_text = lines[0] if lines else briefing_text[:100]
    push_text = push_text.replace("**", "")[:100]
    return briefing_text, push_text

본문에는 굵게 표시를 위해 마크다운 **가 남아있지만, 푸시 텍스트에는 이를 제거한다. 별도 LLM 호출 없이 규칙 순서 + 문자열 슬라이싱만으로 앱 알림과 인앱 문구를 동시에 해결한 셈이다.

thinking_budget이 다 타버리면 응답이 비어있다

LocalLLMClient는 OpenAI 호환 /chat/completions를 호출하면서 reasoning 모델을 위한 thinking.budget_tokens를 함께 넘긴다. 처음 이 기능을 붙였을 때 종종 content가 빈 문자열로 오는 케이스가 있었는데, 원인은 모델이 추론(thinking) 토큰을 예산만큼 다 쓰고 정작 최종 답변을 낼 토큰이 남지 않은 경우였다.

if not content.strip():
    finish_reason = data["choices"][0].get("finish_reason", "unknown")
    reasoning_tokens = data.get("usage", {}).get("completion_tokens_details", {}).get("reasoning_tokens", 0)
    raise ValueError(
        f"LLM returned empty content (finish_reason={finish_reason}, reasoning_tokens={reasoning_tokens}). "
        "max_tokens가 너무 작거나 thinking_budget이 전체 토큰을 소진했을 수 있습니다."
    )

이 예외를 넣고 나서야 실패 원인을 바로 알 수 있게 됐다. 그 전에는 그냥 “빈 브리핑이 생성됨”으로만 보여서, 프롬프트가 잘못됐는지 파싱이 잘못됐는지 한참을 헤맸다. reasoning_tokens 값을 로그에 남겨두면 max_tokens를 늘려야 하는지 thinking_budget을 줄여야 하는지 바로 판단할 수 있다.

정리

문제 해결 방식
오늘/내일 모드 전환 고정 시각이 아니라 그룹별 하교·학원 종료 시각 기반 동적 컷오프
모드별 지시 누락 방지 조건부 문구 대신 시스템 프롬프트 자체를 통째로 분리
마감 임박 항목 우선순위 모델 추론에 맡기지 않고 데이터 단계에서 태그로 표시
푸시 문구 생성 추가 LLM 호출 없이 첫 줄 추출 + 마크다운 제거
reasoning 모델 빈 응답 finish_reason·reasoning_tokens를 로그에 남기고 명시적 예외 발생

결국 프롬프트 엔지니어링의 절반은 모델에게 무엇을 시킬지가 아니라, 모델에게 넘기기 전에 얼마나 정돈된 데이터를 만들어주는가에 달려 있었다.

LLM이 길을 잃지 않도록 — iEvent 프로젝트의 wiki 구성 방식

iEvent는 iOS 앱, FastAPI 백엔드, React 어드민, 스케줄러까지 4개 서브시스템으로 구성된 프로젝트다. 한 세션에서 여러 영역을 넘나들다 보면 LLM이 맥락을 잃거나 불필요한 파일을 모두 읽어 토큰을 낭비하는 일이 반복됐다. 이 문제를 해결하기 위해 wiki/ 디렉토리를 중심으로 한 LLM 전용 문서 구조를 설계했다.

핵심 원칙: INDEX.md 단일 진입점

wiki/INDEX.md 상단에는 LLM을 위한 사용 규칙이 명시돼 있다.

LLM 사용 규칙: 이 파일만 먼저 읽고, 관련 항목만 추가 열람할 것.
전체 파일 일괄 읽기 금지.

모든 세션은 반드시 INDEX.md 하나를 먼저 읽는 것으로 시작한다. INDEX.md는 전체 wiki 파일의 목차이자 각 파일의 한 줄 요약을 담고 있다. LLM은 이 인덱스만 보고 지금 작업과 관련된 파일 1~3개만 골라 읽는다. 전체 wiki를 일괄로 읽는 행위는 금지다.

wiki 디렉토리 구조

wiki/
├── INDEX.md              ← LLM 진입점 (항상 먼저 읽는 파일)
├── architecture/         ← 시스템 구조, 기술 스택, 데이터 흐름, 테스트 전략
├── features/             ← 기능별 스펙 (온보딩, 홈, 스케줄, 알림장 등)
├── design/               ← UI/UX 원칙, 컬러 시스템, 와이어프레임
├── data/                 ← DB 스키마
├── flows/                ← 사용자 여정
└── decisions/            ← ADR (Architecture Decision Records)

파일 수는 30개를 넘지만 LLM이 한 번에 읽는 파일은 최대 3개다. INDEX.md가 필터 역할을 한다.

Why 중심 문서화 원칙

각 wiki 파일은 “무엇을 했는가(What)”가 아니라 “왜 그렇게 결정했는가(Why)”를 기록한다. decisions/ 디렉토리가 이 역할을 담당한다. 예시:

  • decisions/server-first-migration.md — LocalFirst에서 Server-First로 전환한 이유 (serverId 충돌, 계정 전환 복잡도 해소)
  • decisions/tca-architecture.md — TCA 채택 이유, ifLet 체이닝 타임아웃 해결 패턴
  • decisions/apple-ai-strategy.md — 온디바이스 AI를 선택한 이유 (개인정보 보호, 오프라인 동작)

코드 자체는 git에 있고, “왜 이 방식을 골랐는지”는 wiki에 있다. 이 분리가 LLM이 맥락 없이 기존 결정을 번복하는 실수를 막아준다.

실시간 변경 이력 패턴

INDEX.md의 각 항목 요약에는 [2026-06-XX] 날짜 태그로 최신 변경 사항을 인라인으로 기록한다.

| [[architecture/data-flow]] | Server-First 정책 전환 —
  [2026-06-16] POST/PUT/DELETE 서버 우선·실패 시 throw,
  GET은 LocalDB 즉시 반환+백그라운드 pull |

별도 Changelog 파일 없이, 인덱스 한 줄 안에 날짜와 변경 내용이 함께 적힌다. LLM이 “가장 최근에 어떤 결정이 바뀌었는지”를 INDEX.md만 읽고 파악할 수 있다.

CLAUDE.md / GEMINI.md 이중 지원

프로젝트 루트에는 .CLAUDE.mdGEMINI.md 두 파일이 공존한다. Claude Code와 Gemini CLI 모두 이 프로젝트에서 사용하기 때문이다. 두 파일 모두 동일한 원칙을 공유한다.

  • 작업 시작 전 wiki/INDEX.md 읽기
  • 관련 파일 1~3개만 선택 열람
  • 소스 검색 전 codegraph MCP 우선 사용
  • /doc 커맨드는 자동 실행 금지 (수동 호출만 허용)

LLM이 달라도 동일한 컨텍스트 로딩 전략을 따르게 해서, 어떤 도구를 쓰든 일관된 작업 품질을 유지한다.

온디바이스 AI: Vision OCR + Foundation Models

iEvent의 알림장 스캔 기능은 서버 AI 없이 기기 위에서 완전히 동작한다.

사진(Data)
  → VNRecognizeTextRequest (Vision OCR)
  → raw text
  → LanguageModelSession + @Generable (Foundation Models)
  → AIParseSuccess { items, summary }

아이의 학교 알림장은 개인정보가 민감하다. 서버로 이미지를 업로드하지 않고 기기 위에서 처리함으로써 프라이버시를 보호하고, 네트워크 없는 환경에서도 동작하게 했다.

Foundation Models는 iOS 26+에서만 동작하므로, iOS 18 최소 타겟과 공존하기 위해 @available(iOS 26, *) 래퍼 패턴을 사용한다. TCA Action enum에는 FoundationModels 타입이 노출되지 않도록 도메인 타입(AIParseSuccess)으로 변환 후 반환한다.

서버 LLM: AI 브리핑 파이프라인

온디바이스 AI와 별개로, 매일 아침 가족에게 오늘 일정 브리핑을 푸시하는 기능은 서버 LLM을 사용한다. scheduler/llm/local_llm_client.py가 그 역할을 한다.

class LocalLLMClient:
    async def chat(self, system_prompt: str, user_message: str) -> str:
        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user",   "content": user_message},
            ],
            "thinking": {
                "type": "enabled",
                "budget_tokens": self.thinking_budget,
            },
        }
        response = await self._client.post(
            f"{self.base_url}/chat/completions", json=payload
        )
        ...

OpenAI-compatible /chat/completions 엔드포인트를 호출하는 얇은 클라이언트다. thinking.budget_tokens를 지원해 추론 모델도 사용할 수 있다. 스케줄러는 매일 07:30과 21:00에 이 클라이언트를 통해 오늘/내일 브리핑을 생성하고 APNs로 전송한다.

정리

목적방식
LLM 컨텍스트 로딩INDEX.md 단일 진입 → 관련 파일 1~3개만 선택
결정 기록decisions/ ADR — Why 중심, 날짜 인라인 태그
다중 LLM 지원.CLAUDE.md + GEMINI.md 동일 원칙 공유
온디바이스 AIVision OCR + Foundation Models (iOS 26, 개인정보 보호)
서버 LLMLocalLLMClient — OpenAI-compatible, thinking budget 지원

wiki 구조 자체가 LLM의 동작 방식에 맞게 설계돼 있다. “전체를 읽을 수 없으니 인덱스를 먼저 읽어라”는 제약을 문서 설계에 내재화한 것이다.