😷

ChatGPTで使うためにコードをマスクしたり戻したりする

2023/07/30に公開

やりたいこと

  • 業務でChatGPTを使うとき、業務での用語をChatGPTに渡さないようにマスクしたい

    • Pythonスクリプトを使って、マスクしたり戻せるように
    • ホワイトリストを作って、一般的な単語は残す
      • 残さないと、ChatGPTが理解できない
    • suffix, prefixで指定した部分を残す
      • 自分が理解しやすいように
  • 注:ロジックそのものは隠せないので変数名を隠すという意味

書いてないこと

  • マスクする是非、何をマスクすべきか、など

Code

  • 全部同じフォルダにある想定
  • result...はスクリプトで作成されるファイル
ファイル 内容
whitelist.txt ホワイトリスト
input_mask.txt マスク用入力
do_mask.py マスク操作
result_mask.txt マスクした結果
result_mapping.txt マスクのマッピング保存
input_unmask.txt 戻す用入力
do_unmask.py 戻す操作
result_unmask.txt 戻した結果

マスク処理でやっていること

  • デフォルトでは見つけた単語のを順にa01, a02, ...と置き換える
  • result_mapping.txtに下記のように保存する
a01,something1
a02,something2

whitelistの記法

  • 単語ごとの完全一致
  • Pythonの予約語
  • suffix:_id と書くと、語尾にある_idの部分は残す
    • 例: some_id -> a01_id
  • prefix:some_ と書くと、語頭にあるsome_の部分は残す
    • 例: some_id -> some_a01

Code

クリックして開く
do_mask.py
import io
import os
import re
import token
import tokenize

ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
WHITELIST_FILE = os.path.join(ROOT_DIR, 'whitelist.txt')
INPUT_FILE = os.path.join(ROOT_DIR, 'input_mask.txt')
RESULT_MASK_FILE = os.path.join(ROOT_DIR, 'result_mask.txt')
RESULT_MAPPING_FILE = os.path.join(ROOT_DIR, 'result_mapping.txt')

MASK_PREFIX = 'a'
ZERO_PAD = 2


def remove_comments_and_docstrings(source):
    io_obj = io.StringIO(source)
    out = []
    prev_toktype = token.INDENT
    last_lineno = -1
    last_col = 0

    for tok in tokenize.generate_tokens(io_obj.readline):
        toktype, ttext, (slineno, scol), (elineno, ecol), _ = tok
        if is_comment_or_string(toktype, prev_toktype):
            continue

        last_col = update_col(slineno, last_lineno, last_col)
        if need_space(scol, last_col):
            out.append(" " * (scol - last_col))

        out.append(ttext)

        prev_toktype = toktype
        last_lineno = elineno
        last_col = ecol

    return "".join(out)


def is_comment_or_string(toktype, prev_toktype):
    return toktype in (tokenize.COMMENT, token.STRING) and prev_toktype == token.INDENT


def update_col(slineno, last_lineno, last_col):
    return 0 if slineno > last_lineno else last_col


def need_space(scol, last_col):
    return scol > last_col


def load_whitelist(whitelist_file):
    """Loads whitelist from file and splits it into words, prefixes and suffixes."""
    with open(whitelist_file) as file:
        whitelist = set(line.strip() for line in file if line.strip() != '')

    prefixes = {w[7:] for w in whitelist if w.startswith('prefix:')}
    suffixes = {w[7:] for w in whitelist if w.startswith('suffix:')}
    words = whitelist - prefixes - suffixes

    return list(words), list(prefixes), list(suffixes)


def create_replacement(word, counter, prefix=None, suffix=None):
    """Creates a replacement for a given word with an optional prefix or suffix."""
    replacement = f"{prefix if prefix else ''}{MASK_PREFIX}{counter:0{ZERO_PAD}d}{suffix if suffix else ''}"
    return replacement


def mask_text(input_file, words, prefixes, suffixes, mask_numbers=True):
    """Masks words in text according to whitelist of words, prefixes and suffixes."""
    with open(input_file, 'r') as f:
        source = f.read()

    text = remove_comments_and_docstrings(source)
    masked_text = text

    mapping = []
    counter = 1
    words_in_text = set(re.findall(r'\b\w+\b', text))

    for word in words_in_text:
        if word in words or (not mask_numbers and re.match(r'^\d+(\.\d+)?$', word)):
            continue

        replacement = None

        for prefix in prefixes:
            if word.startswith(prefix):
                replacement = create_replacement(word, counter, prefix=prefix)
                break

        if not replacement:
            for suffix in suffixes:
                if word.endswith(suffix):
                    replacement = create_replacement(word, counter, suffix=suffix)
                    break

        if not replacement:
            replacement = create_replacement(word, counter)

        masked_text = re.sub(rf'\b{re.escape(word)}\b', replacement, masked_text)
        mapping.append(f'{replacement},{word}')
        counter += 1

    with open(RESULT_MASK_FILE, 'w') as f:
        f.write(masked_text)

    with open(RESULT_MAPPING_FILE, 'w') as f:
        f.write('\n'.join(mapping))

    print(RESULT_MASK_FILE)


