Das Problem
Wir hatten eine Rails-App, die gescannte PDF-Dokumente über eine API hochlädt. Einige Dokumente überschritten das 10-MB-Grössenlimit, was zu einem Fehler führte.
Die Lösung: Zu grosse PDFs automatisch komprimieren und den Upload erneut versuchen – mit jeweils 10% Reduzierung pro Versuch, maximal 3-mal.
Dieser Blogbeitrag zeigt meine Iterationen zu diesem Problem mit Claude und warum ich wirklich nicht verstehe, wie Leute im März 2026 überhaupt noch denken können, dass Programmieren tot sei. Ich liebe es, bei meiner täglichen Arbeit von KI unterstützt zu werden, aber irgendwie ist die Qualität noch nicht da.
Aber ich bin sicher, ich brauche bloss noch einen weiteren Prompt oder eine bessere CLAUDE.md-Datei.
Was folgt, ist ein Praxisbeispiel für das iterative Vorgehen mit Claude Code beim Entwickeln eines Features. Die KI produzierte beim ersten Versuch funktionierenden Code, aber es brauchte mehr als 10 Feedback-Runden, um ihn in etwas zu verwandeln, womit ich zufrieden war. Jede Version des Codes ist – vereinfacht – enthalten, um zu zeigen, wie sich das Design entwickelt hat.
Version 1: Den PdfCompressor einführen
Die erste Version legte die gesamte Logik in
PdfCompressor – die Wiederholungsschleife, die Fehlererkennung und die Komprimierung. Sie verwendete Ghostscript mit DPI-Downsampling.Dies ist der ursprüngliche Code:
# `app/models/ibm_document.rb`
def self.create(pdf_pdf_file_bytes)
api_client.documents.create(pdf_pdf_file_bytes)
end
Und hier ist die erste – funktionierende – Lösung von Claude Code:
# `app/models/ibm_document.rb` — v1 (create-Methode)
def self.create(pdf_file_bytes)
PdfCompressor.with_compression_retry(pdf_file_bytes) do |bytes|
api_client.documents.create(bytes)
end
end
# `app/services/pdf_compressor.rb` — v1
class PdfCompressor
MAX_RETRIES = 3
SCALE_FACTOR = 0.9
EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"
def self.with_compression_retry(pdf_file_bytes, &block)
new(pdf_file_bytes).with_compression_retry(&block)
end
def initialize(pdf_file_bytes)
@pdf_file_bytes = pdf_file_bytes
end
def with_compression_retry
current_bytes = @pdf_file_bytes
retries = 0
loop do
response = yield(current_bytes)
return response if response.success?
break response unless exceeded_message_size?(response)
break response if retries >= MAX_RETRIES
retries += 1
current_bytes = compress(current_bytes, retries)
end
end
private
def exceeded_message_size?(response)
response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
end
def compress(pdf_file_bytes, attempt)
scale = SCALE_FACTOR ** attempt
dpi = (300 * scale).round
Tempfile.create(["input", ".pdf"]) do |input|
Tempfile.create(["output", ".pdf"]) do |output|
input.binmode
input.write(pdf_file_bytes)
input.flush
system(
"gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
"-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}",
"-dMonoImageResolution=#{dpi}",
"-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
"-dNOPAUSE", "-dBATCH", "-dQUIET",
"-sOutputFile=#{output.path}", input.path,
exception: true
)
File.binread(output.path)
end
end
end
end
Obwohl die erste Version funktionierte, fühlte sich der Code schlicht falsch an.
Mein Hauptanliegen in dieser Phase war die Trennung von Verantwortlichkeiten: Es gefiel mir überhaupt nicht, dass der neue PdfCompressor weit mehr tat als bloss die PDF-Datei zu komprimieren.
Er kannte insbesondere die spezifische Fehlermeldung oder die Tatsache, dass es ein Response-Objekt gibt – das hat nichts mit der Aufgabe zu tun, ein PDF zu komprimieren.
Deshalb kommt nun eine zweite Version.
Version 2: Trennung von Verantwortlichkeiten – nur komprimieren
Mein Feedback: «Der pdf_compressor implementiert einen schönen Schleifen- und Wiederholungsmechanismus, sollte sich aber nur um die Komprimierung kümmern und nichts über den Fehler oder den Grund für die Komprimierung wissen.»
Die Wiederholungslogik wurde nach
IbmDocument verschoben, PdfCompressor weiss jetzt nur noch etwas über die Komprimierung.
# `app/models/ibm_document.rb` — v2 (create-Methode)
EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"
MAX_COMPRESSION_RETRIES = 3
def self.create(pdf_file_bytes)
current_bytes = pdf_file_bytes
MAX_COMPRESSION_RETRIES.times do |attempt|
response = api_client.documents.create(current_bytes)
return response if response.success?
break unless exceeded_message_size?(response)
current_bytes = PdfCompressor.compress(current_bytes, attempt: attempt + 1)
end
response = api_client.documents.create(current_bytes)
response
end
def self.exceeded_message_size?(response)
response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
end
# `app/services/pdf_compressor.rb` — v2
class PdfCompressor
SCALE_FACTOR = 0.9
def self.compress(pdf_file_bytes, attempt:)
new(pdf_file_bytes, attempt: attempt).compress
end
def initialize(pdf_file_bytes, attempt:)
@pdf_file_bytes = pdf_file_bytes
@attempt = attempt
end
def compress
dpi = (300 * (SCALE_FACTOR ** @attempt)).round
run_ghostscript(input.path, output.path, dpi)
end
private
def run_ghostscript(input_path, output_path, dpi)
system(
"gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
"-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}", "-dMonoImageResolution=#{dpi}",
"-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
"-dNOPAUSE", "-dBATCH", "-dQUIET",
"-sOutputFile=#{output_path}", input_path,
exception: true
)
end
end
Wir haben nun eine klare Trennung von Verantwortlichkeiten, aber der Code ist unleserlich geworden: ein riesiges Durcheinander und ausserdem fehlerhaft.
Claude hat es vollständig verfehlt, die Verantwortlichkeiten getrennt zu halten und gleichzeitig die richtige Logik an die richtige Stelle zu extrahieren.
Ein paar konkrete Anmerkungen:
IbmDocumentist nun für das Schleifen und weiteres Komprimieren des Dokuments verantwortlich. Das ist genau das, worum sich der PdfCompressor kümmern sollte! In Version 1 hatten wir das mit der Methodewith_compression_retry, und jetzt ist es verloren gegangen. Ich will den Kuchen und ihn gleichzeitig essen!IbmDocumentdurchläuft die Schleife nun dreimal, aber wenn die API aus einem anderen Grund fehlschlägt und die Schleife abbricht, führt sie einen weiteren Aufruf durch.- Die Methode
PDfCompressor#compressnimmt jetzt einattempt-Argument. Das ist schlicht falsch! Diese Methode sollte nur wissen, wie stark sie komprimieren soll, und nicht über eine Schleifenstrategie informiert sein. - Ich mag es, dass der Ghostscript-Aufruf extrahiert wurde.
Versuchen wir das in der nächsten Version zu beheben.
Version 3: Schleife zurück im PdfCompressor, prozentbasierte Komprimierung
Mein Feedback: «Mir gefiel, dass der PdfCompressor auch eine Methode angeboten hat, um eine bestimmte Anzahl von Malen zu wiederholen, sodass die ‹Schleifen›-Logik dort bleibt, während die Implementierungsdetails und continue oder break beim Aufrufer liegen. Ich denke, die compress-Methode sollte pdf_file_bytes und einen Komprimierungsprozentsatz entgegennehmen, nicht ‹attempts›; die attempts-Logik bleibt in der Schleife.»
Ich habe die Schleife als
progressively_compress zurückgebracht (den Namen habe ich bestimmt!!) und compress dazu gebracht, einen Prozentsatz statt einer Versuchsnummer entgegenzunehmen.
# `app/models/ibm_document.rb` — v3 (create-Methode)
response = api_client.documents.create(current_bytes)
if !response.success? && exceeded_message_size?(response)
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
response = response = api_client.documents.create(compressed_bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
end
response
# `app/services/pdf_compressor.rb` — v3
class PdfCompressor
SCALE_FACTOR = 0.9
def self.compress(pdf_bytes, percentage:)
new(pdf_bytes).compress(percentage: percentage)
end
def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10, &)
new(pdf_bytes).progressively_compress(max_retries: max_retries, step: step, &)
end
def initialize(pdf_bytes)
@pdf_bytes = pdf_bytes
end
def compress(percentage:)
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def progressively_compress(max_retries: 3, step: 10)
percentage = 100
max_retries.times do
percentage -= step
@pdf_bytes = compress(percentage: percentage)
yield(@pdf_bytes)
end
end
private
def run_ghostscript(input_path, output_path, dpi)
# ...
end
end
Ich habe das Gefühl, wir kommen langsam ans Ziel: Mein Drang, die Kontrolle zu übernehmen, ist jetzt gross, aber ich wollte sehen, wie viele Prompts es braucht, um es richtig hinzubekommen. Bedenke, dass ich zu diesem Zeitpunkt bereits mindestens zwei manuelle Änderungen vorgenommen hatte:
- Konstantendefinitionen über die Methoden verschieben, wo sie verwendet wurden
with_compression_retriesinprogressively_compressumbenennen
Ich wollte die Instanzmethoden des PdfCompressors loswerden und nur noch bei Klassenmethoden bleiben. Es ergab keinen Sinn, beides zu haben.
Ausserdem möchte ich keine benannten Parameter, wenn sie nicht optional sind, also sollte der benannte Parameter
percentage: geändert werden.Version 4: Nur Klassenmethoden
Mein Feedback: «Der PdfCompressor kann nur Klassenmethoden haben. Ich sehe keinen Grund, auch Instanzmethoden zu haben. Ausserdem nimmt die compress-Methode einen percentage-Parameter entgegen. Verwende keinen benannten Parameter. Er ist nicht optional.»
Die Instanzmethoden und der Konstruktor wurden entfernt. Alles ist jetzt eine Klassenmethode.
# `app/services/pdf_compressor.rb` — v4
class PdfCompressor
def self.compress(pdf_bytes, percentage)
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10)
percentage = 100
max_retries.times do
percentage -= step
pdf_bytes = compress(pdf_bytes, percentage: percentage)
yield(pdf_bytes)
end
end
def self.run_ghostscript(input_path, output_path, dpi)
# ...
end
end
Als Software-Ingenieur denke ich, dass unsere Aufgabe darin besteht, Dinge beim Namen zu nennen. Deshalb ergibt
step in einer progressively_compress-Methode keinen Sinn. Nennen wir es beim richtigen Namen: progressive_compression_percentage.Version 5: step in compression_percentage umbenannt
Mein Feedback: «Mir gefällt die Variable ‹step› nicht, das ist der progressive_compression_percentage.»
Einfache Umbenennung des Parameters.
# `app/services/pdf_compressor.rb` — v5 (progressively_compress)
def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
percentage = 100
max_retries.times do
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
yield(pdf_bytes)
end
end
Konzentrieren wir uns jetzt auf die
IbmDocument#create-Methode:
response = api_client.documents.create(current_bytes)
if !response.success? && exceeded_message_size?(response)
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
response = response = api_client.documents.create(compressed_bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
end
response
Den
PdfCompressor auf diese Weise zu verwenden ist schlicht hässlich. Man muss zuerst einen Aufruf ausführen und dann, wenn er fehlschlägt, mit der Komprimierung beginnen. Das war nicht meine Vorstellung: Ich möchte, dass der PdfCompressor alles in der Schleife übernimmt.Version 6: Erste Iteration liefert die originalen Bytes
Mein Feedback: «Mir gefällt es nicht, dass der Aufrufer den Aufruf vor dem Block ausführen muss. Passe den Compressor so an, dass er in der ersten Schleifeniteration nicht sofort reduziert, damit es für den Aufrufer einfacher ist.»
Die erste Übergabe liefert jetzt die originalen Bytes, sodass der Aufrufer keinen separaten Aufruf vor dem Block benötigt.
# `app/models/ibm_document.rb` — v6 (create-Methode, nur die Schleife)
response = nil
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |bytes|
response = submit(bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
response
# `app/services/pdf_compressor.rb` — v6 (progressively_compress)
def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
percentage = 100
(max_retries + 1).times do
yield(pdf_bytes)
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
Und hier merke ich, dass der Begriff
retries für Claude die ganze Zeit irreführend war. Ich sagte «3 Wiederholungen», meinte aber «3 Versuche». Deshalb sieht die Schleife so hässlich aus.Version 7: Versuche statt Wiederholungen
Mein Feedback: «Ich möchte ‹attempts› und nicht retries, also wenn ich 3 sage, versucht es 3-mal, nicht 4-mal.»
Die Semantik wurde geändert:
attempts: 3 bedeutet genau 3 Übergaben, nicht 3 Wiederholungen + 1 initial.
# `app/models/ibm_document.rb` — v7
MAX_COMPRESSION_ATTEMPTS = 3
PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
# ...
end
# `app/services/pdf_compressor.rb` — v7 (progressively_compress)
def self.compress(pdf_bytes, percentage)
return pdf_bytes if percentage.zero? # Das habe ich auch noch hinzugefügt!
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
percentage = 100
attempts.times do
yield(pdf_bytes)
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
Das sieht nach einer fantastischen Implementierung aus. Mit «nur» sieben Iterationen sieht der Code so aus, wie ich es wollte.
Claude war sehr schlecht darin, Dinge zu benennen, eine ordentliche Trennung von Verantwortlichkeiten vorzunehmen und eine gute Architektur zu entwerfen. Aber es hat mir zumindest die richtigen Ghostscript-Befehle geliefert, um die PDF-Grösse zu reduzieren – das hätte mich ewig gekostet, selbst herauszufinden.
Ausserdem liess ich es in der Zwischenzeit Tests schreiben, und die waren alle grün.
Ausser dass beim näheren Hinsehen nichts funktionierte.
An diesem Punkt haben die Tests ein grundlegendes Problem aufgedeckt: Ghostscripts einmaliges DPI-Downsampling komprimiert nicht zuverlässig um einen bestimmten Prozentsatz. Bei bereits komprimierten PDFs hat ein einzelner Ghostscript-Durchlauf mit 90% DPI die Ausgabe tatsächlich um mehr als 40% aufgebläht. Das
pdfwrite-Gerät dekomprimiert und kodiert alles neu.
Original: 899'685
90% (270 DPI): 1'258'729 (+40% GRÖSSER!)
80% (240 DPI): 1'072'671 (+19% GRÖSSER!)
70% (210 DPI): 893'270 (endlich kleiner)
60% (180 DPI): 722'807
50% (150 DPI): 555'228
Was mich störte, war, dass ich explizit in die Tests schauen musste, um das zu bemerken. Sie waren alle grün, weil sie nur überprüft haben, dass es funktioniert – nicht dass die tatsächlich generierte Dateigrösse 10% kleiner als die vorherige ist. Ausserdem wurde ein gefälschtes generiertes PDF verwendet, das sich völlig anders verhält als ein echtes: Das gefälschte enthielt bloss etwas Text, während ein reales ein grosses Bild enthielt – das veränderte die Ausgabe komplett.
Und das war meine gesamte Geschäftslogik!
An diesem Punkt liess ich Claude etwa 10 Minuten laufen. 10 Minuten, um das Problem zu finden.
Bis ich mich schliesslich entschied, die Kontrolle zu übernehmen und Claude zu sagen, wie es zu tun ist.
Version 8 (Final): Mehrpass-Ghostscript bis Zielgrösse erreicht
Mein Feedback: «Der Ansatz mit Ghostscript ist in Ordnung, aber der PdfCompressor sollte sich darum kümmern, Ghostscript mehrfach aufzurufen, bis die gewünschte Komprimierung erreicht ist.»
Die entscheidende Erkenntnis: Ein einzelner Ghostscript-Durchlauf kann zwar aufblähen, aber das Iterieren bei schrittweise niedrigerem DPI wird schliesslich das Ziel erreichen. Die
compress-Methode läuft jetzt intern in einer Schleife und reduziert DPI bei jeder Iteration um 10, bis die Ausgabe klein genug ist.
# `app/models/ibm_document.rb` — Finale Version (create-Methode)
MAX_COMPRESSION_ATTEMPTS = 3
def self.create(pdf_file_bytes)
response = nil
PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
response = submit(bytes)
break if response.success? || !exceeded_message_size?(response)
end
response
end
# `app/services/pdf_compressor.rb` — Finale Version
class PdfCompressor
def self.compress(pdf_bytes, percentage)
return pdf_bytes if percentage.zero?
tolerance = 1
target_size = (pdf_bytes.bytesize * (100 - percentage + tolerance) / 100.0).round
current_bytes = pdf_bytes
dpi = BASE_DPI
loop do
dpi -= DPI_STEP
break current_bytes if dpi < MIN_DPI
current_bytes = run_ghostscript_on(current_bytes, dpi)
break current_bytes if current_bytes.bytesize <= target_size
end
end
def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
percentage = 0
attempts.times do
yield(pdf_bytes)
percentage += compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
end
Wichtigste Erkenntnisse
- Trennung von Verantwortlichkeiten braucht Gespür — Die KI hat anfangs alles in eine Klasse gepackt, dann auf Anfrage vollständig getrennt, aber die falsche Logik an die falsche Stelle extrahiert. Es dauerte mehrere Runden, um die richtige Mitte zu finden:
PdfCompressorist für die Schleife und Komprimierung zuständig, der Aufrufer für die Abbruchbedingung. Die KI hat nie widersprochen oder Alternativen vorgeschlagen – sie hat einfach getan, was verlangt wurde. - Namensgebung ist wichtiger als man denkt — «step» vs. «compression_percentage», «retries» vs. «attempts», benannte vs. positionelle Argumente. Das klingt nach Haarspalterei, aber ungenaue Benennung führte zu falscher Semantik: «retries» zu sagen brachte Claude dazu,
(max_retries + 1).timeszu implementieren anstatt das einfachereattempts.times. - Von KI geschriebene Tests können gefährlich grün sein — Die Tests bestanden, aber sie verwendeten ein gefälschtes PDF mit bloss Text und prüften nur, ob die Ausgabe gültig war – nicht, ob sie tatsächlich kleiner war. Bei einem echten gescannten Dokument hat Ghostscript die Datei um 40% aufgebläht. Die KI hat die tatsächliche Geschäftsanforderung nie getestet.
- Domänenwissen ist unersetzlich — Die KI wusste nicht, dass Ghostscripts
pdfwritealles dekomprimiert und neu kodiert, was bei hohen Qualitätsstufen zu Aufblähung führt. Sie verbrachte 10 Minuten damit, Alternativen auszuprobieren, bevor ich ihr die einfache Lösung sagte: Schleife mit schrittweise niedrigerem DPI, bis das Ziel erreicht ist. - KI ist grossartig für das, was man lieber nicht selbst lernen möchte — Trotz all der Iteration über Architektur und Namensgebung waren die Ghostscript-Kommandozeilenflags von Anfang an korrekt. Das ist der Teil, der mich am längsten gekostet hätte, selbst herauszufinden.