Skip to content

Instantly share code, notes, and snippets.

@realgenekim
Last active March 4, 2024 20:05
Show Gist options
  • Save realgenekim/17f9a7ae48aaf2e03df3cc80326a5094 to your computer and use it in GitHub Desktop.
Save realgenekim/17f9a7ae48aaf2e03df3cc80326a5094 to your computer and use it in GitHub Desktop.
A monsterously bad function before rewriting it. This was before rewriting it, inspired by @christoph-neumann and @justone in their Functional Design in Clojure podcast!!
(>defn interpret-photo-from-client
" input: db, photo/id (uuid), prompt string (can be 'default'), and options map "
[db uuid prompt & {:keys [async? model]
:or {async? false} :as opts}]
[(? #(instance? xtdb.node.XtdbNode %)) uuid? string? (s/* (s/or :keyword keyword? :bool boolean?)) => map?]
(log/warn :prompt-photo :model model :uuid uuid :async? async?)
(let [
record (xtp/photo-xtdb-fresh-url-uuid db uuid)
url (-> record
:photo/url
vu/xform-url-size-big)
b64 (-> url
(ol/url->stream)
(ol/stream-to-base64))
prompt (if (= prompt "default")
(slurp "resources-openai/images/podcast-screenshot.txt")
prompt)
_ (log/warn :prompt-photo :prompt prompt)
_ (log/warn :prompt-photo :async? async? :uuid uuid :url url)
;summary (slurp "/tmp/summary")
begin-ms (System/currentTimeMillis)
; must reutrn map
summary (time
(if (= model :gpt-4-vision-preview)
(let [retval (gpt4v/prompt-photo b64 prompt)
_ (log/warn :prompt-photo :first-retval retval)
interpreted (gpt4v/interpret-prompt-photo {:summary (-> retval :summary)})]
interpreted)
; llava
(if async?
(ol/interpret-photo-async! b64 {:prompt prompt
:json? true})
(ol/interpret-photo-sync! b64 {:prompt prompt}))))
_ (do
(def SUMMMARY summary)
(def UUID uuid)
(def RECORD record)
(def URL url)
(ol/write-decoded-base64-to-file b64 "/tmp/decoded.jpg")
(spit "/tmp/summary" summary)
0)
elapsed-ms (- (System/currentTimeMillis) begin-ms)
;new-photo-record (create-photo-summary-record-and-attach-to-parent! db uuid summary elapsed-ms)]
retval {:url url
:summary summary}]
;(def NEWRECORD new-photo-record)
(log/warn :prompt-photo :elapsed elapsed-ms)
(log/warn :prompt-photo :retval retval)
retval))
@realgenekim
Copy link
Author

realgenekim commented Feb 27, 2024

Here's the rewritten version:

I'm so ridiculously happy with it! Thank you @christoph-neumann and @justone!

(>defn doit
  " input: a giant state that contains everything needed to steps, or entire sequence of steps
    output: modified state that we can feed into the next step
  "
  [{:keys [state db photo-id photo-url
           photo-b64-string local-filename
           pass1-prompt model pass1-summary pass1-elapsed-ms
           is-screenshot? youtube-percentage
           pass2-prompt pass2-summary pass2-success? pass2-elapsed-ms]
    :or {state :load-photo-from-db}
    :as bag}]

  [map? => map?]
  (case state
    :load-photo-from-db
    ; input: uuid
    ; output: photo url
    ; notes: this shouldn't live in the photo-ops namespace!  because it
    ;       shouldn't need to know about the databse!
    (let [photo-url (xtp/photo-xtdb-fresh-url-uuid db photo-id)]
      (assoc bag :photo-url photo-url
                 :state :photo-url-to-b64-string))

    ; input: photo-url
    ; output: photo-b64-string
    :photo-url-to-b64-string
    (let [b64 (-> photo-url
                :photo/url
                vu/xform-url-size-big
                (ol/url->stream)
                (ol/stream-to-base64))
          filename "/tmp/decoded.jpg"]
      (ol/write-decoded-base64-to-file b64 filename)
      (assoc bag :photo-b64-string b64
                 :local-filename filename
                 :state :prompt-vision-llm-pass1
                 ; args for next pass
                 :pass1-prompt "default"
                 :model        :gpt-4-vision-preview))


    ; input: photo-b64-string, :pass1-prompt, model
    ; output: screenshot-vision-summary
    ;    (this is not JSON or EDN; it's freeform prose, because gpt-4-vision doesn't have function calling,
    ;     which is why we need a second LLM pass)
    :prompt-vision-llm-pass1
    (let [prompt     (if (= pass1-prompt "default")
                       (slurp "resources-openai/images/podcast-screenshot.txt")
                       pass1-prompt)
          _          (log/warn :prompt-photo :prompt prompt)
          _          (log/warn :prompt-photo :photo-id photo-id :photo-url photo-url)
          begin-ms   (System/currentTimeMillis)
          summary    (gpt4v/prompt-photo photo-b64-string prompt)
          elapsed-ms (- (System/currentTimeMillis) begin-ms)
          _          (log/warn :prompt-photo :summary summary)]
      (def SUMMMARY summary)
      (spit "/tmp/summary" summary)

      (assoc bag :state :analyze-screenshot ; :prompt-llm-pass2-to-edn
                 :pass1-summary summary
                 :pass1-elapsed-ms elapsed-ms))


    ; input: b64-string
    ; output: is-mobile-screenshot? youtube-percentage-progress-bar
    :analyze-screenshot
    (let [is-screenshot?     (screenshots/is-image-iphone-screenshot? local-filename)
          youtube-percentage (if is-screenshot?
                               (detect-red/detect-percentage-complete local-filename)
                               nil)
          newprops           (if (nil? youtube-percentage)
                               {:is-screenshot? is-screenshot?}
                               {:is-screenshot?     is-screenshot?
                                :youtube-percentage youtube-percentage})]
      (-> bag
        (assoc :state :pass2-generate-prompt)
        (merge newprops)))

    :pass2-generate-prompt
    (let [prompt (gpt4v/generate-prompt {:summary            (-> pass1-summary :summary)
                                         :is-screenshot?     is-screenshot?
                                         :youtube-percentage youtube-percentage})]
      (assoc bag :state :pass2-summary-to-edn
                 :pass2-prompt prompt))

    ; this is the second pass to LLM, which we give the first pass interpretation, as well
    ; as the screenshot analysis
    ;
    ; input: pass1-summary, youtube-percentage
    ; output: pass2-summary (EDN map)
    :pass2-summary-to-edn
    (do
      (log/warn :doit :pass2-summary-to-edn)
      (let [begin-ms    (System/currentTimeMillis)
            interpreted (gpt4v/interpret-prompt-photo {:summary (-> pass1-summary :summary)
                                                       :prompt pass2-prompt
                                                       :is-screenshot? is-screenshot?
                                                       :youtube-percentage youtube-percentage})
            elapsed-ms  (- (System/currentTimeMillis) begin-ms)
            success?    (not (-> interpreted :summary :error))]
        (log/warn :doit :pass2-summary-to-edn :results interpreted)
        (if-not success?
          (log/error :doit :FAILURE))

        (assoc bag :state :write-to-database
                   :pass2-summary interpreted
                   :pass2-elapsed-ms elapsed-ms
                   :pass2-success? success?)))

    ; input: pass2-summary pass2-success? db
    ; output: none
    :write-to-database
    (do
      (if (not pass2-success?)
        (log/error :write-to-database :pass2-failed!)
        (let [retval (create-photo-summary-record-and-attach-to-parent! db photo-id
                       (-> pass2-summary :summary)
                       (+ pass1-elapsed-ms pass2-elapsed-ms))]

          (assoc bag :state :done
                     :dbwrite-retval retval))))

    :done
    (do
      (log/warn :DONE :nothing-to-do!)
      bag)
    ,)
  ,)

(comment
  (require '[com.example.components.xtdb :as xtdb])
  ; :load-photo-from-db
  (def state
    (doit {:state :load-photo-from-db
           :photo-id #uuid "230ed695-2224-4285-b96f-59f813bfa68b"
           :db (:photos xtdb/xtdb-nodes)}))
  ; :photo-url-to-b64-string
  (def state2
    (doit state))
  ; :prompt-vision-llm-pass1
  (def state3
    (doit (merge state2 {:pass1-prompt "default"
                         :model        :gpt-4-vision-preview})))
  ; :analyze-screenshot
  (def state4
    (doit state3))
  (tap> state4)

  ; :pass2-generate-prompt
  (def state4a
    (doit state4))
  (-> state4a :pass1-summary)
  (-> state4a :pass2-prompt)
  (tap> state4a)

  ; :pass2-summary-to-edn
  ; 2nd llm pass, with screenshot analysis integrated into the prompt
  (def state5
    (doit state4a))
  (tap> state5)
  (-> state5 :pass2-summary :summary)

  ; :write-to-db
  (def state6
    (doit state5))


  (-> state5 (dissoc :photo-b64-string))

  (-> state5 :pass2-summary :summary :error not)
  (tap> state6)

  0)

(>defn interpret-photo-from-client-new
  [{:keys [db photo-id state] :as bag}]
  [map? => map?]
  (let [init-bag {:state :load-photo-from-db
                  :photo-id photo-id
                  :db db}]
    (loop [bag init-bag]
      (let [newstate (doit bag)]
        (if (= (-> newstate :state) :done)
          (do
            (log/warn :done!)
            newstate)
          (do
            (log/warn :interpret-photo-from-client-new :state (-> newstate :state))
            (recur newstate)))))))


(comment
  (interpret-photo-from-client-new {:db (:photos xtdb/xtdb-nodes)
                                    :photo-id #uuid "230ed695-2224-4285-b96f-59f813bfa68b"
                                    :state :load-photo-from-db})
  0)

@slipset
Copy link

slipset commented Mar 4, 2024

(ns gene)

    ; input: uuid
    ; output: photo url
    ; notes: this shouldn't live in the photo-ops namespace!  because it
    ;       shouldn't need to know about the databse!
(defn load-photo-url-from-db [db photo-id]
  (xtp/photo-xtdb-fresh-url-uuid db photo-id))

(defn photo-url-to-b64-string [photo-url]
  (let [b64 (-> photo-url
                :photo/url
                vu/xform-url-size-big
                (ol/url->stream)
                (ol/stream-to-base64))
        filename "/tmp/decoded.jpg"]
    (ol/write-decoded-base64-to-file b64 filename)
    {:photo-b64-string b64
     :local-filname filename}))

(defn prompt-vision-llm-pass1 [pass1-prompt photo-b64-string]
  (let [prompt (if (= pass1-prompt "default")
                 (slurp "resources-openai/images/podcast-screenshot.txt")
                 pass1-prompt)
        summary    (gpt4v/prompt-photo photo-b64-string prompt)]

    (spit "/tmp/summary" summary)
    summary))

(defn analyze-screenshot [local-filename]
  (let [is-screenshot?     (screenshots/is-image-iphone-screenshot? local-filename)
        youtube-percentage (if is-screenshot?
                             (detect-red/detect-percentage-complete local-filename)
                             nil)]
    (if (nil? youtube-percentage)
                             {:is-screenshot? is-screenshot?}
                             {:is-screenshot?     is-screenshot?
                              :youtube-percentage youtube-percentage})))

(defn pass2-generate-prompt [pass1-summary is-screenshot? youtube-percentage]
  (gpt4v/generate-prompt {:summary            (-> pass1-summary :summary)
                          :is-screenshot?     is-screenshot?
                          :youtube-percentage youtube-percentage}))

(defn pass2-summary-to-edn [pass1-summary pass2-prompt is-screenshot? youtube-percentage]
  (let [
        interpreted (gpt4v/interpret-prompt-photo {:summary (-> pass1-summary :summary)
                                                   :prompt pass2-prompt
                                                   :is-screenshot? is-screenshot?
                                                   :youtube-percentage youtube-percentage})
        success?    (not (-> interpreted :summary :error))]

    {:pass2-summary interpreted
     :pass2-success? success?}))

(defn write-to-database [db photo-id pass2-summary]
  (create-photo-summary-record-and-attach-to-parent! db photo-id
                                                     (-> pass2-summary :summary)
                                                     0 ; don't store the timeing perhaps
                                                     ))

(defn doit
  " input: a giant state that contains everything needed to steps, or entire sequence of steps
    output: modified state that we can feed into the next step
  "
  [{:keys [state db photo-id photo-url
           photo-b64-string local-filename
           pass1-prompt model pass1-summary
           is-screenshot? youtube-percentage
           pass2-prompt pass2-summary pass2-success?]
    :or {state :load-photo-from-db}
    :as bag}]

  (case state
    :load-photo-from-db
    (merge bag {:photo-url (load-photo-url-from-db db photo-id)
                :state :photo-url-to-b64-string})
    :photo-url-to-b64-string
    (merge bag {:state :prompt-vision-llm-pass1
                                        ; args for next pass
                :pass1-prompt "default"
                :model :gpt-4-vision-preview}
           (photo-url-to-b64-string photo-url))

    :prompt-vision-llm-pass1
    (merge bag {:pass1-summary (prompt-vision-llm-pass1 pass1-prompt photo-b64-string)})

                                        ; input: b64-string
                                        ; output: is-mobile-screenshot? youtube-percentage-progress-bar
    :analyze-screenshot
    (merge bag {:state :pass2-generate-prompt} (analyze-screenshot local-filename))

    :pass2-generate-prompt
    (merge bag {:state :pass2-summary-to-edn
                :pass2-prompt (pass2-generate-prompt pass1-summary is-screenshot? youtube-percentage)})

    :pass2-summary-to-edn
    (merge bag {:state :write-to-database} (pass2-summary-to-edn pass1-summary pass2-prompt is-screenshot? youtube-percentage))

                                        ; input: pass2-summary pass2-success? db
                                        ; output: none
    :write-to-database
    (merge bag {:state :done
                :dbwrite-retval (write-to-database db photo-id pass2-summary)})

    :done
    (do
      (log/warn :DONE :nothing-to-do!)
      bag)))

first draft, more coming :)

@slipset
Copy link

slipset commented Mar 4, 2024

Since it seems to me that you've written your doit as a state-machine, but it only allows for one way through the state machine, I'd argue

(defn load-photo-url-from-db [db photo-id]
  (xtp/photo-xtdb-fresh-url-uuid db photo-id))

(defn photo-url-to-b64-string [photo-url]
  (let [b64 (-> photo-url
                :photo/url
                vu/xform-url-size-big
                (ol/url->stream)
                (ol/stream-to-base64))
        filename "/tmp/decoded.jpg"]
    (ol/write-decoded-base64-to-file b64 filename)
    {:photo-b64-string b64
     :local-filname filename}))

(defn prompt-vision-llm-pass1 [{:keys [photo-b64-string] :as bag} pass1-prompt]
  ;; I'd probs split this into three different fns
  (let [prompt (if (= pass1-prompt "default")
                 (slurp "resources-openai/images/podcast-screenshot.txt")
                 pass1-prompt)
        summary (gpt4v/prompt-photo photo-b64-string prompt)]

    (spit "/tmp/summary" summary)
    (merge bag {:pass1 {:summary summary}})))

(defn analyze-screenshot [{:keys [local-filename] :as bag}]
  (let [is-screenshot? (screenshots/is-image-iphone-screenshot? local-filename)
        youtube-percentage (if is-screenshot?
                             (detect-red/detect-percentage-complete local-filename)
                             nil)]
    (merge bag {:screenshot (if (nil? youtube-percentage)
                              {:is-screenshot? is-screenshot?}
                              {:is-screenshot? is-screenshot?
                               :youtube-percentage youtube-percentage})})))

(defn pass2-generate-prompt [{:keys [pass1 screenshot] :as bag}]
  (merge bag {:pass2-prompt (gpt4v/generate-prompt (merge {:summary (-> pass1 :summary :summary)}
                                                          screenshot))}))

(defn pass2-summary-to-edn [{:keys [pass1 pass2-prompt screenshot] :as bag}]
  (let [interpreted (gpt4v/interpret-prompt-photo (merge {:summary (-> pass1 :summary :summary)
                                                          :prompt pass2-prompt}
                                                         screenshot))
        success? (not (-> interpreted :summary :error))]

    (merge bag {:pass2 {:summary interpreted
                        :success? success?}})))

(defn write-to-database [{:keys [pass2] :as bag} db photo-id ]
  (create-photo-summary-record-and-attach-to-parent! db photo-id
                                                     (-> pass2 :summary :summary)
                                                     0 ; don't store the timeing perhaps
                                                     ))




(defn doit [db photo-id]
  (-> (load-photo-url-from-db db photo-id)
      (photo-url-to-b64-string)
      (prompt-vision-llm-pass1 "default")
      (analyze-screenshot)
      (pass2-generate-prompt)
      (pass2-summary-to-edn)
      (write-to-database db photo-id)))

Expresses that clearer

@slipset
Copy link

slipset commented Mar 4, 2024

Splitting that one fn in three (almost)

(defn load-photo-url-from-db [db photo-id]
  (xtp/photo-xtdb-fresh-url-uuid db photo-id))

(defn photo-url-to-b64-string [photo-url]
  (let [b64 (-> photo-url
                :photo/url
                vu/xform-url-size-big
                (ol/url->stream)
                (ol/stream-to-base64))
        filename "/tmp/decoded.jpg"]
    (ol/write-decoded-base64-to-file b64 filename)
    {:photo-b64-string b64
     :local-filname filename}))

(defn prompt-vision-llm-pass1 [{:keys [photo-b64-string] :as bag} prompt]
  (merge bag {:pass1 {:summary (gpt4v/prompt-photo photo-b64-string prompt)}}))

(defn store-summary [bag pass-kw]
  (spit (str "/tmp/" (name pass-kw)) (-> bag pass-kw :summary))
  bag)

(defn analyze-screenshot [{:keys [local-filename] :as bag}]
  (let [is-screenshot? (screenshots/is-image-iphone-screenshot? local-filename)
        youtube-percentage (if is-screenshot?
                             (detect-red/detect-percentage-complete local-filename)
                             nil)]
    (merge bag {:screenshot (if (nil? youtube-percentage)
                              {:is-screenshot? is-screenshot?}
                              {:is-screenshot? is-screenshot?
                               :youtube-percentage youtube-percentage})})))

(defn pass2-generate-prompt [{:keys [pass1 screenshot] :as bag}]
  (merge bag {:pass2-prompt (gpt4v/generate-prompt (merge {:summary (-> pass1 :summary :summary)}
                                                          screenshot))}))

(defn pass2-summary-to-edn [{:keys [pass1 pass2-prompt screenshot] :as bag}]
  (let [interpreted (gpt4v/interpret-prompt-photo (merge {:summary (-> pass1 :summary :summary)
                                                          :prompt pass2-prompt}
                                                         screenshot))
        success? (not (-> interpreted :summary :error))]

    (merge bag {:pass2 {:summary interpreted
                        :success? success?}})))

(defn write-to-database [{:keys [pass2] :as bag} db photo-id ]
  (create-photo-summary-record-and-attach-to-parent! db photo-id
                                                     (-> pass2 :summary :summary)
                                                     0 ; don't store the timeing perhaps
                                                     ))

(def default-prompt (slurp "resources-openai/images/podcast-screenshot.txt"))

(defn doit [db photo-id]
  (-> (load-photo-url-from-db db photo-id)
      (photo-url-to-b64-string)
      (prompt-vision-llm-pass1 default-prompt)
      (store-summary :pass1)
      (analyze-screenshot)
      (pass2-generate-prompt)
      (pass2-summary-to-edn)
      (write-to-database db photo-id)))

@slipset
Copy link

slipset commented Mar 4, 2024

I could noodle with this forever:
Splitting photo-url-to-b64-string into it's two responsibilities

(defn url->b64 [url]
  (-> url
      :photo/url
      vu/xform-url-size-big
      (ol/url->stream)
      (ol/stream-to-base64)))

(defn write-to-file [b64 filename]
  (ol/write-decoded-base64-to-file b64 filename)
  {:photo-b64-string b64
   :local-filname filename})

(defn prompt-vision-llm-pass1 [{:keys [photo-b64-string] :as bag} prompt]
  (merge bag {:pass1 {:summary (gpt4v/prompt-photo photo-b64-string prompt)}}))

(defn store-summary [bag pass-kw]
  (spit (str "/tmp/" (name pass-kw)) (-> bag pass-kw :summary))
  bag)

(defn analyze-screenshot [{:keys [local-filename] :as bag}]
  (let [is-screenshot? (screenshots/is-image-iphone-screenshot? local-filename)
        youtube-percentage (if is-screenshot?
                             (detect-red/detect-percentage-complete local-filename)
                             nil)]
    (merge bag {:screenshot (if (nil? youtube-percentage)
                              {:is-screenshot? is-screenshot?}
                              {:is-screenshot? is-screenshot?
                               :youtube-percentage youtube-percentage})})))

(defn pass2-generate-prompt [{:keys [pass1 screenshot] :as bag}]
  (merge bag {:pass2-prompt (gpt4v/generate-prompt (merge {:summary (-> pass1 :summary :summary)}
                                                          screenshot))}))

(defn pass2-summary-to-edn [{:keys [pass1 pass2-prompt screenshot] :as bag}]
  (let [interpreted (gpt4v/interpret-prompt-photo (merge {:summary (-> pass1 :summary :summary)
                                                          :prompt pass2-prompt}
                                                         screenshot))
        success? (not (-> interpreted :summary :error))]

    (merge bag {:pass2 {:summary interpreted
                        :success? success?}})))

(defn write-to-database [{:keys [pass2] :as bag} db photo-id ]
  (create-photo-summary-record-and-attach-to-parent! db photo-id
                                                     (-> pass2 :summary :summary)
                                                     0 ; don't store the timeing perhaps
                                                     ))

(def default-prompt (slurp "resources-openai/images/podcast-screenshot.txt"))

(defn doit [db photo-id]
  (-> (xtp/photo-xtdb-fresh-url-uuid db photo-id)
      (url->b64)
      (write-to-file "tmp/decoded.jpg")
      (prompt-vision-llm-pass1 default-prompt)
      (store-summary :pass1)
      (analyze-screenshot)
      (pass2-generate-prompt)
      (pass2-summary-to-edn)
      (write-to-database db photo-id)))

@slipset
Copy link

slipset commented Mar 4, 2024

You're not using the local-filename in your threading thing, so no need to add that to the bag:

(defn url->b64 [url]
  (-> url
      :photo/url
      vu/xform-url-size-big
      (ol/url->stream)
      (ol/stream-to-base64)))

(defn write-to-file [b64 filename]
  (ol/write-decoded-base64-to-file b64 filename)
  b64)

(defn prompt-vision-llm-pass1 [b64 prompt]
  (merge bag {:pass1 {:summary (gpt4v/prompt-photo b64 prompt)}}))

(defn store-summary [bag pass-kw]
  (spit (str "/tmp/" (name pass-kw)) (-> bag pass-kw :summary))
  bag)

(defn analyze-screenshot [bag local-filename]
  (let [is-screenshot? (screenshots/is-image-iphone-screenshot? local-filename)
        youtube-percentage (if is-screenshot?
                             (detect-red/detect-percentage-complete local-filename)
                             nil)]
    (merge bag {:screenshot (if (nil? youtube-percentage)
                              {:is-screenshot? is-screenshot?}
                              {:is-screenshot? is-screenshot?
                               :youtube-percentage youtube-percentage})})))

(defn pass2-generate-prompt [{:keys [pass1 screenshot] :as bag}]
  (merge bag {:pass2-prompt (gpt4v/generate-prompt (merge {:summary (-> pass1 :summary :summary)}
                                                          screenshot))}))

(defn pass2-summary-to-edn [{:keys [pass1 pass2-prompt screenshot] :as bag}]
  (let [interpreted (gpt4v/interpret-prompt-photo (merge {:summary (-> pass1 :summary :summary)
                                                          :prompt pass2-prompt}
                                                         screenshot))
        success? (not (-> interpreted :summary :error))]

    (merge bag {:pass2 {:summary interpreted
                        :success? success?}})))

(defn write-to-database [{:keys [pass2] :as bag} db photo-id ]
  (create-photo-summary-record-and-attach-to-parent! db photo-id
                                                     (-> pass2 :summary :summary)
                                                     0 ; don't store the timeing perhaps
                                                     ))

(def default-prompt (slurp "resources-openai/images/podcast-screenshot.txt"))

(defn doit [db photo-id]
  (-> (xtp/photo-xtdb-fresh-url-uuid db photo-id)
      (url->b64)
      (write-to-file "tmp/decoded.jpg")
      (prompt-vision-llm-pass1 default-prompt)
      (store-summary :pass1)
      (analyze-screenshot "tmp/decoded.jpg")
      (pass2-generate-prompt)
      (pass2-summary-to-edn)
      (write-to-database db photo-id)))

@slipset
Copy link

slipset commented Mar 4, 2024

It is now trivial to add back the timings under the various passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment