Vibe Coding ausprobiert
Ich programmiere gerne von Hand. Ich habe jetzt ein bisschen LLM-erzeugten Code gesehen und war nicht wirklich angetan. Ich habe es dann einmal ausprobiert und verglichen.
Nach dem Hoster-Wechsel funktioniert der Spam-Filter anders. Das ist zwar auch Spam Assassin, aber die Werte sind irgendwie verschoben. Ich hatte damals den optimalen Schwellenwert bestimmt, das muss ich nun wiederholen.
Das alte Skript hatte ich noch, aber ich wollte direkt die Mailbox-Datei von Thunderbird einlesen. Von daher musste ich das nochmal neu machen.
Klassischer Weg
Ich bin auf die Python-Dokumentation zu mailbox gegangen und habe mir das angeschaut. Unten gibt es Beispiele. Dort steht das hier:
import mailbox for message in mailbox.mbox('~/mbox'): subject = message['subject'] # Could possibly be None. if subject and 'python' in subject.lower(): print(subject)
Also mal wieder typisch Python: Die mbox ist etwas, über das man iterieren kann. Die Nachrichten sind wie ein Dictionary mit den Header-Feldern. Das reicht dann eigentlich auch schon. Der Spam-Eintrag sieht wie folgt aus:
X-Spam-Status: No, score=3.6 required=5.0 tests=BAYES_99,BAYES_999,DKIM_SIGNED,
DKIM_VALID,DKIM_VALID_AU,HTML_MESSAGE,RCVD_IN_DNSWL_NONE,
RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,RCVD_IN_VALIDITY_RPBL_BLOCKED,
RCVD_IN_ZEN_BLOCKED,SPF_HELO_NONE,SPF_PASS,URIBL_DBL_BLOCKED,
URIBL_ZEN_BLOCKED autolearn=no autolearn_force=no version=3.4.6
Also da kann man dann einen regulären Ausdruck nutzen. Den hatte ich auch schon von vorher.
In fünf Minuten hatte ich dann Code zusammen. Allerdings habe ich dann noch weitere 10 Minuten damit verbracht zu verstehen, warum nicht alle E-Mails aufgelistet werden. Das lag dann daran, dass Thunderbird den Spam-Ordner nicht lokal abspeichert. Und auch, dass bisher Spam-Scores nicht negativ waren, mein Regulärer Ausdruck hatte kein - drin. Das war aber zu verstehen, ich habe jetzt diesen Code:
import mailbox import pathlib import re def get_spam_scores(path: pathlib.Path) -> list[float]: result = [] mb = mailbox.mbox(path, create=False) for message in mb: if 'netcup.net' not in message.get('Received', ""): continue spam_status = message.get("X-Spam-Status", "") if m := re.search(r"^(Yes|No), score=([-\d.]+)", spam_status): result.append(float(m.group(2))) mb.close() return result base = pathlib.Path("/home/mu/.thunderbird/vkuhjyty.default/ImapMail/mx2f59.netcup.net") spam_scores = get_spam_scores(base / "Spam")
Das tut, was ich will.
Claude Sonnet 4.5
Dann habe ich mal Claude gefragt:
Please generate Python code that parses a mailbox and extracts the Spam Assassin scores as a list of floats.
Ich bekam eine längere Antwort mit Erklärung sowie diesen Code:
import mailbox import re from typing import List def extract_spamassassin_scores(mbox_path: str) -> List[float]: """ Parse a mailbox file and extract SpamAssassin scores. Args: mbox_path: Path to the mailbox file (mbox format) Returns: List of SpamAssassin scores as floats """ scores = [] # Open the mailbox mbox = mailbox.mbox(mbox_path) # Iterate through all messages for message in mbox: # Look for SpamAssassin headers # Common header formats: # X-Spam-Status: Yes, score=5.2 # X-Spam-Score: 5.2 # X-Spam-Level: ***** spam_status = message.get('X-Spam-Status', '') spam_score = message.get('X-Spam-Score', '') # Try to extract score from X-Spam-Status header if spam_status: # Pattern: score=5.2 or score=5.2 required=5.0 match = re.search(r'score=([-+]?\d+\.?\d*)', spam_status) if match: scores.append(float(match.group(1))) continue # Try to extract score from X-Spam-Score header if spam_score: try: scores.append(float(spam_score.strip())) continue except ValueError: pass mbox.close() return scores def extract_spamassassin_scores_from_string(mbox_content: str) -> List[float]: """ Parse mailbox content from a string and extract SpamAssassin scores. Args: mbox_content: Mailbox content as a string Returns: List of SpamAssassin scores as floats """ scores = [] # Split into individual messages (mbox format uses "From " as delimiter) messages = re.split(r'^From .+$', mbox_content, flags=re.MULTILINE) for message in messages: if not message.strip(): continue # Look for SpamAssassin score in headers match = re.search(r'^X-Spam-Status:.*?score=([-+]?\d+\.?\d*)', message, re.MULTILINE | re.IGNORECASE) if match: scores.append(float(match.group(1))) continue match = re.search(r'^X-Spam-Score:\s*([-+]?\d+\.?\d*)', message, re.MULTILINE | re.IGNORECASE) if match: scores.append(float(match.group(1))) return scores # Example usage if __name__ == "__main__": # Example 1: Parse from file try: mbox_file = "path/to/your/mailbox.mbox" scores = extract_spamassassin_scores(mbox_file) print(f"Found {len(scores)} SpamAssassin scores:") print(scores) if scores: print(f"\nStatistics:") print(f" Average: {sum(scores) / len(scores):.2f}") print(f" Min: {min(scores):.2f}") print(f" Max: {max(scores):.2f}") except FileNotFoundError: print("Mailbox file not found. Please update the path.") # Example 2: Parse from string (useful for testing) sample_mbox = """From user@example.com Mon Jan 01 00:00:00 2024 X-Spam-Status: Yes, score=5.2 required=5.0 Subject: Test Email 1 Body of email 1 From user@example.com Mon Jan 02 00:00:00 2024 X-Spam-Score: 2.1 Subject: Test Email 2 Body of email 2 """ scores_from_string = extract_spamassassin_scores_from_string(sample_mbox) print(f"\nScores from sample: {scores_from_string}")
Das ist halt extrem lang, das ist typisch für LLM Code. Das liegt unter anderem daran, dass wir hier zwei Funktionen haben, einmal extract_spamassassin_scores zum Extrahieren aus einer Mailbox mit dem mailbox-Modul und einmal extract_spamassassin_scores_from_string mit einem eigenen E-Mail-Parser. Das ist schon unnötig, eine davon hätte gereicht. Und ich würde die erste bevorzugen. Ich ignoriere diese zweite extract_spamassassin_scores_from_string.
In der extract_spamassassin_scores werden zwei Header angeschaut:
-
X-Spam-Status, welches eine ausführliche Beschreibung des Spam-Status enthält. -
X-Spam-Score, welches nur den Wert enthält.
Wenn wir in eine E-Mail schaue, die ich über den neuen Hoster bekommen habe, stehen da diese X-Spam-Header drin:
X-Spam-Checker-Version: SpamAssassin 3.4.6 (2021-04-09) on mx2f59.netcup.net
X-Spam-Level: ***
X-Spam-Status: No, score=3.6 required=5.0 tests=BAYES_99,BAYES_999,DKIM_SIGNED,
DKIM_VALID,DKIM_VALID_AU,HTML_MESSAGE,RCVD_IN_DNSWL_NONE,
RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,RCVD_IN_VALIDITY_RPBL_BLOCKED,
RCVD_IN_ZEN_BLOCKED,SPF_HELO_NONE,SPF_PASS,URIBL_DBL_BLOCKED,
URIBL_ZEN_BLOCKED autolearn=no autolearn_force=no version=3.4.6
X-Spam: Yes
Äh, also warum ist da X-Spam: Yes und X-Spam-Status: No? Das muss ich vielleicht nicht ganz verstehen. Aber wir sehen, dass wir hier kein X-Spam-Score haben. Vielleicht war das bei Manitu damals auch so. Jedenfalls enthält X-Spam-Status alles, was wir brauchen. Das X-Spam-Level hat noch je einen Stern pro vollem Spam-Score, ist aber redundant und nicht so hilfreich.
Schauen wir uns die Funktion nochmal im Detail an:
def extract_spamassassin_scores(mbox_path: str) -> List[float]: """ Parse a mailbox file and extract SpamAssassin scores. Args: mbox_path: Path to the mailbox file (mbox format) Returns: List of SpamAssassin scores as floats """ scores = [] # Open the mailbox mbox = mailbox.mbox(mbox_path) # Iterate through all messages for message in mbox: # Look for SpamAssassin headers # Common header formats: # X-Spam-Status: Yes, score=5.2 # X-Spam-Score: 5.2 # X-Spam-Level: ***** spam_status = message.get('X-Spam-Status', '') spam_score = message.get('X-Spam-Score', '') # Try to extract score from X-Spam-Status header if spam_status: # Pattern: score=5.2 or score=5.2 required=5.0 match = re.search(r'score=([-+]?\d+\.?\d*)', spam_status) if match: scores.append(float(match.group(1))) continue # Try to extract score from X-Spam-Score header if spam_score: try: scores.append(float(spam_score.strip())) continue except ValueError: pass mbox.close() return scores
Ich finde die Dokumentation ziemlich nichtssagend. Sie enthält nur Dinge, die auch schon in der Signatur enthalten ist. Der Name der Funktion ist klar und eindeutig. Der Name des Parameters auch. Für mich bringt der Docstring keinerlei Mehrwert und kann weg. Das möglicherweise interessante Implementierungsdetail, dass nämlich X-Spam-Status oder X-Spam-Score vorhanden sein soll, fehlt. Das ist ein Implementierungsdetail, muss also nicht unbedingt in den Docstring. Andererseits ist es eine Annahme, die an die Mailbox gestellt wird.
Das andere wirklich wichtige ist noch, dass die Liste mit den Ergebnissen nicht unbedingt so lang ist, wie die Anzahl E-Mails in der Mailbox. Wenn eine Nachricht keinen Score hat, wird einfach nichts getan. Man bekommt also nur die Scores zurück, die da sind. Je nach Anwendungsfall kann das verfälschen. Das ist ein Hinweis, der in die Dokumentation gekonnt hätte.
Die Inline-Kommentare finde ich auch nicht sinnvoll. Sie dokumentieren das offensichtliche. Es ist, wie wenn man einem Programmieranfänger sagt, dass man Kommentare möchte. Das Öffnen der Mailbox und insbesondere die Schleife über den Inhalt ist doch offensichtlich. Gerade for message in mbox ist doch fast normales Englisch, was soll da der Kommentar noch helfen?
Der Kommentar mit dem Spam Assassin Header ist hilfreich, weil das wichtiger Kontext ist. Ich habe hier in der Erklärung ja auch den Header einer E-Mail kopiert, weil man das einmal gesehen haben muss.
Die Extraktion ist solide. Sie versucht es erstmal mit dem X-Spam-Status, dann mit dem X-Spam-Score. Fehlt einer der Header, wird der nächste genutzt. Stimmt das Format nicht, wird es nicht übernommen. Auch negative Werte werden erkannt. Das ist also solide gelöst.
Dann gibt es noch Kleinigkeiten. Das List[float] ist überholt, inzwischen nutzt man list[float]. Ich weiß nicht, für welche Python-Version das erstellt wird.
Etwas unglücklich ist noch, dass das neue Schlüsselwort match als Variablenname genutzt worden ist.
Nachfrage zum Kürzen
Dem Modell fehlt jetzt mein Kontext, meine Bewertung. Es kann eigentlich nicht wissen, was ich will. Und von daher kann ich jetzt meine Review dort eingeben. Das habe ich dann auch gemacht:
- There are two implementations, one with the
mailboxmodule and one from a string. Only keep the mailbox version. - I don't want comments that explains things which would be obvious to a Python language expert.
- You can use only the
X-Spam-Status, theX-Spam-Scoreis not present in the e-mails that I have. - You can use Python 3.13.
- Don't use the keyword
matchas a variable name.
Und entsprechend bringt Claude das dann ein und meldet Vollzug:
I've simplified the code based on your requirements:
- Removed the string-based implementation
- Removed obvious comments
- Only looks for
X-Spam-Statusheader- Uses Python 3.13 type hints (
list[float]instead ofList[float])- Changed variable name from
matchtom
Jetzt sieht der Code auch so aus, wie ich mir das vorstelle:
import mailbox import re def extract_spamassassin_scores(mbox_path: str) -> list[float]: scores = [] mbox = mailbox.mbox(mbox_path) for message in mbox: spam_status = message.get('X-Spam-Status', '') if spam_status: m = re.search(r'score=([-+]?\d+\.?\d*)', spam_status) if m: scores.append(float(m.group(1))) mbox.close() return scores if __name__ == "__main__": mbox_file = "path/to/your/mailbox.mbox" scores = extract_spamassassin_scores(mbox_file) print(f"Found {len(scores)} SpamAssassin scores:") print(scores) if scores: print(f"\nStatistics:") print(f" Average: {sum(scores) / len(scores):.2f}") print(f" Min: {min(scores):.2f}") print(f" Max: {max(scores):.2f}")
Fazit
Am Ende habe ich letztlich den gleichen Code, wie ich ihn per Hand geschrieben habe. So wirklich viel Zeit hat mir das nicht gespart. Es ist aber ein vollkommen anderes Arbeiten.
Mit der manuellen Methode habe ich die Dokumentation aufgemacht, ein Beispiel genommen und entsprechend erweitert. Ich habe zwei Fehler gemacht:
- Ich habe das Vorzeichen beim Score nicht antizipiert, entsprechend wurden derartige E-Mails nicht ausgelesen.
- Ich habe die Mailbox nicht wieder geschlossen, weil das nicht im Beispiel war. Anscheinend ist das nicht wirklich nötig, aber schadet auch nicht.
Ich musste meinen Stil nicht erklären, ich programmiere einfach.
Mit Claude hatte ich dann nur mein Problem geschildert. Ich musste gar nicht recherchieren. Allerdings musste ich dann Code Review machen und eine neue Version anfordern. Das würde ich in der Interaktion mit Kollegen auch so machen, wenn ich eine Aufgabe delegieren würde.
Von der Zeit her ist es am Ende ähnlich gewesen. Wenn man etwas Übung hat, dann geht das eine und das andere natürlich nochmal schneller. Jedenfalls muss man beachten, dass dies ein sehr kleines Beispiel war, bei dem ich nur eine kleine Funktion haben wollte. Außerdem wird es das gleiche bestimmt auch schon auf Stack Overflow geben.
Wenn ich die Programmiersprache und die Bibliotheken kann, schreibe ich lieber selbst. Aber zum Erzeugen von GitHub CI-Pipelines war das jetzt schon sehr praktisch. Da müsste ich dann so viel Dokumentation lesen, dass ich darauf keine Lust habe. Und da kümmert mich Wartbarkeit nicht so wirklich.