« ^ »
[WIP]

doctorを治療する

所要時間: 約 4分

Emacsにはdoctorというセラピー機能がある。その機能を有効に活用していくために、以前doctorのChatGPT対応を行った1。その中でdoctorの実装について改善できそうな点にいくつか気付いた。これはどちらかというと、自分の要求を満すためには問題と思える点であるため、根本的な問題という意味ではない。今回はこの問題を修正していく。

キーマップを廃止する

doctorでは RETC-j に対して、doctorの処理実行用の関数を割り当てている。これにより改行をトリガーとしてdoctorとの対話を成立させるような実装をしている。しかしこの実装では、改行を複数含むような文章をdoctorに問い合わせる事ができない。doctorの性能や精度がそれなりに高い場合、問い合わせたい文章は長くなるだろう。当然、改行が入っていたり、構造化されているような文章を、問い合わせる機会も出てくる。このような状況では、現状の問い合わせの方法は、あまり適切ではない。問い合わせ内容と、回答を対応できるような状態を保ちつつ、制御コマンドのような所定の操作によって、問い合わせを行いたい。そこで doctor-mode-map のデフォルトのキーバインドは全て廃止する事にする。

(define-key doctor-mode-map (kbd "C-j") nil)
(define-key doctor-mode-map (kbd "RET") nil)

C-cによってdoctorへの問い合わせを行う

前述でキーマップを全て廃止してしまったため、このままではdoctorへの問い合わせを行うためには M-x doctor-read-print と実行する必要がある。それはそれで良い気もするが、何度も打つ事を考えると、やはりショートカットは欲しい。今回は一番馴染のある C-c C-cdoctor-read-print を割り当てる事にする。

(define-key doctor-mode-map (kbd "C-c C-c") #'doctor-read-print)

doctorの発話と自分の発話を視覚的に区別できるようにする

doctorが作成するバッファは通常、自分の記述したテキストとdoctorの記述したテキストは同じように表示される。そのため後から読もうとすると、どのテキストがdoctorで、どのテキストが自分のものなのか分からなくなる。LINEのような左右に分ける方法、吹き出し、アイコンを用いる方法など考えたが、どれもイマイチしっくり来ない。左右に分ける方法は、テキストの保存方法について考える必要があるし、テキストをコピーや矩形編集する場合、邪魔になるように思う。吹き出しも罫線が邪魔になる。アイコンについては、そもそも基本的にテキスト表現なのに、わざわざ画像を挿入する事自体がイケてない。

もろもろ考えてみたが、org-modeのブロック形式で出力することにする。編集のしやすさも損わないし、見栄えを変更したい場合は、別途対応が可能だ。

(defun doctor-read-print ()
  "Top level loop."
  (interactive nil doctor-mode)
  (setq doctor-sent (doctor-readin))
  (insert "#+begin_example\n")  ;; modified
  (setq doctor--lincount (1+ doctor--lincount))
  (doctor-doc)
  (insert "#+end_example\n")  ;; modified
  (setq doctor--bak doctor-sent))

前回のdoctorの発言の末尾から現在の入力の最後までをdoctorに送信する

doctorは1文1答のような形式を前提にして実装されているため、1つ前の文のみしか処理しない。これでは、長い文章をdoctorに問い合わせる事が難しい。前述でdoctorの発話はorg-modeのブロック形式として出力するようにした為、その最後の位置から現在バッファの最後までの文字列を、doctorに問い合わせるようにする。

org-modeのブロック形式の終了位置にマーカーを設置し、それを元に次回の問い合わせの文章を取得する。

(defvar doctor-current-answer-marker (make-marker))
(defvar doctor-current-talk-end-marker (make-marker))

(unless (symbol-function 'doctor-read-print-original)
  (defalias 'doctor-read-print-original (symbol-function #'doctor-read-print)))

(defun doctor-read-print-extra ()
  "Top level loop."
  (interactive nil doctor-mode)
  (setq doctor-sent (doctor-readin))
  (insert (if (equal (line-beginning-position) (point))
    "\n" "\n\n"))
  (insert "#+begin_example\n")
  (set-marker doctor-current-answer-marker (point))
  (goto-char (+ (point) 1))
  (insert "#+end_example\n\n")
  (set-marker doctor-current-talk-end-marker (point))
  (doctor-doc))

(defalias 'doctor-read-print (symbol-function #'doctor-read-print-extra))

そして設定したマーカーからバッファの末尾までを、送信対象の文字列として doctor-readin で返すように変更する。通常では doctor-readin は文字列のリストを返し、文字列は単語(word)とあるが、実際には文節で区切られた文字列が入っている。ここは、その文字列の処理器によって適当な長さが異なる。ChatGPTの場合は、ぶつ切りにしない方が望ましいし、他の処理機では、もしかしたらMeCabなどによって、形態素解析された状態が良いかもしれない。つまりバックエンドを何にするかによって実装を変更できるようにする必要がある。統合的に使用可能なように doctor-readin-all-sentences という関数を定義し doctor-readin と入れ替えるようにする。 doctor-readin の参照を失ってしまうと、元に戻せなくなってしまうため、これは別名として保持しておく。

(unless (symbol-function 'doctor-readin-original)
  (defalias 'doctor-readin-original (symbol-function #'doctor-readin)))

(defun doctor-readin-all-sentences ()
  (interactive)
  (list
   (buffer-substring-no-properties
    (marker-position doctor-current-talk-end-marker)
    (point-max))))

(defalias 'doctor-readin (symbol-function #'doctor-readin-all-sentences))

ChatGPTのAPIに文字列を送信し、doctorバッファに出力する

(require 'plz)
(require 'json)

(defun doctor-chatgpt-parse-response-get-text (data)
  (cdr (assoc 'text (elt (cdr (assoc 'choices data)) 0))))

(defun doctor-doc-chatgpt ()
  (let* ((sentence (car doctor-sent))
         (endpoint (doctor-chatgpt-api-get-url "/v1/completions"))
         (headers `(("Content-Type" . "application/json")
                    ("Authorization" . ,(format "Bearer %s" doctor-chatgpt-access-token))))
         (body (json-encode `(("model" . "text-davinci-003")
                              ("prompt" . ,sentence)
                              ("temperature" . 0.9)
                              ("max_tokens" . 300)
                              ("top_p" . 1)
                              ("frequency_penalty" . 0.0)
                              ("presence_penalty" . 0.6)
                              ("stop" . (" Human:" " AI:"))))))
    (message body)
    (princ endpoint)
    (princ "\n")
    (princ headers)
    (plz 'post endpoint :headers headers :body body
      :as #'json-read
      :then (lambda (d)
              (princ d)
              (let ((answer (doctor-chatgpt-parse-response-get-text d)))
                (with-current-buffer (marker-buffer doctor-current-answer-marker)
                  (save-excursion
                    (goto-char (marker-position doctor-current-talk-end-marker))
                    (insert answer)))))
      :else (lambda (d)
        (princ d)))))


(unless (symbol-function 'doctor-doc-original)
  (defalias 'doctor-doc-original (symbol-function #'doctor-doc)))

(defalias 'doctor-doc (symbol-function #'doctor-doc-chatgpt))

修正したものをhttps://github.com/TakesxiSximada/emacs.d/commit/82abdef31ac12e1c35ec90ce77b5a9dc7c897591に置いておく。