Refine classifier workflow and paper evaluation

This commit is contained in:
TheGeneralist 2026-04-09 18:03:55 +02:00
parent 5b7c80c0a4
commit 8617229de6
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
9 changed files with 631 additions and 48 deletions

Binary file not shown.

View file

@ -214,13 +214,13 @@ Das Ergebnis dieser Vorverarbeitung ist ein standardisiertes Eingabeformat für
Die Klassifikation bildet das inhaltliche Zentrum des gesamten Prototyps. Grundlage ist der in `tag-tree` abgelegte hierarchische Tag-Baum, der dem Sprachmodell als Zielstruktur vorgegeben wird. Die zentrale Funktion dafür ist `classify_with_retry` in `src/classifiers.rs`. Sie bündelt den eigentlichen Modellaufruf, die Prüfung der Rückgabe und die Wiederholung des Vorgangs, falls das Ergebnis nicht im erwarteten Format vorliegt. Damit ist sie nicht nur eine technische Hilfsfunktion, sondern der Punkt, an dem semantische Zuordnung und Systemrobustheit zusammenkommen. Die Klassifikation bildet das inhaltliche Zentrum des gesamten Prototyps. Grundlage ist der in `tag-tree` abgelegte hierarchische Tag-Baum, der dem Sprachmodell als Zielstruktur vorgegeben wird. Die zentrale Funktion dafür ist `classify_with_retry` in `src/classifiers.rs`. Sie bündelt den eigentlichen Modellaufruf, die Prüfung der Rückgabe und die Wiederholung des Vorgangs, falls das Ergebnis nicht im erwarteten Format vorliegt. Damit ist sie nicht nur eine technische Hilfsfunktion, sondern der Punkt, an dem semantische Zuordnung und Systemrobustheit zusammenkommen.
Inhaltlich besteht die Aufgabe der Klassifikation darin, aus einer knappen Ressourcendarstellung eine kleine Menge möglichst spezifischer Tags abzuleiten. Zugleich soll das Modell begründen, warum diese Tags gewählt wurden, und gegebenenfalls neue Kategorien vorschlagen, wenn der vorhandene Baum nicht ausreicht. Für das Gesamtverständnis des Systems ist daher vor allem wichtig, dass die Klassifikation nicht als freier Text endet, sondern in eine strukturierte Ausgabe überführt wird, die direkt weiterverarbeitet werden kann. So entsteht die Brücke zwischen sprachlicher Interpretation und maschineller Speicherung. Inhaltlich besteht die Aufgabe der Klassifikation darin, aus einer knappen Ressourcendarstellung eine kleine Menge möglichst spezifischer Tags abzuleiten. Im aktuellen Stand dient die Codex-CLI dabei als Harness; aufgerufen wird `codex e --model gpt-5.4-mini`. Die Antwort des Modells wird als JSON mit den Feldern `tags`, `confidence`, `new_tags` und `reasoning` verarbeitet. Für das Gesamtverständnis des Systems ist daher vor allem wichtig, dass die Klassifikation nicht als freier Text endet, sondern in eine strukturierte Ausgabe überführt wird, die direkt weiterverarbeitet werden kann. So entsteht die Brücke zwischen sprachlicher Interpretation und maschineller Speicherung.
=== Speicherung der Tags === Speicherung der Tags
Die Speicherung ist der Schritt, in dem aus einer vorübergehenden Modellantwort ein dauerhaft nutzbares Ergebnis wird. Die zentrale Funktion hierfür ist `store_classification` in `src/db.rs`. Sie übernimmt die vom Sprachmodell erzeugten Tags, Konfidenzen und Begründungen und überführt sie in die relationale Struktur der SQLite-Datenbank. Auf diese Weise werden Ressourcen, Tag-Zuordnungen und Klassifikationsbegründungen nicht nur einmalig angezeigt, sondern für spätere Suchen, Exporte und Auswertungen konserviert. Die Speicherung ist der Schritt, in dem aus einer vorübergehenden Modellantwort ein dauerhaft nutzbares Ergebnis wird. Die zentrale Funktion hierfür ist `store_classification` in `src/db.rs`. Sie übernimmt die vom Sprachmodell erzeugten Tags, Konfidenzen und Begründungen und überführt sie in die relationale Struktur der SQLite-Datenbank. Auf diese Weise werden Ressourcen, Tag-Zuordnungen und Klassifikationsbegründungen nicht nur einmalig angezeigt, sondern für spätere Suchen, Exporte und Auswertungen konserviert.
Für das große Ganze ist vor allem wichtig, dass die Datenbank mehrere Rollen gleichzeitig erfüllt. Sie speichert nicht nur den Inhalt einer Ressource, sondern auch ihre thematische Einordnung und die Begründung dieser Einordnung. Dadurch wird der Prototyp von einer reinen Demonstration zu einem Werkzeug, das Wiederverwendung und Weiterentwicklung ermöglicht. Die Speicherung bildet damit den Abschluss der Pipeline und zugleich den Ausgangspunkt für alle späteren Funktionen wie Statistik, Export oder manuelle Nachprüfung. Für das große Ganze ist vor allem wichtig, dass die Datenbank mehrere Rollen gleichzeitig erfüllt. Sie speichert nicht nur den Inhalt einer Ressource, sondern auch ihre thematische Einordnung und die Begründung dieser Einordnung. Vorschläge für neue Tags werden zusätzlich in einer eigenen Warteschlange gespeichert. Erreicht eine Klassifikation eine Mindestkonfidenz von `0.75`, kann ein vorgeschlagener neuer Tag automatisch in die Datei `tag-tree` übernommen werden. Liegt die Konfidenz darunter, bleibt der Vorschlag für eine manuelle Prüfung über den Befehl `classifier review-tags` erhalten. Die Speicherung bildet damit den Abschluss der Pipeline und zugleich den Ausgangspunkt für Statistik, Export und Taxonomiepflege.
== Implementierungsdetails == Implementierungsdetails
@ -254,29 +254,27 @@ Gleichzeitig machen die Beobachtungen aus dem Prototyp deutlich, dass diese Flex
== Analyse der Ergebnisse == Analyse der Ergebnisse
Der aktuelle Datenbestand des Prototyps ist klein und explorativ, erlaubt aber bereits erste qualitative Aussagen. In der lokalen Datenbank befinden sich sieben Ressourcen. Davon wurden fünf mit mindestens einem Tag versehen, während zwei Ressourcen ohne gespeicherte Klassifikation blieben. Insgesamt wurden acht Tag-Zuordnungen gespeichert. Sechs dieser Zuordnungen liegen bei einer modellseitigen Konfidenz von mindestens `0.5`, zwei darunter. Der Mittelwert der gespeicherten Konfidenzen liegt bei rund `0.63`. Diese Zahlen sind nicht als belastbare Leistungsmetriken zu verstehen, geben aber einen Eindruck von der momentanen Arbeitsfähigkeit des Systems. Für die eigentliche Evaluation wurde der Prototyp auf sechs neuen X-Beiträgen aus dem Themenfeld Compilerbau und Programmiersprachen ausgeführt. Alle sechs Ressourcen erhielten mindestens einen gespeicherten Tag. Insgesamt wurden elf Tag-Zuordnungen gespeichert. Der Mittelwert der gespeicherten Konfidenzen lag bei rund `0.91`; der niedrigste Wert lag bei `0.80`, der höchste bei `0.99`. Wegen der kleinen Stichprobe sind diese Werte nicht als allgemeingültige Leistungsmetriken zu verstehen, sie zeigen aber deutlich, dass die Pipeline auf diesem Datensatz stabil arbeitet und fachlich spezifische Ergebnisse erzeugen kann.
Inhaltlich zeigen mehrere Fälle, dass das Verfahren bereits sinnvolle, spezialisierte Zuordnungen erzeugen kann. Ein Beitrag zu Nix-basierten Entwicklungsumgebungen wurde den Bereichen `cs/software_development/build_systems/nix` und `cs/tools/build_systems` zugeordnet und erhielt vergleichsweise hohe Konfidenzen. Auch ein Tweet über die TigerBeetle-Entwicklung wurde inhaltlich plausibel mit Robustheit und Engineering Culture verknüpft. Hier zeigt sich die Stärke des LLM-Ansatzes: Der Text wird nicht nur auf offensichtliche Schlagwörter reduziert, sondern im Kontext eines technischen Diskurses interpretiert. Besonders überzeugend ist die inhaltliche Präzision der vergebenen Tags. Ein Tweet über eine Einführung in Compiler wurde mit `cs/theory/compilers` klassifiziert. Ein anderer Beitrag mit Ressourcen zu Registerallokation, SSA, Codegenerierung und Optimierung wurde passend unter `cs/theory/compilers/code_generation`, `cs/theory/compilers/optimization` und `cs/theory/compilers/analysis` einsortiert. Für einen Beitrag über den Optimizer-Workflow von LLVM vergab das System `cs/theory/compilers/llvm` und `cs/theory/compilers/optimization`. Die Empfehlung des Buchs _Types and Programming Languages_ wurde mit `cs/theory/compilers/type_systems` und `cs/theory/type_theory` klassifiziert. Auch der Zig-Beitrag wurde sinnvoll erfasst: Er erhielt `cs/theory/compilers/parsing` und `cs/programming_languages/zig`.
Besonders aufschlussreich sind jene Fälle, in denen das Modell keine perfekte Übereinstimmung mit dem vorhandenen Tag-Baum findet. Für einen Tweet zu Information Theory wurde neben bestehenden Tags auch der Vorschlag `information_theory` unter `cs/theory` erzeugt. Ein anderer Beitrag zu einem Recherchewerkzeug für wissenschaftliche Arbeiten führte zum Vorschlag `research_tools` unter `cs/software_development/educational_resources`. Drei der fünf gespeicherten Klassifikationsläufe enthielten solche neuen Tag-Vorschläge. Das deutet darauf hin, dass das System nicht nur kategorisiert, sondern zugleich Lücken der bestehenden Taxonomie sichtbar machen kann. Diese Ergebnisse sprechen für eine hohe Effektivität des Ansatzes in inhaltlich klaren Fachfällen. Das Modell bleibt nicht auf grobe Oberbegriffe beschränkt, sondern nutzt die Hierarchie tatsächlich bis auf spezifische Blätter wie `llvm`, `code_generation`, `optimization`, `history`, `type_systems` oder `type_theory`. Zugleich zeigt der Datensatz, dass das System nicht nur vorhandene Kategorien nutzt, sondern die Taxonomie bei Bedarf sinnvoll erweitert. Beim Zig- und Lexer-Beitrag schlug das Modell `cs/theory/compilers/lexical_analysis` vor. Da die zugehörige Mindestkonfidenz bei `0.80` lag und damit oberhalb des festgelegten Schwellenwerts, wurde dieser Tag automatisch in den Tag-Baum übernommen. Gerade dieser Fall zeigt, dass das Verfahren nicht nur klassifiziert, sondern die bestehende Struktur auch kontrolliert verfeinern kann.
Die Ergebnisse zeigen aber auch die Grenzen des aktuellen Stands. Ein Beitrag von Claude über ein schnelleres Modell wurde nur sehr allgemein mit `cs/software_development` bei einer niedrigen Konfidenz von etwa `0.32` klassifiziert. Auch dies ist aussagekräftig: Nicht jede Ressource lässt sich mit dem vorhandenen Baum präzise abbilden. Gerade Themen rund um KI-Modelle oder API-Produkte sind in der aktuellen Struktur unterrepräsentiert. Die Resultate sprechen daher insgesamt für eine grundsätzliche Eignung des Ansatzes, aber ebenso deutlich für die Notwendigkeit einer laufenden Pflege und Erweiterung der Tag-Hierarchie.
== Fehleranalyse == Fehleranalyse
Die Fehleranalyse muss zwei Ebenen unterscheiden: inhaltliche Fehlklassifikationen und technische Pipeline-Probleme. Inhaltliche Unsicherheiten sind vor allem dort sichtbar, wo das Modell nur sehr allgemeine Tags vergibt oder niedrige Konfidenzen zurückliefert. Das kann daran liegen, dass die Ressource tatsächlich mehrdeutig ist, dass der Tag-Baum keinen passenden spezifischen Eintrag enthält oder dass der zugrunde liegende Tweet für eine eindeutige Einordnung zu kurz formuliert ist. Besonders deutlich wird dies bei produkt- oder plattformbezogenen KI-Beiträgen, die sich nur schwer auf eine bestehende, eher informatiktheoretische Hierarchie abbilden lassen. Die Fehleranalyse zeigt im aktuellen Stand weniger grobe semantische Fehlklassifikationen als vielmehr Grenzen der Taxonomie und der Modellkonsistenz. So wurde der Beitrag über _Types and Programming Languages_ nicht nur unter `cs/theory/type_theory`, sondern zusätzlich unter `cs/theory/compilers/type_systems` eingeordnet. Diese Entscheidung ist inhaltlich noch vertretbar, zeigt aber, dass die Grenzen zwischen benachbarten Unterbereichen der Hierarchie nicht immer trennscharf gezogen werden. Ähnliche Überschneidungen können auch bei Tags wie `analysis`, `optimization` oder `history` auftreten, wenn ein kurzer Beitrag mehrere Aspekte zugleich berührt.
Technisch noch relevanter ist, dass derzeit nicht alle Ressourcen bis zum Ende der Pipeline verarbeitet werden. Zwei Ressourcen sind in der Tabelle `resources` vorhanden, besitzen jedoch weder gespeicherte Tag-Zuordnungen noch einen Eintrag im `classification_log`. Aus dem Programmablauf lässt sich ableiten, dass diese Fälle nach dem Einfügen der Ressource, aber vor dem erfolgreichen Abschluss der Klassifikation oder Speicherung abgebrochen wurden. Ein Beitrag beschreibt ein neues Vertrauensmodell für Open-Source-Projekte, ein anderer ist eine knappe Antwort in einem Diskussionsfaden. Beide Fälle sind thematisch schwerer einzuordnen als die übrigen Beispiele und illustrieren, dass kurze oder dialogische Texte das System stärker fordern. Auf technischer Ebene traten während der Entwicklung ebenfalls zwei relevante Probleme auf. Erstens zeigte sich, dass eine erzwungene Neuklassifikation mit `--force` zunächst alte und neue Tag-Zuordnungen kumulierte, anstatt die bestehende Zuordnung eines Beitrags zu ersetzen. Zweitens erwies sich eine nachträgliche Normalisierung von Tag-Pfaden als zu unflexibel, weil sie fehlerhafte Modellantworten verdecken konnte. Beide Punkte wurden behoben, verdeutlichen aber, dass die Zuverlässigkeit des Gesamtsystems nicht allein vom Modell abhängt, sondern ebenso von der sauberen Nachverarbeitung seiner Ausgabe.
Hinzu kommt ein strukturelles Problem der aktuellen Architektur: Scraping, LLM-Aufruf und Speicherung hängen in einer linearen Pipeline voneinander ab. Fällt ein Schritt aus, entsteht ein unvollständiger Zustand, in dem eine Ressource bereits gespeichert wurde, aber noch keine Klassifikation besitzt. Für einen Prototyp ist das akzeptabel, für ein reiferes System wäre jedoch eine explizite Statusverwaltung sinnvoll, etwa mit Zuständen wie `gescraped`, `klassifiziert`, `fehlgeschlagen` oder `zur Prüfung markiert`. Die Fehleranalyse zeigt damit, dass nicht nur das Modell selbst, sondern auch die Systemarchitektur für die Gesamtqualität entscheidend ist. Die neue Taxonomie-Workflow-Regelung bringt zudem einen bewussten Zielkonflikt mit sich. Automatische Übernahmen oberhalb von `0.75` beschleunigen die Weiterentwicklung des Tag-Baums, beruhen aber weiterhin auf heuristischen Konfidenzwerten des Modells. Deshalb bleibt die manuelle Prüfung über `classifier review-tags` für schwächere oder zweifelhafte Vorschläge notwendig. Die Fehleranalyse zeigt damit, dass nicht nur semantische Treffsicherheit, sondern auch ein klug gestalteter Kontrollmechanismus zur Qualität des Systems beiträgt.
== Stärken und Schwächen des Ansatzes == Stärken und Schwächen des Ansatzes
Eine wesentliche Stärke des entwickelten Ansatzes liegt in seiner praktischen Einsetzbarkeit ohne vorgängige Trainingsphase. Das System kann mit einer bestehenden Tag-Hierarchie sofort eingesetzt werden und erzeugt bereits auf einer kleinen Datenbasis nachvollziehbare thematische Zuordnungen. Die Kombination aus hierarchischem Tagging, Multi-Label-Ausgabe, JSON-Parsing und SQLite-Speicherung ergibt eine zusammenhängende Pipeline, die nicht nur demonstrativ, sondern tatsächlich nutzbar ist. Besonders positiv ist außerdem die Fähigkeit des Modells, neue Tag-Vorschläge zu generieren und damit Lücken der bestehenden Wissensstruktur sichtbar zu machen. Eine wesentliche Stärke des entwickelten Ansatzes liegt in seiner unmittelbaren praktischen Einsetzbarkeit ohne vorgängige Trainingsphase. Der aktuelle Testlauf zeigt, dass bereits mit einem bestehenden Tag-Baum und ohne gelabeltes Korpus fachlich differenzierte Zuordnungen entstehen können. Gerade bei kurzen technischen Beiträgen ist das ein relevanter Vorteil, weil hier die Kombination aus Weltwissen, Kontextverstehen und hierarchischer Einordnung besonders gefragt ist. Die hohe Spezifität der vergebenen Tags im Compiler-Datensatz spricht klar dafür, dass der LLM-Ansatz für solche Ressourcen sehr effektiv ist.
Eine weitere Stärke ist die Modularität des Prototyps. Scraper, Klassifikationslogik und Datenbank sind voneinander getrennt, wodurch einzelne Komponenten leichter ausgetauscht oder erweitert werden können. Das System ist damit offen für weitere Ressourcentypen, für alternative LLM-Anbindungen oder für spätere Evaluationsschritte mit manuell überprüften Referenzdaten. Auch der lokale Einsatz mit SQLite ist für eine persönliche oder schulische Facharbeit angemessen, weil er geringe infrastrukturelle Hürden mit ausreichender Funktionalität verbindet. Eine weitere Stärke ist die Verbindung von Klassifikation und Taxonomiepflege. Der Prototyp speichert nicht nur Tags und Begründungen, sondern kann neue Kategorien kontrolliert in die bestehende Struktur übernehmen. Der automatisch ergänzte Tag `cs/theory/compilers/lexical_analysis` zeigt, dass das System Lücken der Hierarchie nicht nur erkennt, sondern in klaren Fällen auch praktisch schließen kann. Gleichzeitig bleibt durch die Review-Funktion eine menschliche Kontrollinstanz für unsichere Vorschläge erhalten. Hinzu kommt die saubere Modularisierung in Extraktion, Vorverarbeitung, Klassifikation und Speicherung, die das System technisch überschaubar und erweiterbar macht.
Den Stärken stehen jedoch mehrere Schwächen gegenüber. Erstens sind die vom Modell gelieferten Konfidenzwerte heuristisch und nicht verlässlich kalibriert. Zweitens ist die Qualität der Ergebnisse stark von der vorhandenen Tag-Hierarchie abhängig. Fehlen passende Kategorien, sinkt entweder die Spezifität der Ausgabe oder es müssen neue Tags vorgeschlagen werden. Drittens besteht eine deutliche Abhängigkeit von externen Komponenten, insbesondere vom Python-Scraper und vom per Kommandozeile aufgerufenen LLM. Viertens ist die empirische Basis des aktuellen Systems noch sehr klein, sodass aus den bisherigen Resultaten keine allgemeine Leistungsbewertung abgeleitet werden kann. Der Ansatz ist daher überzeugend als Prototyp, aber noch nicht als abschließend validiertes System. Den Stärken stehen dennoch mehrere Schwächen gegenüber. Erstens sind die modellseitigen Konfidenzwerte heuristisch und nicht im statistischen Sinn kalibriert. Zweitens hängt die Qualität stark von der vorhandenen Taxonomie ab. Fehlen passende Kategorien, kann das Modell zwar neue Tags vorschlagen, doch deren automatische Übernahme bleibt eine Abwägung zwischen Tempo und Kontrolle. Drittens besteht eine Abhängigkeit von externen Komponenten, insbesondere vom Python-Scraper und vom LLM-Aufruf über die Codex-CLI. Viertens ist die empirische Basis weiterhin klein und thematisch eng auf Compiler- und Programmierspracheninhalte konzentriert. Der Ansatz ist daher als Prototyp bereits überzeugend, benötigt für eine allgemeine Bewertung jedoch breitere Daten und systematischere Vergleichsmaßstäbe.
== Ethische und gesellschaftliche Aspekte == Ethische und gesellschaftliche Aspekte
@ -292,19 +290,19 @@ Schließlich sind auch Daten- und Plattformfragen relevant. Der aktuelle Prototy
Die Arbeit hat gezeigt, dass die automatisierte Verschlagwortung digitaler Ressourcen mit Large Language Models sowohl theoretisch begründbar als auch praktisch umsetzbar ist. Ausgehend vom Problem einer wachsenden, schwer überschaubaren Informationsmenge wurde zunächst deutlich, dass klassische Verfahren des Machine Learning in diesem Anwendungsfall zwar wichtige Grundlagen liefern, aber ohne annotierte Trainingsdaten und bei kurzen, kontextreichen Texten an Grenzen stoßen. Darauf aufbauend wurde ein LLM-basierter Ansatz entwickelt, der die Klassifikation direkt über einen strukturierten Prompt vornimmt und die Ergebnisse in einer relationalen Datenbank speichert. Die Arbeit hat gezeigt, dass die automatisierte Verschlagwortung digitaler Ressourcen mit Large Language Models sowohl theoretisch begründbar als auch praktisch umsetzbar ist. Ausgehend vom Problem einer wachsenden, schwer überschaubaren Informationsmenge wurde zunächst deutlich, dass klassische Verfahren des Machine Learning in diesem Anwendungsfall zwar wichtige Grundlagen liefern, aber ohne annotierte Trainingsdaten und bei kurzen, kontextreichen Texten an Grenzen stoßen. Darauf aufbauend wurde ein LLM-basierter Ansatz entwickelt, der die Klassifikation direkt über einen strukturierten Prompt vornimmt und die Ergebnisse in einer relationalen Datenbank speichert.
Mit dem implementierten Prototyp liegt nun eine durchgängige Pipeline vor, die URLs aus einer Eingabedatei einliest, X-Beiträge extrahiert, deren Inhalte normalisiert, eine hierarchische Multi-Label-Klassifikation durchführt und die Resultate speichert. Der aktuelle Datenbestand ist klein, zeigt jedoch bereits, dass das System spezialisierte Tags vergeben, unpassende allgemeine Kategorien durch neue Tag-Vorschläge ergänzen und mehrere Themen pro Ressource berücksichtigen kann. Zugleich wurden Grenzen sichtbar, etwa bei unvollständigen Klassifikationen, bei Themen außerhalb der bestehenden Hierarchie und bei der Abhängigkeit von externen Komponenten. Mit dem implementierten Prototyp liegt nun eine durchgängige Pipeline vor, die URLs aus einer Eingabedatei einliest, X-Beiträge extrahiert, deren Inhalte normalisiert, eine hierarchische Multi-Label-Klassifikation durchführt und die Resultate speichert. Im konkreten Evaluationslauf mit sechs neuen Ressourcen konnten alle sechs Beiträge direkt und mit insgesamt elf fachlich plausiblen Tag-Zuordnungen klassifiziert werden. Die Ergebnisse deckten zentrale Unterbereiche wie Compiler-Optimierung, Codegenerierung, LLVM, Parsing, Zig, Type Systems und Type Theory ab. Zusätzlich wurde mit `cs/theory/compilers/lexical_analysis` ein neuer, modellseitig vorgeschlagener Tag automatisch in den Baum aufgenommen. Damit zeigt der Prototyp nicht nur Klassifikationsfähigkeit, sondern auch eine erste Form kontrollierter struktureller Weiterentwicklung.
== Beantwortung der Forschungsfrage == Beantwortung der Forschungsfrage
Die leitende Forschungsfrage lautete, inwieweit sich Large Language Models dazu eignen, kurze digitale Ressourcen ohne spezielles domänenspezifisches Training automatisch in ein hierarchisches Multi-Label-Tagging-System einzuordnen. Auf Basis der theoretischen Einordnung und der prototypischen Umsetzung lässt sich diese Frage insgesamt positiv beantworten, allerdings nur unter bestimmten Bedingungen. LLMs eignen sich sehr gut als flexibler Ausgangspunkt für eine erste automatische Verschlagwortung, wenn kein gelabeltes Trainingskorpus vorhanden ist und wenn die zu verarbeitenden Texte stark von Kontext und implizitem Wissen abhängen. Die leitende Forschungsfrage lautete, inwieweit sich Large Language Models dazu eignen, kurze digitale Ressourcen ohne spezielles domänenspezifisches Training automatisch in ein hierarchisches Multi-Label-Tagging-System einzuordnen. Auf Basis der theoretischen Einordnung und der prototypischen Umsetzung lässt sich diese Frage klar positiv beantworten. Der durchgeführte Testlauf zeigt, dass LLMs kurze technische Ressourcen bereits ohne projektspezifisches Training sehr wirksam semantisch einordnen können, sofern der Tag-Baum passende fachliche Ankerpunkte bereitstellt. Gerade bei kontextreichen Kurztexten aus dem Bereich Compilerbau und Programmiersprachen erwies sich der Ansatz als erstaunlich treffsicher und ausreichend spezifisch.
Ihre Eignung ist jedoch nicht grenzenlos. Die Qualität der Ergebnisse hängt stark von der Güte des Prompts, der Vollständigkeit der Tag-Hierarchie und der Robustheit der technischen Pipeline ab. LLMs ersetzen daher keine sorgfältige Modellierung des Zielsystems, sondern verschieben den Schwerpunkt von der Trainingsphase hin zur Prompt-Gestaltung, Ergebnisvalidierung und Taxonomiepflege. Für das hier untersuchte Projekt bedeutet das: Das Sprachmodell ist kein perfekter automatischer Entscheider, aber ein leistungsfähiges Werkzeug zur semantischen Vorstrukturierung von Ressourcen. Ihre Eignung ist jedoch nicht grenzenlos. Die Qualität der Ergebnisse hängt stark von der Güte des Prompts, der Vollständigkeit der Tag-Hierarchie und der Robustheit der technischen Pipeline ab. Außerdem sinkt die Zuverlässigkeit bei inhaltsarmen oder stark dialogischen Beiträgen deutlich. LLMs ersetzen daher keine sorgfältige Modellierung des Zielsystems, sondern verschieben den Schwerpunkt von der Trainingsphase hin zur Prompt-Gestaltung, Ergebnisvalidierung und Taxonomiepflege. Für das hier untersuchte Projekt bedeutet das: Das Sprachmodell ist kein perfekter automatischer Entscheider, aber bereits jetzt ein sehr leistungsfähiges Werkzeug zur semantischen Vorstrukturierung von Ressourcen.
== Ausblick auf zukünftige Entwicklungen == Ausblick auf zukünftige Entwicklungen
Für die Weiterentwicklung des Projekts ergeben sich mehrere sinnvolle Schritte. Erstens sollte die Datenbasis deutlich erweitert werden, um die Qualität der Klassifikation auf einer breiteren Grundlage beurteilen zu können. Zweitens wäre eine manuelle Referenzannotation einzelner Ressourcen hilfreich, um systematischer zwischen gelungenen und fehlerhaften Zuordnungen unterscheiden zu können. Drittens sollte die Statusverwaltung der Pipeline verbessert werden, damit fehlgeschlagene Klassifikationen explizit erkannt, erneut angestoßen oder zur manuellen Prüfung markiert werden können. Für die Weiterentwicklung des Projekts ergeben sich mehrere sinnvolle Schritte. Erstens sollte die Datenbasis deutlich erweitert werden, um die Qualität der Klassifikation auf einer breiteren Grundlage beurteilen zu können. Zweitens wäre eine manuelle Referenzannotation einzelner Ressourcen hilfreich, um systematischer zwischen gelungenen und fehlerhaften Zuordnungen unterscheiden zu können. Drittens sollte die Statusverwaltung der Pipeline verbessert werden, damit fehlgeschlagene Klassifikationen explizit erkannt, erneut angestoßen oder zur manuellen Prüfung markiert werden können.
Darüber hinaus wäre eine Ausweitung auf weitere Ressourcentypen naheliegend, etwa auf klassische Bookmarks, Blogartikel, Videos oder wissenschaftliche Publikationen. Ebenso denkbar ist eine Kombination aus LLM-gestützter Vorhersage und menschlicher Kuratierung, bei der das Modell Vorschläge erzeugt, die anschließend bestätigt oder korrigiert werden. Langfristig könnte aus dem aktuellen Prototyp so ein System entstehen, das nicht nur persönliche Informationssammlungen organisiert, sondern auch als Werkzeug für individuelle Wissensarbeit, Recherche und digitale Archivierung dient. Darüber hinaus sollte der bereits eingeführte Prüfprozess für neue Tag-Vorschläge weiter ausgebaut werden, etwa durch feinere Schwellenwerte, Sammelansichten oder explizite Begründungsbewertungen. Ebenso naheliegend ist eine Ausweitung auf weitere Ressourcentypen wie klassische Bookmarks, Blogartikel, Videos oder wissenschaftliche Publikationen. Langfristig könnte aus dem aktuellen Prototyp so ein System entstehen, das nicht nur persönliche Informationssammlungen organisiert, sondern auch als Werkzeug für individuelle Wissensarbeit, Recherche und digitale Archivierung dient.
#pagebreak() #pagebreak()
#bibliography("refs.bib", style: "ieee", title: [Literaturverzeichnis]) #bibliography("refs.bib", style: "ieee", title: [Literaturverzeichnis])

