## Dify Workflow DSL for "DKS Spellcheck"
## Tested against Dify 1.10.1 (DSL version 0.5.0).
##
## Placeholders that init_dify.sh substitutes before /apps/imports:
##   __WP_BASIC_AUTH__   = admin:<WP_APP_PASSWORD>
##   __DKS_API_TOKEN__   = <DKS_API_TOKEN>   (from api_token table)
##   __WP_INTERNAL_URL__ = http://wordpress-60
##   __DKS_MCP_URL__     = http://dks:80/mcp
##
## Pipeline (zwei parallele Branches: Titel + Inhalt, am Ende zusammengefuehrt):
##   start(post_id, post_type)
##     -> derive_endpoints     (post_type -> wp_collection 'posts'/'pages')
##     -> fetch_post           (GET WP REST raw post/page)
##     -> strip_html           (plain text for DKS, keeps raw_html + title)
##     -> [Titel-Branch]
##         -> dks_check_title          (level=2: Rechtschr.+Gramm., kein Stil)
##         -> extract_findings_title
##         -> llm_apply_title          (plain text, keine Markup-Sorgen)
##     -> [Inhalt-Branch]
##         -> dks_check_content        (level=3: + Stil)
##         -> extract_findings_content
##         -> llm_apply_content        (raw HTML, Markup wird erhalten)
##     -> build_payloads               (kombiniert beide LLM-Outputs)
##     -> create_autosave              (POST /wp/v2/{type}/{id}/autosaves)
##     -> add_comment                  (Editor-Kommentar mit Befunden)
##     -> end
##
## Das LLM-Modell ist auf beiden llm_apply_* Nodes gesetzt. Nach dem Import unter
## Dify -> Settings -> Model Provider das OpenAI-Plugin installieren und API-Key
## hinterlegen. Default-Provider "langgenius/openai/openai", Modell "gpt-4o" --
## anderen Provider/Modell pro Node im UI waehlbar.
app:
  description: Triggered by WordPress publish, runs Duden Korrektor (DKS) via MCP and writes corrections back as a draft.
  icon: "\U0001F4DD"
  icon_background: "#FFEAD5"
  icon_type: emoji
  mode: workflow
  name: DKS Spellcheck
kind: app
version: 0.5.0
workflow:
  features: {}
  environment_variables: []
  conversation_variables: []
  graph:
    nodes:
      - id: start
        position: {x: 0, y: 0}
        data:
          type: start
          title: Start
          desc: "Receives post_id + post_type from the WordPress mu-plugin"
          variables:
            - variable: post_id
              label: "Post ID"
              type: number
              required: true
            - variable: post_type
              label: "Post Type"
              type: text-input
              required: false
              default: "post"
            - variable: site_url
              label: "Site URL (override)"
              type: text-input
              required: false
              default: "http://wordpress-60"

      - id: derive_endpoints
        position: {x: 125, y: 0}
        data:
          type: code
          title: Derive REST Collection
          desc: "Mappt post_type (post|page) auf REST-Collection-Plural (posts|pages)"
          code_language: python3
          code: |
            def main(post_type: str) -> dict:
                ## fallback auf 'posts' falls leer/unbekannt
                pt = (post_type or "post").strip().lower()
                mapping = {"post": "posts", "page": "pages"}
                collection = mapping.get(pt, pt + "s")
                return {"wp_collection": collection}
          variables:
            - variable: post_type
              value_selector: [start, post_type]
          outputs:
            wp_collection:
              type: string

      - id: fetch_post
        position: {x: 250, y: 0}
        data:
          type: http-request
          title: Fetch Post/Page
          desc: "GET WordPress post/page with raw content"
          method: get
          url: "__WP_INTERNAL_URL__/wp-json/wp/v2/{{#derive_endpoints.wp_collection#}}/{{#start.post_id#}}?context=edit"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: ""
          params: ""
          body:
            type: none
            data: []

      - id: strip_html
        position: {x: 500, y: 0}
        data:
          type: code
          title: Strip HTML
          desc: "Parse fetch_post.body JSON and reduce post content.raw to plain text"
          code_language: python3
          code: |
            import json, re, html
            def main(post_body: str) -> dict:
                try:    post = json.loads(post_body or "{}")
                except: post = {}
                raw_html = (post.get("content") or {}).get("raw") or ""
                title    = (post.get("title")   or {}).get("raw") or ""
                txt = re.sub(r"<[^>]+>", " ", raw_html)
                txt = html.unescape(re.sub(r"\s+", " ", txt)).strip()
                return {"plain": txt, "raw_html": raw_html, "title": title}
          variables:
            - variable: post_body
              value_selector: [fetch_post, body]
          outputs:
            plain:
              type: string
            raw_html:
              type: string
            title:
              type: string

      - id: dks_check_title
        position: {x: 750, y: -180}
        data:
          type: http-request
          title: DKS MCP check_text (Titel)
          desc: "level=2 -- nur Rechtschreibung+Grammatik, kein Stil (kein 'fehlender Punkt am Ende' Geraffel)"
          method: post
          url: "__DKS_MCP_URL__"
          authorization:
            type: api-key
            config:
              type: bearer
              api_key: "__DKS_API_TOKEN__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: json
            data:
              - key: ""
                type: text
                value: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_text","arguments":{"text":"{{#strip_html.title#}}","language":"de-DE","level":2}}}'

      - id: extract_findings_title
        position: {x: 1000, y: -180}
        data:
          type: code
          title: Extract Findings (Titel)
          code_language: python3
          code: |
            import json
            def main(mcp_body) -> dict:
                if isinstance(mcp_body, str):
                    try:    mcp_body = json.loads(mcp_body)
                    except: mcp_body = {}
                if not isinstance(mcp_body, dict):
                    mcp_body = {}
                struct = (mcp_body.get("result") or {}).get("structuredContent") or {}
                errors = struct.get("errors", []) or []
                findings, md_lines = [], []
                for f in errors:
                    snippet = f.get("snippet", "")
                    if not snippet:
                        continue
                    findings.append({
                        "snippet":     snippet,
                        "suggestions": (f.get("suggestions") or [])[:8],
                        "type":        f.get("type", ""),
                        "errorCode":   f.get("errorCode"),
                        "message":     f.get("message", "")     or "",
                        "longMessage": f.get("longMessage", "") or "",
                        "examples":    (f.get("examples") or [])[:3],
                        "group":       f.get("group", "")    or "",
                        "category":    f.get("category", "") or "",
                    })
                    sugs = findings[-1]["suggestions"]
                    rhs   = ("[" + " | ".join(sugs) + "]") if sugs else "(kein Vorschlag)"
                    extra = " (" + findings[-1]["message"] + ")" if findings[-1]["message"] else ""
                    md_lines.append("- [Titel/" + (findings[-1]["type"] or "?") + "] '" + snippet + "' -> " + rhs + extra)
                return {
                    "findings_json": json.dumps(findings, ensure_ascii=False),
                    "findings_md":   "\n".join(md_lines),
                    "count":         len(findings),
                }
          variables:
            - variable: mcp_body
              value_selector: [dks_check_title, body]
          outputs:
            findings_json: {type: string}
            findings_md:   {type: string}
            count:         {type: number}

      - id: llm_apply_title
        position: {x: 1250, y: -180}
        data:
          type: llm
          title: LLM Apply Corrections (Titel)
          desc: "Plain-Text Korrektur, kein HTML"
          model:
            provider: langgenius/openai/openai
            name: gpt-4o
            mode: chat
            completion_params:
              temperature: 0
              max_tokens: 512
          prompt_template:
            - role: system
              text: |
                Du erhaeltst einen kurzen Artikel-Titel und eine Liste von DKS-Korrekturbefunden.
                Jeder Befund enthaelt -- soweit verfuegbar -- die Felder:
                  - snippet     : die markierte Textstelle
                  - type        : "orth" | "gram" | "style" | ...
                  - errorCode   : numerischer DKS-Regel-Code
                  - message     : Kurztext der Regel (z.B. "Dieses Wort korrigieren?")
                  - longMessage : ausfuehrliche Beschreibung der Regel
                  - examples    : Liste von { "false": ..., "correct": ... } Beispielen
                                  derselben Regel -- DIE wichtigste Hilfe, wenn
                                  suggestions leer ist.
                  - suggestions : ggf. von DKS vorgeschlagene Korrekturen
                Wende AUSSCHLIESSLICH die Befunde an. Pro Befund gilt:
                  a) suggestions gefuellt -> waehle den grammatikalisch und semantisch
                     passendsten Vorschlag (Numerus, Genus, Kasus, Artikel, Verbkongruenz).
                  b) suggestions leer     -> orientiere dich an longMessage und examples.
                     Uebertrage das Muster aus den Beispielen ANALOG auf den vorliegenden
                     Titel (gleiche Regel, anderer konkreter Text).
                     Bist du dir trotz Regelinfo + Beispielen nicht sicher, lass den
                     Befund unveraendert.
                Antworte ausschliesslich mit dem korrigierten Titel als plain text,
                ohne Anfuehrungszeichen, ohne Markdown, ohne Kommentar.
            - role: user
              text: |
                === DKS-Befunde (JSON) ===
                {{#extract_findings_title.findings_json#}}

                === Original-Titel ===
                {{#strip_html.title#}}
          context: {enabled: false, variable_selector: []}
          vision:  {enabled: false}
          structured_output: {enabled: false}

      - id: dks_check_content
        position: {x: 750, y: 180}
        data:
          type: http-request
          title: DKS MCP check_text (Inhalt)
          desc: "level=3 -- Rechtschreibung+Grammatik+Stil"
          method: post
          url: "__DKS_MCP_URL__"
          authorization:
            type: api-key
            config:
              type: bearer
              api_key: "__DKS_API_TOKEN__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: json
            data:
              - key: ""
                type: text
                value: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_text","arguments":{"text":"{{#strip_html.plain#}}","language":"de-DE","level":3}}}'

      - id: extract_findings_content
        position: {x: 1000, y: 180}
        data:
          type: code
          title: Extract Findings (Inhalt)
          code_language: python3
          code: |
            import json
            def main(mcp_body) -> dict:
                if isinstance(mcp_body, str):
                    try:    mcp_body = json.loads(mcp_body)
                    except: mcp_body = {}
                if not isinstance(mcp_body, dict):
                    mcp_body = {}
                struct = (mcp_body.get("result") or {}).get("structuredContent") or {}
                errors = struct.get("errors", []) or []
                findings, md_lines = [], []
                for f in errors:
                    snippet = f.get("snippet", "")
                    if not snippet:
                        continue
                    findings.append({
                        "snippet":     snippet,
                        "suggestions": (f.get("suggestions") or [])[:8],
                        "type":        f.get("type", ""),
                        "errorCode":   f.get("errorCode"),
                        "message":     f.get("message", "")     or "",
                        "longMessage": f.get("longMessage", "") or "",
                        "examples":    (f.get("examples") or [])[:3],
                        "group":       f.get("group", "")    or "",
                        "category":    f.get("category", "") or "",
                    })
                    sugs = findings[-1]["suggestions"]
                    rhs   = ("[" + " | ".join(sugs) + "]") if sugs else "(kein Vorschlag)"
                    extra = " (" + findings[-1]["message"] + ")" if findings[-1]["message"] else ""
                    md_lines.append("- [Inhalt/" + (findings[-1]["type"] or "?") + "] '" + snippet + "' -> " + rhs + extra)
                return {
                    "findings_json": json.dumps(findings, ensure_ascii=False),
                    "findings_md":   "\n".join(md_lines),
                    "count":         len(findings),
                }
          variables:
            - variable: mcp_body
              value_selector: [dks_check_content, body]
          outputs:
            findings_json: {type: string}
            findings_md:   {type: string}
            count:         {type: number}

      - id: llm_apply_content
        position: {x: 1250, y: 180}
        data:
          type: llm
          title: LLM Apply Corrections (Inhalt)
          desc: "Apply DKS findings to the original HTML, preserving every tag, attribute and entity"
          model:
            provider: langgenius/openai/openai
            name: gpt-4o
            mode: chat
            completion_params:
              temperature: 0
              max_tokens: 8192
          prompt_template:
            - role: system
              text: |
                Du bist ein deterministischer HTML-Korrekturassistent.
                Wende AUSSCHLIESSLICH die unten gelisteten Befunde des Duden-Korrektor-Servers (DKS) auf den gegebenen WordPress-HTML-Inhalt an.
                Jeder Befund enthält -- soweit verfügbar -- die Felder:
                  - snippet     : die markierte Textstelle im sichtbaren Text
                  - type        : "orth" | "gram" | "style" | ...
                  - errorCode   : numerischer DKS-Regel-Code
                  - message     : Kurztext der Regel (z.B. "Dieses Wort korrigieren?")
                  - longMessage : ausführliche Erklärung der Regel
                  - examples    : Liste von { "false": ..., "correct": ... } Beispielen
                                  derselben Regel -- maßgebliche Orientierungshilfe
                                  besonders wenn suggestions leer ist.
                  - group/category : grobe DKS-Kategorisierung
                  - suggestions : ggf. von DKS vorgeschlagene Korrekturen
                Strikte Regeln:
                1. Ändere KEINE HTML-Tags, -Attribute, -Klassennamen, -IDs, -URLs oder HTML-Entities. Die Tag-Struktur des Outputs muss zeichengenau mit der des Inputs übereinstimmen; nur reine Text-Knoten dürfen sich ändern.
                2. Pro Befund -- abhängig von "suggestions":
                   a) GEFÜLLT: Wähle den Vorschlag, der grammatikalisch und semantisch am besten in den umgebenden Satz passt -- berücksichtige Numerus (Singular/Plural), Genus, Kasus, Artikel, Verbkongruenz und Satzbedeutung. longMessage und examples beschreiben dabei den Regel-Hintergrund. Beispiel: bei "ein Haii" mit ["Haie","Hai"] wählst du "Hai", weil "ein" Singular verlangt.
                   b) LEER: DKS hat den Fehler erkannt, aber keinen automatischen Vorschlag erzeugt. Bestimme die Korrektur SELBST anhand von longMessage und examples. Übertrage das in den Beispielen gezeigte Muster ANALOG auf den vorliegenden Text (gleiche Regel, anderer konkreter Wortlaut). Beispiele:
                       - type "orth", longMessage "Schreibweise unbekannt", examples ["Entgeld"->"Entgelt", "watren"->"warten"]: → "Resteraunt" wird zu "Restaurant", "verschiednen" wird zu "verschiedenen".
                       - type "gram", longMessage "steht im Nominativ, möglicherweise passt anderer Kasus besser", example "Wir vertrauen Herr Schmidt"->"Wir vertrauen Herrn Schmidt": → "bestellten ... ein komplizierter Cocktail" wird zu "bestellten ... einen komplizierten Cocktail" (Akkusativ nach "bestellen").
                       - type "style" ohne Vorschlag: meist nicht zwingend ändern -- nur wenn die examples einen trivialen, eindeutigen Fix zeigen.
                3. Wenn weder suggestions noch examples eine eindeutige Korrektur ermöglichen, lass den Befund unverändert und ignoriere ihn still.
                4. Korrigiere im sichtbaren Text nur die "snippet"-Strings (bzw. bei type "gram": die ggf. längere, durch das snippet umrissene Wortgruppe) -- berücksichtige umliegende Satzzeichen. Erscheint ein Snippet mehrfach und der Kontext erlaubt keine eindeutige Unterscheidung, korrigiere alle Vorkommen.
                5. Wenn ein Snippet im sichtbaren Text gar nicht auffindbar ist, ignoriere den Befund still.
                6. Füge KEINEN neuen Text, KEINE Erklärungen, KEINE Kommentare und KEINE Markdown-Codeblöcke hinzu.
                7. Antworte ausschließlich mit dem korrigierten HTML, beginnend exakt mit dem ersten Zeichen des Originals.
            - role: user
              text: |
                === DKS-Befunde (JSON-Array) ===
                {{#extract_findings_content.findings_json#}}

                === Anzahl Befunde ===
                {{#extract_findings_content.count#}}

                === HTML-Quelle ===
                {{#strip_html.raw_html#}}
          context: {enabled: false, variable_selector: []}
          vision:  {enabled: false}
          structured_output: {enabled: false}

      - id: build_payloads
        position: {x: 1500, y: 0}
        data:
          type: code
          title: Build WP Payloads
          desc: "Kombiniert korrigierten Titel + korrigierten HTML-Inhalt zu Autosave + Comment"
          code_language: python3
          code: |
            import json
            def main(post_id,
                     title_orig: str, corrected_title: str,
                     raw_html: str, corrected_html: str,
                     count_title, count_content,
                     findings_md_title: str, findings_md_content: str) -> dict:
                ## --- Titel ---------------------------------------------------------
                t = (corrected_title or "").strip()
                ## LLM koennte aus Reflex Anfuehrungszeichen oder Codefences setzen
                if t.startswith("```"):
                    nl = t.find("\n")
                    t  = t[nl+1:] if nl != -1 else t[3:]
                    if t.endswith("```"):
                        t = t[:-3]
                    t = t.strip()
                if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'", "\u201c", "\u201e"):
                    t = t[1:-1].strip()
                if not t:
                    t = title_orig or ""

                ## --- Inhalt --------------------------------------------------------
                s = (corrected_html or "").strip()
                if s.startswith("```"):
                    nl = s.find("\n")
                    s  = s[nl+1:] if nl != -1 else s[3:]
                    if s.endswith("```"):
                        s = s[:-3]
                    s = s.strip()
                if not s:
                    s = raw_html or ""

                ## --- Autosave + Comment -------------------------------------------
                autosave = json.dumps({"title": t, "content": s})

                total = int(count_title) + int(count_content)
                md_parts = [x for x in [findings_md_title, findings_md_content] if x]
                comment = json.dumps({
                    "post":    int(post_id),
                    "content": "DKS-Vorschlag als Autosave hinterlegt -- im Editor pruefen.\n\n"
                               + str(total) + " Befund(e):\n" + ("\n".join(md_parts) or "(keine Befunde)"),
                })
                return {
                    "autosave_json": autosave,
                    "comment_json":  comment,
                    "total_count":   total,
                }
          variables:
            - variable: post_id
              value_selector: [start, post_id]
            - variable: title_orig
              value_selector: [strip_html, title]
            - variable: corrected_title
              value_selector: [llm_apply_title, text]
            - variable: raw_html
              value_selector: [strip_html, raw_html]
            - variable: corrected_html
              value_selector: [llm_apply_content, text]
            - variable: count_title
              value_selector: [extract_findings_title, count]
            - variable: count_content
              value_selector: [extract_findings_content, count]
            - variable: findings_md_title
              value_selector: [extract_findings_title, findings_md]
            - variable: findings_md_content
              value_selector: [extract_findings_content, findings_md]
          outputs:
            autosave_json: {type: string}
            comment_json:  {type: string}
            total_count:   {type: number}

      - id: create_autosave
        position: {x: 1750, y: 0}
        data:
          type: http-request
          title: Create Autosave
          desc: "POST /wp/v2/{type}/{id}/autosaves -- Editor sieht 'neuere Version' beim Oeffnen des Live-Artikels"
          method: post
          url: "__WP_INTERNAL_URL__/index.php?rest_route=/wp/v2/{{#derive_endpoints.wp_collection#}}/{{#start.post_id#}}/autosaves"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: raw-text
            data:
              - key: ""
                type: text
                value: "{{#build_payloads.autosave_json#}}"

      - id: add_comment
        position: {x: 2000, y: 0}
        data:
          type: http-request
          title: Add Editor Comment
          desc: "POST comment on the original post summarising findings (no-op fuer pages mit deaktivierten comments)"
          method: post
          url: "__WP_INTERNAL_URL__/index.php?rest_route=/wp/v2/comments"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: raw-text
            data:
              - key: ""
                type: text
                value: "{{#build_payloads.comment_json#}}"

      - id: end
        position: {x: 2250, y: 0}
        data:
          type: end
          title: End
          outputs:
            - variable: autosave_status
              value_selector: [create_autosave, status_code]
            - variable: comment_status
              value_selector: [add_comment, status_code]
            - variable: findings_count
              value_selector: [build_payloads, total_count]
            - variable: findings_count_title
              value_selector: [extract_findings_title, count]
            - variable: findings_count_content
              value_selector: [extract_findings_content, count]
            - variable: wp_collection
              value_selector: [derive_endpoints, wp_collection]

    edges:
      - {id: e0,  source: start,                    target: derive_endpoints,        sourceHandle: source}
      - {id: e1,  source: derive_endpoints,         target: fetch_post,              sourceHandle: source}
      - {id: e2,  source: fetch_post,               target: strip_html,              sourceHandle: source}

      ## Titel-Branch
      - {id: e3t, source: strip_html,               target: dks_check_title,         sourceHandle: source}
      - {id: e4t, source: dks_check_title,          target: extract_findings_title,  sourceHandle: source}
      - {id: e5t, source: extract_findings_title,   target: llm_apply_title,         sourceHandle: source}
      - {id: e6t, source: llm_apply_title,          target: build_payloads,          sourceHandle: source}

      ## Inhalt-Branch
      - {id: e3c, source: strip_html,               target: dks_check_content,       sourceHandle: source}
      - {id: e4c, source: dks_check_content,        target: extract_findings_content,sourceHandle: source}
      - {id: e5c, source: extract_findings_content, target: llm_apply_content,       sourceHandle: source}
      - {id: e6c, source: llm_apply_content,        target: build_payloads,          sourceHandle: source}

      ## Merge -> WP
      - {id: e7,  source: build_payloads,           target: create_autosave,         sourceHandle: source}
      - {id: e8,  source: create_autosave,          target: add_comment,             sourceHandle: source}
      - {id: e9,  source: add_comment,              target: end,                     sourceHandle: source}
