All projects

AI 리포트 메이커 Notaformat

LIVE

A LangGraph agent pipeline that turns page photos into a finished PDF report: text is restored as structured HTML, and each figure is classified and redrawn by a renderer for its type.

페이지 사진을 완성된 PDF 리포트로 만드는 LangGraph 에이전트 파이프라인. 본문은 구조화 HTML로 복원하고, 그림은 종류를 판별해 전용 렌더러로 다시 그립니다

Role · Full-stack builder — LangGraph orchestration, 5 render tools, FastAPI backend, auth/history/share, Fly.io deploy.

LLMAI AgentMulti-Agent

Key Metrics

82.7%
Vision token cut
5.1×
Classify cost cut
4
Parallel Render Workers

Why it matters

The hard part was not OCR — it was that a figure is not text. One extra agent step (classify the region first, then route it to a matching renderer) is what turns a transcript into a report.

어려운 건 OCR이 아니라 '그림은 글자가 아니다'라는 점이었습니다. 영역을 먼저 분류하고 맞는 렌더러로 보내는 한 단계가 전사본을 리포트로 바꿉니다.

Problem

A page is not one medium. Running everything through a single OCR/LLM pass returns a flat transcript: headings collapse, and every figure is either dropped or pasted back as a blurry crop of the original photo. I split the page into two tracks. Text is reconstructed as structured HTML with the hierarchy intact. Figures are cut out by bounding box, classified by what they actually are, and each routed to a renderer that can redraw it cleanly — Chart.js for charts, Mermaid for node-link diagrams, matplotlib for math plots, gpt-image-1 for illustrations. The result is a report you can hand in, not a transcript you have to redo.

한 페이지는 한 가지 매체가 아닙니다. 전부를 단일 OCR/LLM 호출로 밀면 평면 전사본이 나옵니다. 위계가 무너지고, 그림은 버려지거나 원본 사진의 흐릿한 크롭으로 다시 붙습니다. 그래서 페이지를 두 갈래로 나눴습니다. 본문은 위계를 살린 구조화 HTML로 복원합니다. 그림은 bounding box로 잘라내 '무엇인가'를 먼저 분류하고, 종류에 맞는 렌더러로 다시 그립니다. 차트는 Chart.js, 노드-링크 도표는 Mermaid, 수학 그래프는 matplotlib, 일러스트는 gpt-image-1. 결과물은 다시 손볼 전사본이 아니라 그대로 낼 수 있는 리포트입니다.

What I built

Key Decisions (trade-offs)

1

Classify the figure before rendering it

A chart, a flowchart, a function plot and a hand-drawn illustration each need a different renderer — one generic image model handles all of them badly. Inserting a classification step first makes the render stage a routing problem, so each tool only has to be good at one thing. It costs an extra LLM call per figure, paid back by not having to fix the output by hand.

차트·순서도·함수 그래프·손그림은 각각 다른 렌더러가 필요합니다. 범용 이미지 모델 하나로 다 처리하면 전부 애매하게 나옵니다. 분류 단계를 앞에 두면 렌더링이 라우팅 문제가 되어, 각 도구는 한 가지만 잘하면 됩니다. 그림당 LLM 호출이 한 번 늘지만 손으로 고치는 비용으로 회수됩니다.

2

Redraw figures instead of preserving the crop

Keeping the original crop is the safe default, and it looks like a photo glued into a document. I removed preserve_original from the classifier's choices entirely — every figure attempts a redraw, and preserving the original survives only as a per-renderer fallback when the redraw fails. The failure mode moves from 'always mediocre' to 'usually clean, occasionally falls back'.

원본 크롭을 그대로 두는 것이 안전한 기본값이지만, 결과물은 문서에 사진을 붙인 꼴이 됩니다. 분류기의 선택지에서 preserve_original 을 아예 제거했습니다. 모든 그림은 재생성을 시도하고, 원본 보존은 재생성 실패 시의 렌더러 내부 폴백으로만 남겼습니다. 실패 양상이 '항상 애매함'에서 '대개 깔끔하고 가끔 폴백'으로 바뀝니다.

3

Model tier per task, not one model everywhere

Every step originally called gpt-4o with default parameters. Classification only needs to know what kind of thing a region is, so it runs on a cheaper tier at detail:'low' — measured at 142 vision tokens versus 822 at high, an 82.7% cut on the same image. Generation and vision reading stay on the stronger model. The saving came from the detail parameter, not from resizing.

처음에는 모든 단계가 gpt-4o 를 기본값으로 호출했습니다. 분류는 '어떤 종류인가'만 알면 되므로 저렴한 티어 + detail:'low' 로 내렸습니다. 동일 이미지 실측 기준 high 822토큰 → low 142토큰으로 82.7% 감소했습니다. 생성과 비전 독해는 상위 모델을 유지합니다. 절감은 해상도 축소가 아니라 detail 파라미터에서 나왔습니다.

4

Show the pipeline instead of a progress bar

Conversion takes tens of seconds, and the UI showed a single percentage. Users read a static screen as a hung job and reload, discarding the work. Splitting the backend into four stages created something worth showing: the progress API now returns the current stage and its per-item count, and the screen renders them as four rows. The information already existed in the pipeline state — it simply had no path to the client.

변환은 수십 초가 걸리는데 화면에는 퍼센트 하나만 있었습니다. 변화 없는 화면을 사용자는 멈춘 것으로 읽고 새로고침하며, 그때까지의 처리가 버려집니다. 백엔드를 4단계로 나누자 보여줄 단위가 생겼습니다. 진행률 API 가 현재 단계와 세부 카운트를 반환하고 화면은 4행으로 그립니다. 정보는 이미 파이프라인 상태에 있었고, 프런트로 나올 통로만 없었습니다.

5

Enforce layout rules in one place, not seven

All seven app screens used height:100vh with overflow:hidden, which crops content on mobile because 100vh assumes no address bar. Rather than editing seven module stylesheets, a single global shell overrides them — 100dvh, safe-area insets, and per-region scrolling. Rules scattered across files drift on the next screen added; enforced centrally, new screens inherit them.

앱 화면 7개가 모두 height:100vh + overflow:hidden 이라 모바일에서 내용이 잘렸습니다. 100vh 가 주소창을 없는 것으로 계산하기 때문입니다. 모듈 CSS 7개를 각각 고치는 대신 전역 셸 하나로 덮었습니다(100dvh · safe-area · 영역별 스크롤). 흩어진 규칙은 다음 화면에서 어긋나지만, 한 곳에서 강제하면 새 화면이 자동으로 따릅니다.

Stack

LangGraphLangChainFastAPIGemini 2.5 FlashGPT-4o / gpt-image-1PlaywrightReact / TypeScriptSQLAlchemy / PostgreSQLDocker / Fly.io