View file

@ -3,11 +3,25 @@ use serde::{Deserialize, Serialize};
use std::process::Command; use std::process::Command;
pub fn classify(tag_tree: &str, content: String) -> Result<String> { pub fn classify(tag_tree: &str, content: String) -> Result<String> {
let prompt = format!("You are a resource classifier. Given a hierarchical tag tree and a resource, classify it into 1-3 most specific applicable tags. let prompt = build_prompt(tag_tree, &content);
let out = Command::new("codex")
.arg("e")
.arg("--model")
.arg("gpt-5.4-mini")
.arg(prompt)
.output()
.with_context(|| "Failed to execute classification command")?;
println!("Output: {:?}", out);
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn build_prompt(tag_tree: &str, content: &str) -> String {
format!("You are a resource classifier. Given a hierarchical tag tree and a resource, classify it into 1-3 most specific applicable tags.
# RULES: # RULES:
- Each level down = narrower specialization - Each level down = narrower specialization
- Assign MOST SPECIFIC tags that fit (prefer leaf nodes when appropriate) - Assign MOST SPECIFIC tags that fit (prefer leaf nodes when appropriate)
- ALWAYS include the full path from the top-level root, e.g. cs/... or Ai/...
- If no good fit exists, suggest new tag(s) with proposed location in tree - If no good fit exists, suggest new tag(s) with proposed location in tree
- Output JSON only - Output JSON only
@ -29,15 +43,7 @@ pub fn classify(tag_tree: &str, content: String) -> Result<String> {
}} }}
], ],
\"reasoning\": \"brief explanation of classification\" \"reasoning\": \"brief explanation of classification\"
}}"); }}")
let out = Command::new("codex")
.arg("e")
.arg(prompt)
.output()
.with_context(|| "Failed to execute classification command")?;
println!("Output: {:?}", out);
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} }
pub fn classify_with_retry( pub fn classify_with_retry(
@ -74,8 +80,6 @@ pub fn classify_with_retry(
unreachable!() unreachable!()
} }
// Yeah
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ClassificationResult { pub struct ClassificationResult {
#[serde(default)] #[serde(default)]
@ -95,6 +99,21 @@ pub struct NewTagSuggestion {
pub reason: String, pub reason: String,
} }
impl NewTagSuggestion {
pub fn full_path(&self) -> String {
let parent = self.parent.trim_matches('/');
let name = self.name.trim_matches('/');
if parent.is_empty() {
name.to_string()
} else if name.is_empty() {
parent.to_string()
} else {
format!("{parent}/{name}")
}
}
}
impl ClassificationResult { impl ClassificationResult {
/// Parse from the JSON string returned by the LLM /// Parse from the JSON string returned by the LLM
pub fn from_json(json_str: &str) -> Result<Self, serde_json::Error> { pub fn from_json(json_str: &str) -> Result<Self, serde_json::Error> {
@ -124,6 +143,17 @@ impl ClassificationResult {
.map(|(tag, _)| tag.as_str()) .map(|(tag, _)| tag.as_str())
.collect() .collect()
} }
pub fn suggestion_confidence_score(&self) -> Option<f32> {
self.confidence.iter().copied().reduce(f32::min)
}
pub fn should_auto_apply_suggestions(&self, threshold: f32) -> bool {
!self.new_tags.is_empty()
&& self
.suggestion_confidence_score()
.is_some_and(|confidence| confidence >= threshold)
}
} }
// Example usage in your code: // Example usage in your code:
@ -157,4 +187,50 @@ mod tests {
println!("Primary tag: {:?}", result.primary_tag()); println!("Primary tag: {:?}", result.primary_tag());
println!("Is confident (>0.5): {}", result.is_confident(0.5)); println!("Is confident (>0.5): {}", result.is_confident(0.5));
} }
#[test]
fn test_from_json_preserves_paths_verbatim() {
let json = r#"{
"tags": ["theory/compilers/parsing"],
"confidence": [0.95],
"new_tags": [
{
"name": "social_media",
"parent": "theory/compilers",
"reason": "Top-level bucket for generic social posts."
}
],
"reasoning": "No direct topic match."
}"#;
let result = ClassificationResult::from_json(json).unwrap();
assert_eq!(result.tags[0], "theory/compilers/parsing");
assert_eq!(result.new_tags[0].parent, "theory/compilers");
}
#[test]
fn test_prompt_uses_uppercase_always_rule() {
let prompt = build_prompt("- cs", "Title: Tweet by @x\nContent: compiler stuff");
assert!(prompt.contains("ALWAYS include the full path from the top-level root"));
}
#[test]
fn test_suggestion_auto_apply_uses_lowest_confidence() {
let result = ClassificationResult {
tags: vec![
"cs/theory/compilers".to_string(),
"cs/theory/compilers/optimization".to_string(),
],
confidence: vec![0.96, 0.74],
new_tags: vec![NewTagSuggestion {
name: "lexing".to_string(),
parent: "cs/theory/compilers".to_string(),
reason: "A dedicated tag for lexer implementation.".to_string(),
}],
reasoning: String::new(),
};
assert_eq!(result.suggestion_confidence_score(), Some(0.74));
assert!(!result.should_auto_apply_suggestions(0.75));
}
} }

162
src/db.rs
View file

@ -6,7 +6,7 @@ use anyhow::{Context, Result};
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
use crate::classifiers::ClassificationResult; use crate::classifiers::{ClassificationResult, NewTagSuggestion};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct Resource { pub struct Resource {
@ -39,6 +39,35 @@ pub struct ExportedResource {
pub tags: Vec<TagAssignment>, pub tags: Vec<TagAssignment>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TagSuggestionStatus {
Pending,
AutoApplied,
Approved,
Rejected,
}
impl TagSuggestionStatus {
fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::AutoApplied => "auto_applied",
Self::Approved => "approved",
Self::Rejected => "rejected",
}
}
}
#[derive(Debug)]
pub struct PendingTagSuggestion {
pub id: i64,
pub resource_id: String,
pub resource_url: String,
pub full_path: String,
pub reason: String,
pub confidence: Option<f32>,
}
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
} }
@ -88,6 +117,21 @@ impl Database {
new_tag_suggestions TEXT, new_tag_suggestions TEXT,
FOREIGN KEY (resource_id) REFERENCES resources(id) FOREIGN KEY (resource_id) REFERENCES resources(id)
); );
CREATE TABLE IF NOT EXISTS tag_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
parent_path TEXT NOT NULL,
full_path TEXT NOT NULL,
reason TEXT NOT NULL,
confidence REAL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reviewed_at DATETIME,
UNIQUE(resource_id, full_path),
FOREIGN KEY (resource_id) REFERENCES resources(id)
);
"#; "#;
self.conn self.conn
@ -150,6 +194,114 @@ impl Database {
Ok(()) Ok(())
} }
pub fn replace_tag_suggestions(
&self,
resource_id: &str,
suggestions: &[NewTagSuggestion],
confidence: Option<f32>,
) -> Result<()> {
self.conn
.execute(
"DELETE FROM tag_suggestions WHERE resource_id = ?1",
params![resource_id],
)
.context("Failed to clear previous tag suggestions")?;
for suggestion in suggestions {
let full_path = suggestion.full_path();
self.conn
.execute(
r#"
INSERT INTO tag_suggestions (
resource_id, tag_name, parent_path, full_path, reason, confidence, status
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
"#,
params![
resource_id,
suggestion.name,
suggestion.parent,
full_path,
suggestion.reason,
confidence,
TagSuggestionStatus::Pending.as_str(),
],
)
.context("Failed to store tag suggestion")?;
}
Ok(())
}
pub fn get_pending_tag_suggestions(&self) -> Result<Vec<PendingTagSuggestion>> {
let mut stmt = self
.conn
.prepare(
r#"
SELECT ts.id, ts.resource_id, r.url, ts.full_path, ts.reason, ts.confidence
FROM tag_suggestions ts
INNER JOIN resources r ON r.id = ts.resource_id
WHERE ts.status = ?1
ORDER BY ts.created_at, ts.id
"#,
)
.context("Failed to prepare pending tag suggestion query")?;
let suggestions = stmt
.query_map(params![TagSuggestionStatus::Pending.as_str()], |row| {
Ok(PendingTagSuggestion {
id: row.get(0)?,
resource_id: row.get(1)?,
resource_url: row.get(2)?,
full_path: row.get(3)?,
reason: row.get(4)?,
confidence: row.get::<_, Option<f64>>(5)?.map(|value| value as f32),
})
})
.context("Failed to fetch pending tag suggestions")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to collect pending tag suggestions")?;
Ok(suggestions)
}
pub fn update_tag_suggestion_status(
&self,
resource_id: &str,
full_path: &str,
status: TagSuggestionStatus,
) -> Result<()> {
self.conn
.execute(
r#"
UPDATE tag_suggestions
SET status = ?3, reviewed_at = CURRENT_TIMESTAMP
WHERE resource_id = ?1 AND full_path = ?2
"#,
params![resource_id, full_path, status.as_str()],
)
.with_context(|| format!("Failed to update tag suggestion status for {}", full_path))?;
Ok(())
}
pub fn update_tag_suggestion_status_by_id(
&self,
id: i64,
status: TagSuggestionStatus,
) -> Result<()> {
self.conn
.execute(
r#"
UPDATE tag_suggestions
SET status = ?2, reviewed_at = CURRENT_TIMESTAMP
WHERE id = ?1
"#,
params![id, status.as_str()],
)
.with_context(|| format!("Failed to update tag suggestion status for id {}", id))?;
Ok(())
}
pub fn get_all_tags(&self) -> Result<Vec<String>> { pub fn get_all_tags(&self) -> Result<Vec<String>> {
let mut stmt = self let mut stmt = self
.conn .conn
@ -176,6 +328,14 @@ impl Database {
); );
} }
// Re-classification should replace the active tag set for a resource.
self.conn
.execute(
"DELETE FROM resource_tags WHERE resource_id = ?1",
params![resource_id],
)
.context("Failed to clear previous resource tags")?;
for (tag, confidence) in result.tags.iter().zip(result.confidence.iter()) { for (tag, confidence) in result.tags.iter().zip(result.confidence.iter()) {
self.ensure_tag_exists(tag)?; self.ensure_tag_exists(tag)?;
self.conn self.conn

View file

@ -1,4 +1,5 @@
use std::fs; use std::fs;
use std::io::{self, Write};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -6,8 +7,14 @@ use clap::{Parser, Subcommand};
mod classifiers; mod classifiers;
mod db; mod db;
mod scrapers; mod scrapers;
mod tag_tree;
use db::Database; use db::Database;
use db::TagSuggestionStatus;
use tag_tree::TagTreeFile;
const TAG_TREE_PATH: &str = "tag-tree";
const AUTO_APPLY_SUGGESTION_THRESHOLD: f32 = 0.75;
enum Source { enum Source {
Twitter, Twitter,
@ -52,6 +59,9 @@ enum Commands {
/// Show statistics /// Show statistics
Stats, Stats,
/// Review pending low-confidence tag suggestions
ReviewTags,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -66,13 +76,14 @@ fn main() -> Result<()> {
Commands::Classify { input, force } => classify_resources(&db, &input, force), Commands::Classify { input, force } => classify_resources(&db, &input, force),
Commands::Export { output } => export_resources(&db, &output), Commands::Export { output } => export_resources(&db, &output),
Commands::Stats => show_stats(&db), Commands::Stats => show_stats(&db),
Commands::ReviewTags => review_tag_suggestions(&db),
} }
} }
fn classify_resources(db: &Database, input: &str, force: bool) -> Result<()> { fn classify_resources(db: &Database, input: &str, force: bool) -> Result<()> {
let contents = fs::read_to_string(input) let contents = fs::read_to_string(input)
.with_context(|| format!("Failed to read input file: {}", input))?; .with_context(|| format!("Failed to read input file: {}", input))?;
let tag_tree = fs::read_to_string("tag-tree").context("Failed to read tag tree file")?; let mut tag_tree = TagTreeFile::load(TAG_TREE_PATH).context("Failed to read tag tree file")?;
for line in contents.lines() { for line in contents.lines() {
let url = line.trim(); let url = line.trim();
@ -114,7 +125,7 @@ fn classify_resources(db: &Database, input: &str, force: bool) -> Result<()> {
let content = format!("Title: Tweet by @{}\nContent: {}", tweet.author, tweet.text); let content = format!("Title: Tweet by @{}\nContent: {}", tweet.author, tweet.text);
let resource_id = db.insert_resource(url, "twitter", &content)?; let resource_id = db.insert_resource(url, "twitter", &content)?;
let result = match classifiers::classify_with_retry(&tag_tree, content, 3) { let result = match classifiers::classify_with_retry(&tag_tree.render(), content, 3) {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
eprintln!("Classification failed for {}: {}", url, e); eprintln!("Classification failed for {}: {}", url, e);
@ -143,6 +154,20 @@ fn classify_resources(db: &Database, input: &str, force: bool) -> Result<()> {
if let Err(e) = db.store_classification(&resource_id, &result) { if let Err(e) = db.store_classification(&resource_id, &result) {
eprintln!("Failed to store classification for {}: {}", url, e); eprintln!("Failed to store classification for {}: {}", url, e);
continue;
}
if let Err(e) = db.replace_tag_suggestions(
&resource_id,
&result.new_tags,
result.suggestion_confidence_score(),
) {
eprintln!("Failed to store tag suggestions for {}: {}", url, e);
continue;
}
if let Err(e) = process_tag_suggestions(db, &mut tag_tree, &resource_id, &result) {
eprintln!("Failed to process tag suggestions for {}: {}", url, e);
} }
} }
Source::Other => { Source::Other => {
@ -181,3 +206,107 @@ fn show_stats(db: &Database) -> Result<()> {
Ok(()) Ok(())
} }
fn process_tag_suggestions(
db: &Database,
tag_tree: &mut TagTreeFile,
resource_id: &str,
result: &classifiers::ClassificationResult,
) -> Result<()> {
if result.new_tags.is_empty() {
return Ok(());
}
if !result.should_auto_apply_suggestions(AUTO_APPLY_SUGGESTION_THRESHOLD) {
println!(
"Queued {} tag suggestion(s) for manual review (`classifier review-tags`).",
result.new_tags.len()
);
return Ok(());
}
let mut tree_changed = false;
for suggestion in &result.new_tags {
let full_path = suggestion.full_path();
let parent_path = suggestion.parent.trim_matches('/');
if !parent_path.is_empty() && !tag_tree.contains_path(parent_path) {
println!(
"Keeping suggestion pending because parent path is missing: {}",
full_path
);
continue;
}
if tag_tree.ensure_path(&full_path)? {
tree_changed = true;
}
db.ensure_tag_exists(&full_path)?;
db.update_tag_suggestion_status(resource_id, &full_path, TagSuggestionStatus::AutoApplied)?;
println!("Auto-applied tag suggestion: {}", full_path);
}
if tree_changed {
tag_tree.save()?;
}
Ok(())
}
fn review_tag_suggestions(db: &Database) -> Result<()> {
let mut tag_tree = TagTreeFile::load(TAG_TREE_PATH).context("Failed to read tag tree file")?;
let pending = db.get_pending_tag_suggestions()?;
if pending.is_empty() {
println!("No pending tag suggestions.");
return Ok(());
}
let mut tree_changed = false;
for suggestion in pending {
if tag_tree.contains_path(&suggestion.full_path) {
db.update_tag_suggestion_status_by_id(suggestion.id, TagSuggestionStatus::Approved)?;
continue;
}
println!("\nPending tag suggestion: {}", suggestion.full_path);
println!("Resource: {}", suggestion.resource_url);
if let Some(confidence) = suggestion.confidence {
println!("Confidence score: {:.2}", confidence);
} else {
println!("Confidence score: n/a");
}
println!("Reason: {}", suggestion.reason);
print!("Approve this suggestion? [y]es/[n]o/[q]uit: ");
io::stdout().flush().context("Failed to flush stdout")?;
let mut response = String::new();
io::stdin()
.read_line(&mut response)
.context("Failed to read approval response")?;
match response.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => {
if tag_tree.ensure_path(&suggestion.full_path)? {
tree_changed = true;
}
db.ensure_tag_exists(&suggestion.full_path)?;
db.update_tag_suggestion_status_by_id(suggestion.id, TagSuggestionStatus::Approved)?;
println!("Approved and added to tag-tree: {}", suggestion.full_path);
}
"n" | "no" => {
db.update_tag_suggestion_status_by_id(suggestion.id, TagSuggestionStatus::Rejected)?;
println!("Rejected suggestion: {}", suggestion.full_path);
}
"q" | "quit" => break,
_ => println!("Leaving suggestion pending: {}", suggestion.full_path),
}
}
if tree_changed {
tag_tree.save()?;
}
Ok(())
}

View file

@ -1,6 +1,6 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::Deserialize; use serde::Deserialize;
use std::{fs, path::PathBuf, process::Command}; use std::{env, fs, path::PathBuf, process::Command};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ScrapedTweet { pub struct ScrapedTweet {
@ -80,10 +80,33 @@ fn unescape_toml_string(value: String) -> String {
} }
pub fn scrape(url: &str) -> Result<PathBuf> { pub fn scrape(url: &str) -> Result<PathBuf> {
let tweet_id = url.split('/').next_back().unwrap(); let tweet_id = extract_tweet_id(url)?;
println!("Scraping tweet ID: {}", tweet_id); println!("Scraping tweet ID: {}", tweet_id);
let out = Command::new("python") let output_path = PathBuf::from("scraped-tweets").join(format!("tweet-{}.toml", tweet_id));
if output_path.exists() {
return Ok(output_path);
}
let python = if PathBuf::from(".venv/bin/python").exists() {
".venv/bin/python"
} else {
"python"
};
let mut command = Command::new(python);
if PathBuf::from(".pydeps").exists() {
let mut python_path = String::from(".pydeps");
if let Ok(existing) = env::var("PYTHONPATH") {
if !existing.is_empty() {
python_path.push(':');
python_path.push_str(&existing);
}
}
command.env("PYTHONPATH", python_path);
}
let out = command
.arg("scrape_user_tweet_contents.py") .arg("scrape_user_tweet_contents.py")
.arg("--tweet-ids") .arg("--tweet-ids")
.arg(tweet_id) .arg(tweet_id)
@ -91,12 +114,27 @@ pub fn scrape(url: &str) -> Result<PathBuf> {
.with_context(|| "Failed to execute tweet scraping command")?; .with_context(|| "Failed to execute tweet scraping command")?;
println!("Output command: {:?}", out); println!("Output command: {:?}", out);
if PathBuf::from("scraped-tweets") if output_path.exists() {
.join(format!("tweet-{}.toml", tweet_id)) return Ok(output_path);
.exists()
{
return Ok(PathBuf::from("scraped-tweets").join(format!("tweet-{}.toml", tweet_id)));
} }
bail!("Scraping failed for tweet: {}", url) bail!("Scraping failed for tweet: {}", url)
} }
fn extract_tweet_id(url: &str) -> Result<&str> {
let trimmed = url.trim_end_matches('/');
let last_segment = trimmed
.rsplit('/')
.next()
.context("Missing tweet ID in URL")?;
let tweet_id = last_segment
.split(['?', '#'])
.next()
.context("Missing tweet ID in URL")?;
if tweet_id.is_empty() {
bail!("Missing tweet ID in URL: {}", url);
}
Ok(tweet_id)
}

183
src/tag_tree.rs Normal file
View file

@ -0,0 +1,183 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub struct TagTreeFile {
path: PathBuf,
lines: Vec<String>,
}
impl TagTreeFile {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read tag tree file: {}", path.display()))?;
let lines = contents.lines().map(ToOwned::to_owned).collect();
Ok(Self { path, lines })
}
pub fn render(&self) -> String {
let mut rendered = self.lines.join("\n");
rendered.push('\n');
rendered
}
pub fn contains_path(&self, full_path: &str) -> bool {
self.indexed_paths().contains_key(full_path)
}
pub fn ensure_path(&mut self, full_path: &str) -> Result<bool> {
let parts: Vec<&str> = full_path.split('/').filter(|part| !part.is_empty()).collect();
if parts.is_empty() {
return Ok(false);
}
let mut changed = false;
for depth in 0..parts.len() {
let path = parts[..=depth].join("/");
if self.contains_path(&path) {
continue;
}
self.insert_path(&path)?;
changed = true;
}
Ok(changed)
}
pub fn save(&self) -> Result<()> {
fs::write(&self.path, self.render())
.with_context(|| format!("Failed to write tag tree file: {}", self.path.display()))
}
fn insert_path(&mut self, full_path: &str) -> Result<()> {
let parts: Vec<&str> = full_path.split('/').filter(|part| !part.is_empty()).collect();
let leaf = parts
.last()
.copied()
.context("Cannot insert empty tag path")?;
let indexed = self.indexed_paths();
let (insert_at, depth) = if parts.len() == 1 {
(self.lines.len(), 0)
} else {
let parent_path = parts[..parts.len() - 1].join("/");
let parent = indexed
.get(&parent_path)
.with_context(|| format!("Missing parent path in tag tree: {}", parent_path))?;
(
subtree_end_index(&self.lines, parent.index, parent.depth),
parent.depth + 1,
)
};
self.lines
.insert(insert_at, format!("{}- {}", " ".repeat(depth), leaf));
Ok(())
}
fn indexed_paths(&self) -> HashMap<String, ParsedPath> {
parse_paths(&self.lines)
}
}
#[derive(Clone, Copy)]
struct ParsedPath {
index: usize,
depth: usize,
}
fn parse_paths(lines: &[String]) -> HashMap<String, ParsedPath> {
let mut stack: Vec<String> = Vec::new();
let mut paths = HashMap::new();
for (index, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
continue;
}
let depth = line.chars().take_while(|ch| *ch == ' ').count() / 2;
let name = line
.trim()
.strip_prefix("- ")
.unwrap_or_else(|| line.trim())
.to_string();
stack.truncate(depth);
stack.push(name);
paths.insert(
stack.join("/"),
ParsedPath {
index,
depth,
},
);
}
paths
}
fn subtree_end_index(lines: &[String], parent_index: usize, parent_depth: usize) -> usize {
for (index, line) in lines.iter().enumerate().skip(parent_index + 1) {
if line.trim().is_empty() {
continue;
}
let depth = line.chars().take_while(|ch| *ch == ' ').count() / 2;
if depth <= parent_depth {
return index;
}
}
lines.len()
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
#[test]
fn test_ensure_path_adds_new_leaf_under_existing_parent() {
let path = temp_path("tag-tree-insert");
fs::write(
&path,
"- cs\n - theory\n - compilers\n - parsing\n",
)
.unwrap();
let mut tree = TagTreeFile::load(&path).unwrap();
let changed = tree.ensure_path("cs/theory/compilers/lexing").unwrap();
assert!(changed);
assert!(tree.render().contains(" - lexing\n"));
assert!(tree.render().contains(" - parsing\n - lexing\n"));
}
#[test]
fn test_ensure_path_is_idempotent() {
let path = temp_path("tag-tree-idempotent");
fs::write(
&path,
"- cs\n - theory\n - compilers\n - parsing\n",
)
.unwrap();
let mut tree = TagTreeFile::load(&path).unwrap();
assert!(!tree.ensure_path("cs/theory/compilers/parsing").unwrap());
}
fn temp_path(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{nanos}.txt"))
}
}

View file

@ -123,6 +123,7 @@
- specialized_crypto - specialized_crypto
- toolchains - toolchains
- type_systems - type_systems
- lexical_analysis
- computation - computation
- complexity - complexity
- quantum - quantum

View file

@ -1,8 +1,6 @@
https://x.com/fleetwood___/status/1987527758558228809 https://x.com/ludwigabap/status/1892500346833936779?s=52
https://x.com/thegeneralist01/status/2017960363099107400 https://x.com/ludwigabap/status/1825272086077530322?s=52
https://x.com/thegeneralist01/status/2007161972442145086 https://x.com/ludwigabap/status/1823653420751827347?s=52
https://x.com/TigerBeetleDB/status/2019013589705916447 https://x.com/ludwigabap/status/1827814296643854814?s=52
https://x.com/TigerBeetleDB/status/2019013589705916447 https://x.com/effectfully/status/1879151558774104343?s=52
https://x.com/mitchellh/status/2020252149117313349 https://x.com/0xmer_/status/1822115435166253562?s=52
https://x.com/amitpr/status/2020263065745519001
https://x.com/claudeai/status/2020207322124132504