제가 만든 것
인도에서 장학금, 입학 또는 시험에 지원한 경험이 있다면 이 화면을 알고 있을 것입니다:
사진: JPG, 3.5 cm × 4.5 cm, 흰색 배경, 20–50 KB. 서명: JPG, 10–20 KB. 클래스 12 성적 증명서: PDF, 앞면과 뒷면을 하나의 파일에, 최대 200 KB. 소득 증명서: 2026년 4월 1일 이후 발급, PDF, 0.3 MB를 초과하지 않음.
모든 포털은 동일한 문서를 약간씩 다른 형태로 요구합니다. 그래서 다운로드 폴더나 WhatsApp으로 받은 IMG-20240612-WA0007.jpg 같은 파일을 뒤지거나, 성적 증명서를 임의의 온라인 압축기에 업로드하고 '파일 크기가 너무 작습니다'라는 메시지를 받거나, 마지막 날에 소득 증명서가 3월 것인 것을 알게 됩니다.
Kaagaz (कागज़, “종이”)는 이 문제를 해결하며, 사용자 문서가 노트북을 벗어나지 않도록 합니다.
이는 Gemma 4 E4B를 Ollama를 통해 로컬로 구동하여 사용자의 폴더를 읽고 모든 문서를 라벨링합니다 (클래스 12 성적 증명서, 소득 증명서, 여권 사진, 서명 등 총 28가지 유형). 파일 이름이 아닌 문서 자체의 내용을 읽습니다.
사용자가 포털의 “업로드할 문서” 섹션을 붙여넣으면, Gemma가 이를 편집 가능한 체크리스트로 변환합니다.
Kaagaz는 각 행에 맞는 올바른 파일을 선택하고, 사용자가 양식을 시작하기 전에 무엇이 누락되었거나 만료되었는지 알려줍니다.
순수 Python 코드가 파일들을 생성합니다: 변환(convert), 얼굴 자르기(face-crops), 정확한 KB 범위로 압축(compress), 포털의 패턴에 맞춰 이름 변경을 수행합니다.
그런 다음 모든 출력물을 다시 열어 측정합니다. PASS / WARN / FAIL과 함께 실제 수치를 보여줍니다. 실패하는 파일은 절대로 저장되지 않으므로 실수로 업로드할 수 없습니다.
제가 모든 것을 구축한 규칙:
AI가 무엇을 결정할지. 코드가 어떻게 할지를 담당한다. 코드가 검증한다.
모델은 복잡한 증명서나 포털 지침의 벽을 읽는 데 능숙합니다. 하지만 JPEG 파일이 49 KB인지 51 KB인지를 누가 결정해야 하는지는 아닙니다.
누구를 위한 것인가: 인도 정부 및 대학 양식에 서류를 작성하는 학생과 가족, 특히 파일을 작게 만들기 위해 Aadhaar나 성적 증명서를 낯선 웹사이트에 업로드할 필요가 없는 사람들을 위한 것입니다.
데모
Kaagaz는 사용자의 자체 기기에서 실행되므로 클릭할 수 있는 호스팅 버전이 없습니다. 작동하는 모습은 가상의 학생인 Riya Sharma를 위해 제가 생성한 예시 문서를 사용하여 다음과 같습니다:
Library Section
Review Section
Match Sectionn
Build Section
이 일치(match) 화면이 바로 프로젝트의 핵심입니다. 소득 증명서는 3월 12일에 발급되었지만, 양식은 4월 1일 이후를 요구하므로, 문서 검증을 하기까지 3주가 남았을 때가 아니라 지금 즉시 '만료(EXPIRED)'로 표시됩니다.
📖 전체 가이드 (설정, 모든 화면, 문제 해결): kaagaz-docs.vercel.app
코드
사용자의 자체 GPU에서 Gemma 4를 이용해 문서를 읽고, 각 포털의 정확한 규칙에 맞춰 업로드 준비 파일을 생성하며 모든 것을 측정하는 오프라인 비서입니다.
Kaagaz 📄🦥
어떤 양식에도 대비된 당신의 문서들. 오프라인으로.
인도의 모든 장학금, 입학 및 시험 포털은 동일한 문서를 다른 형식으로 요구합니다: “사진 3.5 × 4.5 cm, JPG, 20–50 KB”, “성적표 PDF (200 KB 미만)”, “발급일이 4월 1일 이후인 소득 증명서”. Kaagaz는 사용자의 폴더를 읽고, Ollama를 통해 로컬로 실행되는 Gemma 4 E4B로 각 문서를 인식하며, 포털의 업로드 지침을 붙여 넣으면 해당 규칙에 맞춰 변환, 자르기(crop), 압축 및 이름이 변경된 파일들로 폴더를 구성합니다. 그런 다음 모든 결과물을 다시 열고 측정하여, 사용자가 양식을 시작하기 전에 무엇이 누락되었거나 만료되었는지 알려줍니다.
AI가 _무엇_을 결정하고, 코드가 어떻게 수행하며, 코드가 검증한다.
사용자의 노트북 밖으로 아무것도 나가지 않습니다. 업로드도 없고, 온라인 압축기 사용도 없으며, 클라우드 API를 사용하지 않습니다.
📖 가이드: kaagaz 가이드 페이지 (설정, 사용 방법, 결과, 문제 해결)
DEV Hacktoberfest 오픈소스 AI 챌린지 주차 3('느리고...')을 위해 제작되었습니다.
GitHub에서 보기
Python 3.12, Typer CLI 및 단일 패키지로 구현된 Streamlit 앱. 테스트는 71개이며, 그중 GPU가 필요한 것은 없습니다.
구축 과정 (How I Built It)
스택과 하드웨어의 놀라운 발견
Gemma 4 E4B를 Ollama로 사용하고, 모든 모델 스키마에는 Pydantic을, 파일 작업에는 Pillow와 PyMuPDF를, 얼굴 자르기(face cropping)에는 OpenCV의 Haar cascades를, 라이브러리로는 SQLite를, UI는 Streamlit을 사용했습니다.
원래 계획은 하드 문서를 위해 Gemma 4 12B가 폴백으로 작동하는 8 GB GPU를 가정했습니다. 하지만 첫날에 nvidia-smi조차 인식되지 않았습니다. 제 RTX 4050 노트북 GPU에는 드라이버가 설치되어 있지 않아 'Microsoft Basic Display Adapter'로 표시되었습니다. 드라이버를 설치한 후 확인해보니, 실제로는 6 GB였습니다. 12B는 들어가지 않습니다. 그래서 Kaagaz는 E4B 전용이며, 모델이 확신하지 못하는 것은 더 큰 모델 대신 사람의 검토 대기열로 보내집니다. 돌이켜보면 이것이 훨씬 정직한 설계입니다.
모델에 대한 하나의 문(One door to the model)
모든 Gemma 호출은 동일한 설정으로 작동하는 단일 함수를 거칩니다:
think=False: Gemma 4는 기본적으로 Ollama에서 사고(thinks)합니다. 첫 테스트에서는 15토큰 답변을 생성하는 데 약 260개의 숨겨진 토큰을 사용했습니다. 이것을 끄자 문서당 약 5초를 절약할 수 있었습니다.
num_ctx=8192로 설정하여 모델 전체가 6GB GPU에 머무르게 했습니다.
- Temperature는 0으로, 그리고 Pydantic의 JSON 스키마를 사용했기 때문에 출력이 구조화되고 반복 가능합니다.
- Ollama가 다운되면 실행이 **일시 중지(pauses)**됩니다. JSON 형식이 잘못되면 해당 문서 하나만 **검토(review)**로 이동합니다. 아무것도 충돌하지 않으며, 스캔은 멈춘 지점부터 정확하게 재개됩니다. 페이지 이미지를 1,024px로 다운스케일링하여 라벨링 시간을 문서당 중앙값 2.1초(p95 3.0초)까지 줄였습니다. 전체 6개 파일 패킷을 만드는 데는 0.3초가 걸립니다.
모두에게 경고하고 싶은 버그: 선택적 필드(optional fields)
요구사항 파서의 첫 버전은 테스트 텍스트에서 27개 중 22개 제한 사항을 정확하게 잡아냈습니다. 놓친 모든 항목은 _누락(omission)_이었습니다. 사진의 최소 20KB, 3.5 × 4.5 cm 크기, 서명의 최소 10KB 등 아무것도 잘못된 것이 아니었고, 단지… 부재했을 뿐입니다.
원인은 다음과 같습니다. 기본값(defaults)을 가진 필드는 JSON 스키마에서 **선택적(optional)**이며, Ollama의 제약형 디코딩(constrained decoding)은 작은 모델이 선택적 키를 건너뛰도록 허용합니다. 그래서 그렇게 했습니다. 해결책은 한 줄입니다: 모든 키를 모델에 전송되는 스키마에서 필수(required)로 지정하는 것입니다 (null 값은 여전히 허용됩니다).
class ParsedItem(BaseModel):
# Ollama에 전송되는 스키마의 모든 키는 REQUIRED여야 합니다 (null 여전히 허용),
# 그래야 모델이 필드를 건너뛰기보다 각 필드를 고려하게 됩니다.
...
이를 통해 27개 중 27개를 달성했으며, 이는 체크리스트당 약 5초의 비용을 치렀습니다. 이 정도는 양식별로 한 번 실행하는 것이라면 괜찮은 수준입니다. 이틀 후 저는 문서 추출에서 _정확히 같은 버그_를 발견했습니다: 모델이 13개 문서 중 13개 문서에서 사람의 이름과 연도를 비워두었던 것입니다. 이제 Kaagaz에 있는 모든 모델 대상 스키마는 모든 키를 필수(required)로 설정합니다.
코드가 양방향으로 모델을 확인합니다
저는 모델의 신뢰도 점수만으로는 스스로의 실수를 잡아낼 수 있다고 믿지 않기 때문에, 파서는 각 체크리스트 행이 어디에서 왔는지 **문장을 인용(quote the sentence)**해야 하며, 이후 코드가 이를 검사합니다:
• 근거 기반(Grounding): 모델이 반환하는 모든 숫자는 해당 문장에 나타나야 합니다 (0.3 MB ↔ 300 KB 및 35 mm ↔ 3.5 cm 허용). 꾸며낸 제한 값은 플래그가 지정됩니다.
• 포괄성(Coverage): 문장의 단위가 포함된 모든 숫자는 어떤 필드에 의해 사용되어야 합니다. 누락된 제한 값이 있으면 플래그가 지정됩니다. 플래그가 지정된 셀은 편집 가능한 체크리스트에서 강조 표시로 나타납니다. 단위가 있는 숫자만 계산되므로, “Class 12”나 “01-04-2026”과 같은 값은 크기 제한으로 오인되지 않습니다.
모델이 읽고, 코드가 변환합니다
인도 문서에서는 날짜를 일(day)-먼저 형식으로 인쇄합니다: 14-06-2022. 제가 날짜 필드를 date로 입력하자, 제약 디코딩(constrained decoding)이 이 숫자를 ISO 슬롯에 강제로 넣었습니다: “1406-06-20”. Pydantic은 이를 1406년의 유효한 날짜로 기꺼이 받아들였습니다. 이것이야말로 최악의 종류의 버그입니다: 조용한(silent) 오류.
이제 Gemma는 날짜를 인쇄된 그대로 복사하며, 작게 테스트된 파서가 이를 변환합니다 (일-먼저 형식, 두 자리 연도, 월 이름, 심지어 ०५-०८-२०२५와 같은 Devanagari 숫자까지). 크기 값도 동일하게 작동합니다. 모델은 절대 산술 계산을 수행하지 않습니다.
또한 Gemma가 생년월일을 학업 성적표의 발급 날짜(2024년 성적표에 2006년)로 복사하는 경우를 포착했습니다. 코드는 이제 발급 날짜와 인쇄된 연도를 교차 확인하고, 두 값이 일치하지 않으면 해당 날짜를 제외합니다. 모델 출력이 서로를 검증하는 방식입니다.
만료는 오직 사실(facts)에 의해서만
Kaagaz는 문서가 **만료됨(EXPIRED)**이라고 부르려면 이를 증명할 수 있어야 합니다: 인쇄된 만료 날짜, 또는 포털의 마감일보다 이전의 발급 날짜입니다. “소득 증명서는 약 1년 동안 유효하다”와 같은 경험적 규칙은 오직 **확인 날짜(CHECK DATE)**만 생성할 수 있으며, 이는 추정치로 명확하게 표시됩니다. 저는 추측에 기반하여 귀하의 증명서가 만료되었다고 말하기보다 직접 여쭤보는 편이 낫습니다.
개인 정보 보호는 README에 약속되는 것이 아니라 코드에서 강제됩니다
- 모델 호스트는 반드시
127.0.0.1이어야 합니다. 원격 호스트를 사용하면 Kaagaz가 시작을 거부합니다.
- Aadhaar, PAN 및 계좌 번호는 검증기(validator)와 모든 저장 필드(모델의 원시 출력 포함)에서 실행되는 스크러버(scrubber)에 의해 마지막 4자리만으로 저장됩니다.
- 사용자의 원래 파일은 절대 변경되지 않습니다. 출력물은 별도의 Packets 폴더로 이동합니다.
- Streamlit은 기본적으로 익명 사용 통계를 전송합니다. 저는 이 기능을 비활성화했고, 앱은
127.0.0.1에서만 수신 대기하도록 했습니다. UI는 Tailwind, Google Fonts 및 인터넷의 이미지를 로드하여 AI가 생성한 목업(mockups)으로 시작했었는데, 저는 모든 것을 제거하여 Wi-Fi가 꺼져 있어도 앱이 동일하게 보이도록 만들었습니다. ### 느리지만 꾸준히, 말 그대로
Kaagaz는 인내심을 갖고 작동하도록 설계되었습니다. 스캔은 2단계로 진행됩니다: 파일 등록에는 몇 초가 걸리고, 라벨링(~각 2초)이 그 후에 실행됩니다. 모든 문서는 완료됨에 따라 커밋되므로, 400개 파일 중 절반 정도에서 노트북을 닫아도 중단된 지점부터 다시 시작합니다. 이 때문에 나무늘보(sloth)라는 이름이 붙었습니다: 폴더를 느리고 사적으로 읽으며 위치를 잃지 않습니다.
마지막 밤의 깜짝 사건
마지막 날 밤, Wi-Fi가 꺼진 상태에서 라벨링을 하다가 실패하기 시작했습니다: CUDA error: shared object initialization failed. Kaagaz가 인터넷이 필요한 것처럼 보였습니다.
하지만 그렇지 않았습니다. 저는 ollama stop / ollama run으로 9번의 콜드 로드를 실행해 보았습니다. Wi-Fi를 켜고 끄면서 두 번은 성공했지만, 일곱 번은 충돌했습니다. Ollama 자체 로그는 충돌이 모델 **웜업 단계(warm-up step)**에서 발생했으며, CUDA 13 백엔드 내부였음을 보여주었습니다. 그리고 그 전날 오후에 Ollama 설치 폴더가 재작성된 상태였습니다: Windows 앱이 도전 도중에 자동 업데이트되었던 것입니다.
해결책은 CUDA 12 백엔드를 강제하는 것이었습니다(setx OLLAMA_LLM_LIBRARY cuda_v12). 결과는...
Kaagaz는 이제 충돌한 러너를 더 긴 대기 시간과 함께 세 번 재시도하며, Windows의 “이것은 악성 사용자가 제어권을 얻을 수 있게 할 수 있습니다”라는 메시지 대신 차분한 메시지를 표시하며 일시 중지합니다. 제가 배운 교훈: 마감 기한 동안에는 파이썬 패키지만이 아니라 로컬 AI 스택의 모든 움직이는 부분을 고정해야 합니다.
다루지 못한 것들