def main():
    """Main execution function."""
    words, prefixes, suffixes = load_whitelist(WHITELIST_FILE)
    mask_text(INPUT_FILE, words, prefixes, suffixes, mask_numbers=False)


if __name__ == "__main__":
    main()
do_unmask.py
import os
import re

ROOT_DIR = os.path.dirname(os.path.abspath(__file__))


def load_mapping(mapping_file):
    with open(mapping_file, 'r') as f:
        return dict(line.strip().split(',') for line in f)


def unmask_text(mapping, result_file, output_file):
    with open(result_file, 'r') as f:
        masked_text = f.read()

    unmasked_text = masked_text
    for masked_var, word in mapping.items():
        unmasked_text = re.sub(rf'\b{re.escape(masked_var)}\b', word, unmasked_text)

    with open(output_file, 'w') as f:
        f.write(unmasked_text)

    print(output_file)


if __name__ == "__main__":
    mapping = load_mapping(os.path.join(ROOT_DIR, 'result_mapping.txt'))
    unmask_text(mapping, os.path.join(ROOT_DIR, 'input_unmask.txt'), os.path.join(ROOT_DIR, 'result_unmask.txt'))
  • 以下にはPythonの予約語、ビルトイン関数、標準ライブラリが入っている
  • 空白行は無視される
whitelist.txt
and
as
assert
async
await
break
class
continue
def
del
elif
else
except
False
finally
for
from
global
if
import
in
is
lambda
None
nonlocal
not
or
pass
raise
return
True
try
while
with
yield
abs
all
any
ascii
bin
bool
breakpoint
bytearray
bytes
callable
chr
classmethod
compile
complex
delattr
dict
dir
divmod
enumerate
eval
exec
filter
float
format
frozenset
getattr
globals
hasattr
hash
help
hex
id
input
int
isinstance
issubclass
iter
len
list
locals
map
max
memoryview
min
next
object
oct
open
ord
pow
print
property
range
repr
reversed
round
set
setattr
slice
sorted
staticmethod
str
sum
super
tuple
type
vars
zip
import
abc
argparse
array
asyncio
binascii
bisect
builtins
bz2
calendar
cmath
collections
contextlib
copy
csv
datetime
decimal
dis
doctest
enum
functools
gc
glob
gzip
hashlib
heapq
io
itertools
json
locale
logging
math
mmap
multiprocessing
operator
os
pathlib
pickle
pprint
queue
random
re
select
shutil
signal
socket
sqlite3
ssl
statistics
string
struct
subprocess
sys
tempfile
threading
time
timeit
tkinter
traceback
types
typing
unicodedata
unittest
urllib
uuid
venv
warnings
xml
zipfile
zlib

使い方

1.whitelist.txtを確認/修正する
2. input_mask.txtにマスクしたい部分を貼り付け
3. do_mask.pyを実行
4. result_mask.txtを確認
5. 修正する場合(1.)に戻る。OKなら(6.)へ
6. ChatGPTに投げる
7. ChatGPTの出力をinput_unmask.txtに貼り付け
8. do_unmask.pyを実行
9. result_unmask.txtを確認する

デモ

このコードを使います(特に理由ないです)。引用 https://docs.pytest.org/en/7.4.x/

# content of test_sample.py
def inc(x):
    return x + 1


def test_answer():
    assert inc(3) == 5

これを上記のwhitelistを使ってマスクします。例えば

python3 do_mask.py

このような結果になります。コメントは削除されます。

result_mask.txt
def a02(a03):
    return a03 + 1


def a01():
    assert a02(3) == 5
result_mapping.txt
a01,test_answer
a02,inc
a03,x

prefix:を使う

上記の続きで、「test_の部分はマスクしない」ようにします。その場合、prefix:test_whitelist.txtに追加します。以下のようになるはずです。

result_mask.txt
def a01(a02):
    return a02 + 1


def test_a03():
    assert a01(3) == 5
result_mapping.txt
a01,inc
a02,x
test_a03,test_answer

あとはこれを使ってChatGPTに聞けばよいです。

まとめ

  • ChatGPTに業務情報を入れないようにするためにマスクするPythonスクリプトを作りました

Discussion