selectedleafs POS-Restock — Übergabeprotokoll auswerten & ablegen
Operative Anleitung an dich, den Agenten pos-restock, für den Kern deiner Kette: das Protokoll-PDF in die Sandbox holen (Schritt 1), ein Übergabeprotokoll auswerten (Schritt 2), das PDF komprimieren/umbenennen (Schritt 4) und in Google Drive ablegen (Schritt 5). Du verarbeitest genau ein Protokoll pro Lauf.
Scope: Schritt 1, 2, 4, 5 deiner Kette. Schritt 3 (Posten) und 6 (Status) führst du laut System-Prompt aus. Auch für Schritt 1 gilt das Muster von §5/§6: der System-Prompt triggert (es liegt ein PDF im Topic „Protokoll-Eingang" bereit), dieser Skill liefert nur die Tiefe des Tool-Calls (§1.1) — er dupliziert den System-Prompt nicht und entscheidet nicht über den Auslöser.
Abgrenzung — was NICHT hier steht:
- Restock-Nachrichtenformat + City→Channel-Zuordnung →
selectedleafs-telegram(§5 Templates, §10 Auslöser). Du lieferst nur Stadt + Sorten-Buckets; das Rendern und der Channel laufen dort. - Managed-Agents-/Console-Mechanik (Config, Tools, Deploy) →
global-agent-framework(build-time). - Store-Daten → Shopify MCP (
graphql_query), zweistufig & feldselektiv (§2.5) — nie ein Voll-Dump aller Store-Felder.
Verhältnis zu telegram §10: Dort ist die Protokoll-Verarbeitung halb-manuell mit Review beschrieben. Du läufst vollautomatisch und postest ohne Vorab-Bestätigung — der Mensch-im-Loop ist hier durch den harten Abbruch bei Unklarheit (§3) ersetzt. Bei Mehrdeutigkeit postest du nie öffentlich, sondern stellst eine Rückfrage im Topic „Protokoll-Eingang" und brichst ab.
1. Eingang & Reihenfolge
Du bekommst ein PDF aus dem Topic „Protokoll-Eingang" in die Sandbox (Schritt 1). Dann in dieser Reihenfolge:
- Idempotenz-Check zuerst (§4) — schon verarbeitet? → abbrechen, bevor du irgendetwas postest oder ablegst.
- Parsen (§2) — Store, Stadt, Sorten, neu vs. aufgefüllt.
- Mehrdeutig? (§3) → Rückfrage im Topic, abbrechen. Sonst weiter.
- Posten — Stadt + Sorten-Buckets an den Telegram-Schritt übergeben (Format/Channel →
selectedleafs-telegram). - Write-back (§8) — gepostete neue Sorten (🌿) nach erfolgreichem Post an die
product_listdes Stores anhängen. - Komprimieren + umbenennen (§5), dann in Drive ablegen (§6).
- Status ins Topic (§7).
1.1 PDF in die Sandbox holen (Schritt 1, Referenz-Pfad)
Das Protokoll kommt als Telegram-Dokument im Topic „Protokoll-Eingang" an. Protokolle sind Scans (~0,5–1 MB) — das Inline-base64-Budget des Agenten reicht dafür nicht, download_file (gibt die Bytes als base64 durch den Kontext zurück) ist daher nicht der Weg. Stattdessen der Referenz-Pfad, spiegelbildlich zur Upload-Seite (§6):
- Referenz holen —
create_download_urlmit derfile_iddes PDF-Anhangs (aus dem eingehenden Update,document.file_id— nicht diemessage_id). Der Bot-Token bleibt serverseitig; das Tool lädt die Datei serverseitig von Telegram, legt die Bytes in R2 ab und gibt JSON zurück:{ url, key, file_name, mime_type, size, expires_in }.urlist eine token-freie, kurzlebige presigned GET-URL. - Bytes direkt ziehen — die
urlpercurl -oin die Sandbox holen; die Bytes laufen nicht durch den Agenten-Kontext. Zügig nach dem Call ausführen — die URL läuft nachexpires_inSekunden ab.
# Agent-Sandbox (Code-Execution)
curl -s -o "<eingangs-pdf>" "<url>"
# Danach prüfen: Datei > 0 Byte und valides PDF, bevor du weitermachst (§2).
Voraussetzung (Egress): Der Presign-Host des R2-Buckets — <account-id>.r2.cloudflarestorage.com — muss in den Allowed-Hosts des Environments stehen, sonst schlägt curl mit Netzwerkfehler fehl. Den Host build-time eintragen (Agent-Config, global-agent-framework), nicht hier hardcoden; er steckt im url-Feld der Tool-Antwort.
Fail-closed: Gibt create_download_url keine url zurück (Fehler/Netzwerkproblem) oder liefert curl einen Non-2xx-Status bzw. eine 0-Byte-Datei → Abbruch, Fehler-Status ins Topic (§7), kein Retry und kein base64-Fallback über download_file (würde den Kontext sprengen — genau die Ursache, die der Referenz-Pfad behebt).
Protokolle sind immer unterschriebene Scans (Foto/Schräglage, Knickkanten, kein Text-Layer). Darum ist OCR der Pflichtpfad, kein Fallback — kein vorheriger Textextraktions-Versuch: leichte Vorverarbeitung (Graustufen, bei Bedarf Deskew/Kontrast), dann tesseract mit lang="deu". Die tesseract-Engine ist im Base-Image der Env vorhanden; das deutsche Sprachpaket kommt deklarativ als pip-Paket tessdata.fast-deu (kein apt, kein File-Mount — in der Managed-Agents-Beta wird die apt-Paketzeile nicht provisioniert, deshalb nicht über apt installieren). Den tessdata-Ordner nicht blind aus tessdata.data_path() nehmen — das liefert sys.prefix/share/tessdata und liegt im Container ggf. neben dem echten Ort (/usr/local/share/tessdata); stattdessen den Kandidaten finden, der deu.traineddata enthält, und ihn via --tessdata-dir übergeben:
# Agent-Sandbox: 1) tessdata-Ordner robust bestimmen
import tessdata, os, sys
def tessdata_dir():
for c in (tessdata.data_path(), os.path.join(sys.prefix, "share", "tessdata"),
"/usr/local/share/tessdata", "/usr/share/tessdata"):
if os.path.exists(os.path.join(c, "deu.traineddata")):
return c
raise RuntimeError("deu.traineddata nicht gefunden")
# 2) Seiten EINMAL rendern (pymupdf). tesseract kann PDF NICHT lesen
# ("Pdf reading is not supported") → immer auf gerenderte Bilder, NIE auf die PDF-Datei.
import fitz, glob
OCR_W = 2480 # feste Zielbreite ~300 dpi A4 für zuverlässige OCR
doc = fitz.open("<eingangs-pdf>")
for i, page in enumerate(doc):
zoom = OCR_W / page.rect.width # MediaBox liegt in Scan-pt vor → feste Zielbreite, NICHT dpi=
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), colorspace=fitz.csGRAY)
pix.save(f"/tmp/page_{i:03d}.png") # bleibt auf Platte → §5 nutzt dieselben Seiten, KEIN 2. Render
# 3) OCR auf den gerenderten Seiten (nie tesseract auf die PDF):
import pytesseract
from PIL import Image
ocr_text = "\n".join(
pytesseract.image_to_string(Image.open(p), lang="deu",
config=f'--tessdata-dir "{tessdata_dir()}"')
for p in sorted(glob.glob("/tmp/page_*.png"))
)
Ein Render für beides. Der Schritt oben rendert die Seiten einmal hochauflösend (OCR_W, ~300 dpi) für die Texterkennung und legt sie als /tmp/page_*.png ab. Die Komprimierung (§5) skaliert genau diese PNGs auf die Archivbreite herunter — sie öffnet das PDF nicht erneut und rendert nicht ein zweites Mal. Bewusst OCR_W > Archivbreite: OCR braucht die höhere Auflösung, das Archiv nicht (Downscale ist verlustarm).
Liefert OCR an einer entscheidungsrelevanten Stelle nur unsicheren Text (Store, Protokollnummer, Sorte), behandle das als Mehrdeutigkeit (§3) — rate nichts. Strain-Lesungen werden zusätzlich gegen den 9-Strain-Index gefuzzt (§2.4), damit OCR-Rauschen nicht jede Sorte zur Rückfrage macht.
Bekannte OCR-Artefakte auf diesen Belegen (keine §3-Auslöser, einfach kompensieren):
selectedieafs→selectedleafs(l/i-Vertauschung, Scan-Rauschen — für die Store-Zuordnung irrelevant).»-vor dem Vein: Die Tier-Subzeile beginnt im Scan oft mit€ »-statt€ ·— das Vein-Muster daher nicht auf den Punkt·festnageln, sondern auf das Vein-Wort direkt vorKratommatchen:re.search(r"(White|Green|Red)\s+Kratom", zeile, re.IGNORECASE)— funktioniert unabhängig vom Trennzeichen davor.- Mojibake
\xc2\xbb(=») und\xe2\x82\xac(=€) in raw bytes: beim Vein-Match auf den Unicode-String (nachutf-8-Decode) operieren, nicht auf Byte-Rohdaten. M-CM-<rfürfürin POS-Beschreibungen: nur Werbematerial, kein Strain — schon durch §2.3 (M-Präfix-Regel) abgedeckt.
Parsing-Budget: Extrahiere in einem Block, richte bei Artefakten höchstens einmal die Regex nach (max. ein Inspektion-/Korrektur-Schritt). Starte keine iterative Debug-Schleife über mehrere Turns — bei anhaltender Unlesbarkeit sofort §3 (Rückfrage), statt im Kreis zu debuggen.
2. Protokoll parsen (Schritt 2)
2.1 Store = Empfänger / Kommissionär — nie der Absender
Auf dem Beleg stehen zwei Adressen:
- Absender = selectedleafs.com (eigene Anschrift, oft „Fischerstr. 7 · 30167 Hannover"). Ignorieren.
- Empfänger = der Partner-Store (Kommissionär), z. B. „Spätkauf Hannover, Goseriede 15, 30159 Hannover". Das ist der Store.
Verifiziere über die Unterschriftszeile auf der letzten Seite: der Store steht bei „… als Kommissionär", selectedleafs bei „… als Kommittent". Bei Konflikt zwischen Empfängerblock und Kommissionär-Zeile → Kommissionär-Zeile gewinnt, sonst (uneindeutig) §3.
Achtung: Der Store-Name kann selbst „Hannover" enthalten und der Absender steht ebenfalls in Hannover — beide Hannover-Vorkommen sind irrelevant für die Stadt. Die Stadt kommt ausschließlich aus dem Metaobjekt (§2.5).
2.2 Protokollnummer & Datum
- Protokollnummer: Muster
UL-<zahl>-<zahl>(z. B.UL-10033-1), steht neben „Übergabeprotokoll" auf jeder Seite. Das ist dein Idempotenz-Schlüssel und Teil des Dateinamens (§4/§5). Fehlt/uneindeutig → §3. - Datum: oben auf Seite 1 im Format
TT.MM.JJJJ(z. B.17.06.2026) → normalisiere zuJJJJ-MM-TT(2026-06-17). Das ist das Protokolldatum, nicht das Verarbeitungsdatum.
2.3 Artikel-Tabelle: was als Sorte zählt
Spalten: Pos. | Artikelnummer | Artikelbezeichnung | Menge | Gewicht | MHD. Die Artikelbezeichnung hat zwei Zeilen: eine fette Produktzeile und optional eine Subzeile {Tier} · {Vein} Kratom (Tier = € | €€ | €££, Vein = White | Green | Red).
Entscheidungsregel — eine Position ist nur dann eine verkaufbare Sorte, wenn sie die Tier·Vein-Subzeile hat. Alles ohne diese Subzeile ist kein Strain und wird ignoriert:
- POS-Displays, Theken-Material, Werbemittel (typisch Artikel-Nr. mit
M-Präfix, z. B.M10000-001) → ignorieren, auch wenn der Titel „White Vein Kratom" enthält. - Verkaufbare Sorten haben numerische Strain-SKUs (z. B.
10001-002) plus die Subzeile.
Menge, Gewicht, MHD sind für den Post irrelevant (Verfügbarkeit ist binär — telegram §6). Nur Strain + Vein zählen.
2.4 Strain + Vein extrahieren, Größen dedupen
Je verkaufbarer Position:
- Strain = fette Produktzeile ohne Größen-Suffix (
25g/50g/100g…). „Indo Fusion 25g" → „Indo Fusion". - Vein = das Wort vor „Kratom" in der Subzeile. „€ · White Kratom" → White.
- Dedupe über Größen: dieselbe Strain+Vein-Kombination aus mehreren Positionen (z. B. „Indo Fusion 25g" + „Indo Fusion 50g") kollabiert zu einer Sorte „Indo Fusion (White)".
Fuzzy-Match gegen den kanonischen 9-Strain-Index (telegram §5): Den OCR-Strain nicht wörtlich übernehmen, sondern auf den nächstgelegenen Index-Eintrag mappen (OCR-tolerant, z. B. „lndo Fuslon" → „Indo Fusion", „Borneo Blizz" → „Borneo Bliss"). Liegt der beste Treffer klar über der Ähnlichkeitsschwelle → diesen kanonischen Namen verwenden (nicht den rohen OCR-Text). Bleibt der beste Treffer mehrdeutig (zwei Index-Einträge ähnlich nah) oder unter der Schwelle (kein plausibler Match → mutmaßlich echte Katalog-Neuheit oder unlesbar) → nicht öffentlich raten, sondern §3 (Rückfrage). Der Index selbst (Sortierung/Tier) lebt in telegram — hier nur Auflösung + Plausibilisierung.
2.5 Store → Metaobjekt-Match → Stadt & Channel (Wunstorf-Regel)
- Store → Metaobjekt-Match in zwei Stufen (Name-Match, kein gespeichertes Mapping) — schlank statt Voll-Dump aller Store-Felder:
- 1a Match-Stufe (nur
id+name): Pergraphql_queryalleliftr_store-Metaobjekte mit nur diesen zwei Feldern holen und über die Standard-Seitengröße (50) hinaus paginieren (Cursor /pageInfo.hasNextPage, bis ein eindeutiger Match steht oder alle Seiten durch sind) — sonst sind Stores ab dem 51. unauffindbar und würden fälschlich als „kein Match" (§3) behandelt. Den OCR-Store-Namen OCR-tolerant gegen diese Namensliste matchen (wie bei den Strains §2.4) → genau eine Store-id. Kein serverseitigerdisplay_name-Filter: gegen OCR-Abweichungen zu fragil (er fände bei Tippfehlern nichts → fälschlich §3).query($cursor: String) { metaobjects(type: "liftr_store", first: 50, after: $cursor) { edges { node { id name: field(key: "name") { value } } } pageInfo { hasNextPage endCursor } } } - 1b Detail-Stufe (nur der Treffer, nur die nötigen Felder): Für die gematchte
idgenau die fünf Felder holen, die die Kette braucht —name,postal_code,city→name,district→name,product_list. Die city-Referenz wird im selben Query zum Klartext aufgelöst (kein separater Lookup).product_listliefert Produkt-GIDs (für §2.6).query($id: ID!) { metaobject(id: $id) { name: field(key: "name") { value } postal_code: field(key: "postal_code") { value } city: field(key: "city") { reference { ... on Metaobject { name: field(key: "name") { value } } } } district: field(key: "district") { reference { ... on Metaobject { name: field(key: "name") { value } } } } product_list: field(key: "product_list") { value } } } - Nie
fields { key value }(= alle ~25 Felder inkl. der JSON-Blobsgoogle_place/opening_hoursund derassortment_/service_/testimonial_list-Referenzen) über alle Stores ziehen — genau das ist der Token-Fresser, den diese zwei Stufen ersetzen (schlanke Namensliste + ein Einzelstore statt Voll-Dump).
- 1a Match-Stufe (nur
- Stadt/Channel kommt aus dem Metaobjekt, nicht aus der Belegadresse. Ein Store kann physisch z. B. in Wunstorf liegen, aber redaktionell der Hannover-Page/dem Hannover-Channel zugeordnet sein — dann ist die maßgebliche Stadt Hannover. Lies die zugewiesene Stadt aus dem Store-Metaobjekt (Stadt-/
district-Zuordnung), nicht die Postanschrift. - Die Belegstadt dient nur als Plausi-Check: weicht sie stark von der Metaobjekt-Stadt ab, ist das ein Signal, aber das Metaobjekt gewinnt. Nur wenn kein eindeutiger Store-Match existiert oder dem Store keine Stadt zugeordnet ist → §3.
Dieselbe aufgelöste Stadt benutzt du in §6 für den Drive-Pfad — so bleiben Channel und Ablage konsistent.
2.6 Neu vs. aufgefüllt (pro Sorte, via product_list)
product_list enthält Produkt-GIDs (z. B. gid://shopify/Product/15603432653149), keine Klartext-Strain-Namen. Darum in zwei Schritten:
Schritt A — GIDs zu Titeln auflösen (ein Query für alle):
query($ids: [ID!]!) {
nodes(ids: $ids) {
... on Product { id title }
}
}
ids = JSON-Parse der product_list-Value aus §2.5 1b (String → Array). Das ergibt eine Map { gid → title }. Titel sind Klartext-Strain-Namen ohne Größensuffix (z. B. "Indo Fusion").
Schritt B — Zuordnung pro deduplizierter Sorte (§2.4):
- Strain-Name aus OCR-Extraktion (§2.4, kanonisch) in der Titel-Map enthalten (Fuzzy-tolerant) → aufgefüllt (📦 Restock).
- Nicht enthalten → neue Sorte (🌿).
Die Entscheidung ist pro Sorte: ein Protokoll kann gleichzeitig aufgefüllte und neue Sorten enthalten → zwei Buckets.
Write-back (§8) und Query-Effizienz: nodes(ids:) ist batch-fähig — ein Query für beliebig viele GIDs, keine N-Aliase. Den Query nicht aufsplitten (kein product1: product(id:), product2: product(id:), …).
2.7 Was dieser Skill liefert (Übergabe-Payload)
Du übergibst an den Telegram-Schritt (Format/Channel → selectedleafs-telegram §5/§10) strukturierte Daten, keinen fertigen Text:
{
"stadt": "<aufgelöste Metaobjekt-Stadt>", // bestimmt den Channel (dort)
"store_ref": "<liftr_store-Metaobjekt + Name>",
"protokoll_nr": "UL-10033-1",
"aufgefuellt": [ {"strain": "...", "vein": "White|Green|Red"}, ... ], // 📦
"neue_sorten": [ {"strain": "...", "vein": "White|Green|Red"}, ... ] // 🌿
}
Leere Buckets sind erlaubt. Sind beide Buckets leer (nur Werbemittel/POS im Protokoll), gibt es nichts zu posten — kein Fehler, kein Abbruch: du überspringst Schritt 3 und legst das PDF trotzdem ab (§5/§6), Status „keine Sorten, nichts gepostet" (§7).
3. Mehrdeutigkeit → Abbruch (verbindlich)
Ist Store oder Stadt nicht eindeutig bestimmbar, postest du NICHTS öffentlich. Stattdessen: kurze Rückfrage ins Topic „Protokoll-Eingang" + Kette abbrechen (kein Post, keine Drive-Ablage). Auslöser:
- Store-Name matcht null oder mehrere
liftr_store-Metaobjekte. - Gematchter Store hat keine zugewiesene Stadt/Channel.
- Protokollnummer fehlt oder ist unlesbar (ohne sie keine sichere Idempotenz).
- Strain lässt sich nicht zuverlässig lesen / matcht keinen bekannten Index-Eintrag (mutmaßlicher OCR-Fehler).
- Empfänger/Kommissionär-Block unlesbar.
Rückfrage knapp und konkret halten, z. B.: „Protokoll UL-10033-1: Store ‚Spätkauf Hannover' matcht 2 Metaobjekte (Goseriede / Limmerstr.). Welcher? Verarbeitung pausiert." Erst nach Klärung erneut anstoßen.
4. Idempotenz — jedes Protokoll genau einmal
Schlüssel = Protokollnummer (UL-10033-1). Da der Dateiname in Drive die Nummer enthält (§5), ist der Idempotenz-Check ein Existenz-Check im Ziel-Ordner:
- Ziel-Ordner in Drive bestimmen —
ensure_folder_path(§6) liefert die Zielordner-id(idempotent; legt den Stadt/Store-Pfad bei Bedarf an, ohne zu duplizieren). - In diesem Ordner per
list_filesprüfen, ob bereits eine Datei mit Endung_<protokoll_nr>.pdfliegt. - Existiert sie → abbrechen, nicht erneut posten/ablegen. Status ins Topic: „Protokoll
UL-10033-1bereits verarbeitet, übersprungen."
Diesen Check vor dem Posten ausführen (§1), damit ein doppelt eingereichtes Protokoll keinen Doppel-Post erzeugt. Kein externer State/keine DB nötig — der Drive-Ordner ist die Quelle der Wahrheit (web-only-tauglich).
5. Komprimieren + Umbenennen (Schritt 4)
Im Agent-Sandbox (Code-Execution, nicht lokal, kein VPS).
Zieldateiname: Der Ordnerpfad (§6) trägt bereits Stadt + Store — darum bleibt der Dateiname kurz: Datum (Sortierung im Store-Ordner) + Protokollnummer (eindeutige ID / Idempotenz-Schlüssel):
{JJJJ-MM-TT}_{protokoll_nr}.pdf
- Beide Teile sind deterministisch aus dem Beleg ableitbar (§2.2) → derselbe Beleg ergibt immer denselben Namen.
- Datum als
JJJJ-MM-TTzuerst, weil das verlässlich chronologisch sortiert (die reine UL-Nummer sortiert lexikalisch falsch an Stellen-Grenzen).
Beispiel: 2026-06-17_UL-10033-1.pdf
Komprimierung — die Seiten sind in §1.1 schon gerendert (/tmp/page_*.png, Graustufe, OCR_W breit). Hier nur herunterskalieren, kein zweiter Render:
# Agent-Sandbox (Code-Execution) — Archiv aus den §1.1-Seiten, KEIN zweites fitz.open()/Rendern
import fitz, glob, io
from PIL import Image
ARCH_W, Q = 1240, 75 # ~150 dpi A4; Fallback 1500/q80
out = fitz.open(); A4 = fitz.paper_rect("a4")
for p in sorted(glob.glob("/tmp/page_*.png")): # die in §1.1 gerenderten OCR-Seiten
img = Image.open(p) # bereits Graustufe, OCR_W breit
img = img.resize((ARCH_W, round(img.height * ARCH_W / img.width)))
buf = io.BytesIO(); img.save(buf, format="JPEG", quality=Q)
npage = out.new_page(width=A4.width, height=A4.height)
npage.insert_image(npage.rect, stream=buf.getvalue())
out.save("2026-06-17_UL-10033-1.pdf", deflate=True, garbage=4)
- Render-Sharing: §1.1 rendert das PDF einmal hochauflösend (
OCR_W) für die OCR; hier werden exakt diese PNGs aufARCH_W(1240 px) herunterskaliert. Das spart das zweite Öffnen/Rendern des PDFs — der ursprünglich teuerste, doppelt ausgeführte Schritt. Sind die PNGs (wider Erwarten) nicht vorhanden, fällt der Schritt auffitz.open("<eingangs-pdf>")+ Render beiARCH_Wzurück. - 1240 px / Graustufe / JPEG q75 drückt einen 5-MB-Scan auf
0,3 MB und bleibt OCR-sicher (am realen Beleg verifiziert, Kerndaten + Umlaute). Nur wenn das Ergebnis unscharf würde, auf 1500 px / q80 (0,5 MB) hochgehen. - Da Protokolle immer Scans sind, ist
pymupdfder einzige Pfad —pikepdfrührt die eingebetteten Flate-Bilder nicht an (0 % Kompression) und ist hier nutzlos.pymupdfrendert PDF→Bild selbst, also wird kein poppler/pdf2imagegebraucht.
Verifiziere nach der Komprimierung, dass die Datei > 0 Byte und valide ist, bevor du sie hochlädst.
6. Drive-Ablage (Schritt 5)
Voraussetzung (Egress): Die komprimierte PDF wird per PUT direkt aus der Sandbox zur Google-Upload-URL geschickt — die Bytes laufen nicht durch den Agenten-Kontext. Dafür muss der Upload-Host (typisch www.googleapis.com) in der Egress-Allowed-Hosts-Liste des Environments eingetragen sein. Fehlt er → Upload schlägt mit Netzwerkfehler fehl → Abbruch+Status (§7), kein Retry. Den genauen Host einmalig aus einer Test-Session ablesen (URL kommt im Rückgabe-Feld uploadUrl) und build-time ins Environment eintragen — hier nicht hardcoden.
Ordnerstruktur (B3):
<Übergabeprotokolle-Wurzel>/{city.name}/{postal_code} {store.name}/
- Alle Segmente kommen 1:1 aus den
liftr_store-Metaobjekt-Feldern des gematchten Stores (§2.5), nie aus dem rohen Belegtext oder OCR — das hält die Namen stabil und damit die Idempotenz (§4) intakt:{city.name}=name-Feld des verknüpften city-Metaobjekts (Klartext, z. B.Hannover) — nicht die Belegstadt, nicht lowercase. Lies dieses Feld aktiv aus dem Metaobjekt, rate es nicht aus der Adresse.{postal_code}=postal_code-Feld des Stores (z. B.30159), als Präfix zur Sortierung und Disambiguierung gleichnamiger Stores.{store.name}=name-Feld des Stores (Klartext mit Umlauten/Leerzeichen, z. B.Spätkauf Hannover).
- Keine Slugifizierung. Großschreibung, Leerzeichen und Umlaute (
ä ö ü ß) sind in Drive zulässig und gewollt (lesbare Ordner). Die einzige Anforderung an den Namen ist Determinismus aus stabiler Quelle (Metaobjekt-Feld) — die ist erfüllt; Groß/Klein und Umlaut sind für die Idempotenz irrelevant. - Ordnerpfad idempotent auflösen/anlegen — ein Call. Statt
list_files+create_foldervon Hand zu kombinieren:ensure_folder_pathmitparentFolderId= Übergabeprotokolle-Wurzel undsegments = [ "{city.name}", "{postal_code} {store.name}" ]. Das Tool legt fehlende Segmente an und nutzt vorhandene wieder — mkdir-p, idempotent: nie ein zweiter gleichnamiger Ordner. Der Vergleich ist serverseitig Unicode-NFC-normalisiert (ä/ö/ü/ß sicher), exakt und case-sensitiv; gleichnamige Dateien werden ignoriert. Du musst Namen daher nicht mehr selbst NFC-normalisieren und keinen Existenz-Check vorschalten — das übernimmt das Tool. Rückgabe:{ id, name, webViewLink?, created[] }.idist der Zielordner (für die PDF-Ablage und den Idempotenz-Check §4);createdlistet die in diesem Lauf neu angelegten Segmente (leer = Pfad existierte bereits, z. B. beim Zweitlauf). - Die Wurzel/Folder-ID ist Agent-Config (build-time,
global-agent-framework) — nicht hier hardcoden.
Upload-Ablauf (zweistufig, Referenz-Pfad):
Protokoll-PDFs sind Scans (~500 KB–1 MB nach Komprimierung). Das überschreitet das Inline-base64-Budget des Agenten → upload_file ist nicht verwendbar; stattdessen:
- Komprimierung abschließen (§5), dann erst Session holen. Die Upload-Session (
create_upload_session) ist an die bei der Eröffnung deklariertesizeBytesgebunden — ändert sich die Dateigröße danach (z. B. durch nochmalige Komprimierung), ist die Session ungültig und jeder PUT liefert HTTP 400. Daher: erst komprimieren, Endgröße feststellen, dann Session eröffnen — nie umgekehrt. - Session holen —
create_upload_sessionmitname,mimeType: "application/pdf",sizeBytes(exakte Byte-Zahl der finalen PDF, z. B. viaos.path.getsize()) undparentFolderId(Zielordner-ID). Gibt{ uploadUrl, name, parentFolderId }zurück. - Bytes direkt hochladen — HTTP
PUTaus der Sandbox an dieuploadUrl, Datei-Bytes als Body. Die Session-URL trägt ihre eigene Autorisierung; kein zusätzliches Google-Credential nötig. Erwarteter Status: 200 oder 201. HTTP 308 (Resume Incomplete) ist kein Fehler, aber bei vollständigem Single-PUT-Upload nicht zu erwarten.
Häufige Fehlerursache HTTP 400: Fast immer ein sizeBytes-Mismatch — die deklarierte Größe stimmt nicht mit der tatsächlichen Datei überein, weil die Datei nach der Session-Eröffnung noch verändert wurde. Lösung: neue Session mit korrekter sizeBytes (nicht dieselbe Session erneut verwenden). Die 256-KB-Chunk-Grenze (262.144 Bytes) ist kein hartes Limit für den Datei-Upload — sie betrifft nur die Chunk-Größe bei resumable Multi-Part-Uploads, nicht die Gesamtgröße der Datei.
# Agent-Sandbox (Code-Execution)
curl -s -o /dev/null -w "%{http_code}" \
-X PUT \
--data-binary @"<komprimierte-pdf>" \
"<uploadUrl>"
# Erwarteter HTTP-Status: 200 oder 201 → Erfolg
# Jeder andere Status → Abbruch + Fehler-Status ins Topic (§7)
Fail-closed: Gibt create_upload_session keinen uploadUrl zurück (Fehler, Netzwerkproblem), oder liefert der PUT einen Non-2xx-Status → Abbruch, keine Drive-Ablage, Fehler-Status ins Topic. Kein stiller Weiter-Lauf ohne abgelegte PDF.
Beispiel-Ablage (Ergebnis):
…/Übergabeprotokolle/Hannover/30159 Spätkauf Hannover/2026-06-17_UL-10033-1.pdf
7. Status-Rückmeldung ins Topic (Schritt 6, kompakt)
Genau eine knappe Status-Zeile ins Topic „Protokoll-Eingang", je nach Ausgang:
| Ausgang | Status (Beispiel) |
|---|---|
| Erfolg | „✅ UL-10033-1 — Spätkauf Hannover (Hannover): 1 aufgefüllt, 0 neu. Gepostet + in Drive abgelegt." |
| Nichts zu posten | „✅ UL-10033-1 — nur Werbemittel/POS, keine Sorten. Kein Post, PDF abgelegt." |
| Bereits verarbeitet | „↩︎ UL-10033-1 bereits verarbeitet, übersprungen." |
| Mehrdeutig (§3) | „⚠️ UL-10033-1 — <konkrete Rückfrage>. Verarbeitung pausiert." |
Keine Sorten-/Mengen-Details öffentlich in den City-Channel schreiben, die nicht aus dem Telegram-Format (§5) stammen — der Status bleibt im Topic.
Wurden neue Sorten gepostet, den Write-back (§8) in derselben Zeile kurz quittieren, z. B.: „✅ UL-10040-1 — Kiosk Linden (Hannover): 0 aufgefüllt, 1 neu (Sortiment ergänzt). Gepostet + in Drive abgelegt."
8. Write-back: neue Sorten in product_list (nach dem Post)
Läuft als Schritt 5 in §1, direkt nach dem erfolgreichen 🌿-Post (Schritt 4) und betrifft nur den neue_sorten-Bucket. Zweck: Eine erstmals gelieferte Sorte wird ins Sortiment des Stores aufgenommen, damit dieselbe Sorte beim nächsten Protokoll korrekt als 📦 (aufgefüllt) statt erneut als 🌿 erkannt wird (§2.6). Ohne Write-back bliebe jede neue Sorte dauerhaft „neu".
Regeln:
- Nur anhängen, nie entfernen. Du fügst jede tatsächlich gepostete neue Sorte zur
product_listdes gematchten Store-Metaobjekts hinzu. Das Auslisten ausverkaufter Sorten macht der Mensch manuell — der Agent löscht nie ausproduct_list. - Idempotent, kein Clobbern. Vor dem Anhängen prüfen, ob die Sorte schon enthalten ist (sollte sie nicht, sonst wäre sie 📦 gewesen); bestehende Einträge nie überschreiben, nur ergänzen.
- Repräsentation deckungsgleich mit der Leseseite (§2.6). In genau der Form anhängen, in der
product_listSorten führt, damit Lese- (§2.6) und Schreibseite konsistent bleiben — sonst zählt dieselbe Sorte beim nächsten Lauf wieder als neu. - Mutation-Signatur:
valueistString!, nichtJSON!. Das Metaobject-Feldproduct_listträgt einen JSON-codierten String — das GraphQL-Schema akzeptiert den Wert alsString!-Literal (kein$value: JSON!-Variable). Immer den serialisierten JSON-Array direkt inline übergeben:
Eine Variablemutation($id: ID!) { metaobjectUpdate(id: $id, metaobject: { fields: [{ key: "product_list", value: "["gid://shopify/Product/111","gid://shopify/Product/222"]" }] }) { metaobject { id product_list: field(key: "product_list") { value } } userErrors { field message } } }$value: JSON!erzeugt einen Type-Mismatch-Error. Den neuen GID perproducts(first:5, query:"title:\"<strain-name>\"")ermitteln;first:nicht weglassen (sonst"first or last must be provided"-Error). - Neue Session bei Größenänderung (→ §6). Dieser Hinweis gilt nicht direkt für den Write-back, sondern ist in §6 verankert — der Vollständigkeit halber: Write-back läuft vor dem Upload; die Upload-Session wird erst danach eröffnet, wenn die finale PDF-Größe feststeht.
- Nur bei erfolgreichem Post. Schlägt der 🌿-Post fehl, kein Write-back — sonst meldete
product_listeine Sorte als geführt, die nie announced wurde. - Tool / Permission ist build-time. Der Write-back ist eine Mutation aufs Metaobjekt; das read-only
graphql_queryreicht dafür nicht. Tool-Oberfläche, Least-Privilege und Bestätigungs-Policy regeltglobal-agent-framework— dieser Skill beschreibt nur das Verhalten, nicht die Tool-Mechanik.
Bewusste Folge fürs Typing: Nimmt der Mensch eine ausverkaufte Sorte später aus product_list und sie wird erneut geliefert, postet der Agent sie wieder als 🌿 „Neue Sorte" (nicht 📦). Das ist laut telegram-SSOT korrekt (Trigger = Membership in product_list) und braucht keinen „kennen-wir-schon"-Sonderfall. Wer das Framing später glätten will, müsste eine separate Historie führen — bewusst nicht Teil dieser Kette.
Changelog
| Datum | Änderung |
|---|---|
| 2026-06-29 | v1.8.0 — Drei Patches aus Run-Analyse (sesn_01XwoJ). §1.1 + §2.4 (OCR-Robustheit): Bekannte Artefakte dieser Belege dokumentiert (»- statt · vor Vein, Mojibake \xc2\xbb/\xe2\x82\xac, l/i-Vertauschung bei selectedleafs) mit konkretem Vein-Regex-Muster (`re.search(r"(White |
| 2026-06-29 | v1.7.0 — Token-Hebel im Store-Lookup (§2.5). Der Match lief vorher als Voll-Dump (listMetaobjects(type:"liftr_store") über alle Stores mit allen ~25 Feldern, inkl. der JSON-Blobs google_place/opening_hours und der assortment_/service_/testimonial_list-Referenzen) → ~22k Zeichen pro Lauf. Jetzt zweistufig über graphql_query: 1a alle Stores mit nur id + name (paginiert), OCR-toleranter Client-Match → eine id; 1b metaobject(id:) nur für den Treffer mit genau name, postal_code, city→name, district→name, product_list — city-Referenz im selben Query aufgelöst (kein separater Lookup). Kein serverseitiger display_name-Filter (gegen OCR-Abweichung zu fragil → fälschlich §3). Queries am realen Shop verifiziert. Abgrenzungszeile (§Kopf) von listMetaobjects auf den feldselektiven Ansatz gezogen. Match-Robustheit und Wunstorf-Regel (§2.5.2/3) unverändert. |
| 2026-06-29 | v1.6.0 — Render-Sharing zwischen OCR (§1.1) und Komprimierung (§5). §1.1: Der OCR-Schritt rendert die Seiten jetzt explizit mit pymupdf zu Graustufen-PNGs (/tmp/page_*.png, feste Breite OCR_W ~300 dpi) und gibt diese an tesseract — vorher war der Render-Schritt nicht ausformuliert (nur der tessdata_dir()-Finder + ein nebulöses img), wodurch der Agent tesseract auf die PDF-Datei selbst ansetzte und mit „Pdf reading is not supported" abbrach. Jetzt explizit: nie tesseract auf die PDF, immer auf die gerenderten Bilder. §5: Komprimierung öffnet das PDF nicht mehr ein zweites Mal, sondern skaliert die in §1.1 erzeugten PNGs auf ARCH_W (1240 px) herunter → ein Render statt zwei (der bisher teuerste, doppelt ausgeführte Schritt entfällt); Fallback auf eigenes fitz.open+Render, falls die PNGs fehlen. OCR_W (OCR) bewusst > ARCH_W (Archiv), Downscale ist verlustarm. Kompressionsparameter (Graustufe, q75, Fallback 1500/q80) und feste-Zielbreite-statt-dpi= unverändert. |
| 2026-06-29 | v1.5.0 — Auf die neuen MCP-Tools umgestellt. §1.1 (neu) Download auf Referenz-Pfad: Inline-base64 download_file → create_download_url (lädt serverseitig von Telegram nach R2, Bot-Token bleibt am Worker, gibt eine token-freie presigned GET-URL zurück; per curl -o in die Sandbox, läuft nicht durch den Agenten-Kontext). Egress-Voraussetzung ergänzt (<account-id>.r2.cloudflarestorage.com in Allowed-Hosts, build-time). Fail-closed bei fehlender url/Non-2xx, kein base64-Fallback. Scope zieht Schritt 1 in die „Tiefe" (System-Prompt triggert, Skill liefert den Tool-Call) — Spiegel zur Upload-Seite §6. §6 Drive-Ablage: manuelle list_files+create_folder-Ordnerschleife durch einen ensure_folder_path-Call ersetzt (mkdir-p für {city.name}/{postal_code} {store.name}); NFC-Normalisierung + Dedup laufen jetzt serverseitig im Tool → der manuelle NFC-Wiederfind-Hinweis entfällt, Rückgabe-id ist der Zielordner. §4: Ordner-Bestimmung zeigt auf ensure_folder_path; der list_files-Existenz-Check fürs _<protokoll_nr>.pdf (Idempotenz) bleibt. Tool-Kontrakte aus telegram-mcp 4.2.1 + google-drive-mcp main übernommen (nicht geraten). |
| 2026-06-27 | v1.4.0 — Kompression von Ghostscript auf pymupdf (reines pip-Wheel) umgestellt (§5): in der Managed-Agents-Beta wird die deklarierte apt-Paketzeile nicht provisioniert (pip greift, apt nicht — in zwei Envs reproduziert), darum gs/apt komplett raus. pymupdf rendert PDF→Bild selbst (kein poppler/pdf2image); feste Zielbreite 1240 px / Graustufe / JPEG q75 statt dpi= (MediaBox liegt in Scan-pt vor → dpi= skaliert ins Leere), ~5,3 MB → ~0,3 MB, OCR am realen Beleg verifiziert (Kerndaten + Umlaute); Fallback 1500/q80. pikepdf verworfen (rührt Flate-Scan-Bilder nicht an, 0 %). §1: deutsches Sprachpaket nicht mehr „apt-vorinstalliert", sondern pip-Paket tessdata.fast-deu; tessdata-Pfad über Fallback-Finder + --tessdata-dir, weil tessdata.data_path() (=sys.prefix/share/tessdata) im Container neben dem echten Ort (/usr/local/share/tessdata) liegen kann. tesseract-Engine bleibt Base-Image. |
| 2026-06-27 | v1.3.0 — §6 Drive-Ordnernamen von slugifiziert auf Klartext aus Metaobjekt-Feldern umgestellt: {stadt}/{store-slug} → {city.name}/{postal_code} {store.name} (z. B. Hannover/30159 Spätkauf Hannover). Slugifizierung (lowercase, Umlaut-Faltung, --Ersetzung) entfällt — Großschreibung, Leerzeichen und Umlaute sind in Drive zulässig; einzige Anforderung bleibt Determinismus aus stabiler Quelle (Metaobjekt-name/postal_code, nie OCR/Belegtext). NFC-Normalisierung beim Ordner-Wiederfinden ergänzt; expliziter Hinweis, dass create_folder nicht dedupliziert (vorher list_files). Idempotenz (§4, Schlüssel = Protokollnummer) und Dateiname (§5) unberührt. |
| 2026-06-26 | v1.2.0 — §6 Drive-Ablage auf Referenz-Upload umgestellt: statt Inline-base64 (upload_file) jetzt zweistufig via create_upload_session (Session-URL holen) + curl PUT (Bytes direkt aus Sandbox zu Google, läuft nicht durch Agenten-Kontext). Egress-Voraussetzung ergänzt (Upload-Host muss in Allowed-Hosts des Environments, einmalig via Test-Session ablesen). Fail-closed bei Non-2xx oder fehlendem uploadUrl. Hintergrund: ~916 KB PDF ergibt ~1,2M Zeichen base64 — sprengt das Argument-Budget des Agenten; war Root Cause des Kettenabbruchs. |
| 2026-06-25 | Rename selectedleafs-pos-documentation → selectedleafs-pos-restock (topic-scoped, näher am Restock-Zweck). Inhaltlich unverändert ggü. v1.1.0. Frontmatter-Name + H1 angepasst, keine sonstigen Selbstreferenzen. Achtung: erzeugt ein neues Skill — alte Installation manuell entfernen, Agent neu attachen. Cross-Verweise in anderen Skills (selectedleafs-telegram, global-agent-framework) ggf. nachziehen. |
| 2026-06-25 | v1.1.0 — Write-back ergänzt (neue §8): nach erfolgreichem 🌿-Post wird die neue Sorte an product_list des Stores angehängt (append-only, idempotent, Remove bleibt manuell); §1-Reihenfolge um den Write-back-Schritt erweitert, §7-Status quittiert ihn. OCR von Fallback auf Pflichtpfad umgestellt (Protokolle sind immer unterschriebene Scans → tesseract -l deu + leichte Vorverarbeitung, vorinstalliert, nicht prüfen/installieren); Textextraktions-Vorstufe und pikepdf-Digital-Zweig (§5) entfernt. Strain-Auflösung von exaktem Index-Vergleich auf OCR-toleranten Fuzzy-Match gegen den 9-Strain-Index umgestellt (§2.4), nur unauflösbar/mehrdeutig → §3. liftr_store-Match paginiert jetzt über 50 Stores hinaus (Cursor, §2.5). |
| 2026-06-24 | v1.0.0 — Initial. Parsing (Store=Kommissionär, Protokollnr UL-…, Sorte nur mit Tier·Vein-Subzeile, Größen-Dedupe), Stadt aus Metaobjekt (Wunstorf-Regel), neu vs. aufgefüllt via product_list, Übergabe-Payload (Buckets). Vollautomatisch, Abbruch+Rückfrage bei Mehrdeutigkeit. Idempotenz via Protokollnr im Dateinamen (Drive-Existenz-Check). Naming = Datum + Protokollnr (Stadt/Store stecken im B3-Pfad, nicht doppelt im Namen), Drive B3 ({stadt}/{store-slug}). Komprimierung Ghostscript /ebook (Scan) bzw. pikepdf (digital). Format/Channel → selectedleafs-telegram. |