見出し画像

結局アルゴリズムが大事という話

こないだ作ったメールをいい感じに要約して適当な返信文まで考えてくれるツールを「まごころメール」と名付けた。

僕は過去に書いたいろんな本で「AI時代に価値を持つのは真心と思いやり」と締めくくっていたのだが、一番真心が足りてないのは僕だったようだ。

このツール、単なるネタではなく本当に仕事のやり取りに使っている。
面白いのは、一度手動で「※この文章は人工知能が作成し、清水が確認の上送信したものです」という一文をつけたら、次からそのスレッドでは自動的にこの文章が署名のところに入るようになったこと。学習してるわけではないが、ちょっと笑ってしまった。

さて、それにつけても煩わしいのはアポイントメントである。
特に「来週どこかで時間もらえませんか?」と聞かれるのが一番面倒だ。

そこでGoogleカレンダーから自動的に予定を拾ってきて適当にいい感じの条件で空き時間を見つけるツールをClaude-3に作らせて見た。

スケジュールの取得までは「まごころメール」と同じくらい面倒な設定をGoogleのAPIでしなければならないが、一度取得してしまえば、「空き時間を数えるプログラムを書け」とかはすぐできた。

・・・のだが、ここで問題にぶち当たる。
今の所大規模言語モデルは最初のコードスニペットを表示させたり、ちょっとしたコードを解説させたりするのにはまあまあ役立つ。ただ、話が込み入ってくるとだいぶ混乱してくる。

まず、最初に書かせた「空きスケジュールをとってくるコード」はこんな感じになった。

from __future__ import print_function
import datetime
import os.path
import pickle
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request

# スコープを設定
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']

creds = None
if os.path.exists('token.pickle'):
    with open('token.pickle', 'rb') as token:
        creds = pickle.load(token)

if not creds or not creds.valid:
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())
    else:
        flow = InstalledAppFlow.from_client_secrets_file('client_secret.json', SCOPES)
        creds = flow.run_local_server(port=64516)
    with open('token.pickle', 'wb') as token:
        pickle.dump(creds, token)

# Google Calendar APIクライアントを作成
service = build('calendar', 'v3', credentials=creds)

# カレンダーIDを指定
calendar_id = 'primary'

# 指定された日時以降の予定を取得
start_time = datetime.datetime(2024, 4, 6, 0, 0, 0).isoformat() + 'Z'
events_result = service.events().list(calendarId=calendar_id, timeMin=start_time,
                                      maxResults=100, singleEvents=True,
                                      orderBy='startTime').execute()
events = events_result.get('items', [])

# 予定のある時間を取得
busy_times = []
for event in events:
    start = event['start'].get('dateTime', event['start'].get('date'))
    end = event['end'].get('dateTime', event['end'].get('date'))
    busy_times.append((start, end))

# アポイント可能な時間を提案
def suggest_available_times(start_date, end_date, start_time, end_time, duration):
    current_time = datetime.datetime.combine(start_date, start_time)
    end_datetime = datetime.datetime.combine(end_date, end_time)
    
    while current_time + duration <= end_datetime:
        available = True
        for busy_start, busy_end in busy_times:
            if busy_start <= current_time.isoformat() < busy_end:
                available = False
                current_time = datetime.datetime.fromisoformat(busy_end)
                break
        
        if available:
            print(f"Available time: {current_time.isoformat()} - {(current_time + duration).isoformat()}")
            current_time += duration
        
        current_time += datetime.timedelta(minutes=30)

# 使用例
start_date = datetime.date(2024, 4, 6)
end_date = datetime.date(2024, 4, 10)
start_time = datetime.time(9, 0)
end_time = datetime.time(17, 0) 
duration = datetime.timedelta(hours=1)

suggest_available_times(start_date, end_date, start_time, end_time, duration)

これは驚くべきことにまあまあちゃんと動く。
しかしこのコードは深夜まで候補時間として出してしまう。

夜は吾輩にとって夜の街で自然知能の会話を観察するという研究者として極めて重要なフィールドワークの時間であるため、できればこのあたりのアポは遠慮いただきたい。あと多分酔っ払ってるし。

日中のみに対応してくれと言うとその通りにコードが修正された。

def suggest_available_times(start_date, end_date, start_time, end_time, working_start_time, working_end_time, duration):
    start_datetime = datetime.datetime.combine(start_date, start_time)
    end_datetime = datetime.datetime.combine(end_date, end_time)
    current_time = start_datetime
    
    while current_time + duration <= end_datetime:
        current_date = current_time.date()
        current_working_start_time = datetime.datetime.combine(current_date, working_start_time)
        current_working_end_time = datetime.datetime.combine(current_date, working_end_time)
        
        if current_time < current_working_start_time:
            current_time = current_working_start_time
        
        if current_time + duration > current_working_end_time:
            current_time = current_working_start_time + datetime.timedelta(days=1)
            continue
        
        available = True
        nearest_busy_end_time = None
        
        for busy_start_date, busy_start_time, busy_end_date, busy_end_time in busy_times:
            busy_start_datetime = datetime.datetime.combine(datetime.date.fromisoformat(busy_start_date), datetime.time.fromisoformat(busy_start_time))
            busy_end_datetime = datetime.datetime.combine(datetime.date.fromisoformat(busy_end_date), datetime.time.fromisoformat(busy_end_time))
            
            if busy_start_datetime <= current_time < busy_end_datetime:
                available = False
                current_time = busy_end_datetime
                break
            
            if current_time < busy_start_datetime and (nearest_busy_end_time is None or busy_end_datetime < nearest_busy_end_time):
                nearest_busy_end_time = busy_end_datetime
        
        if available:
            if nearest_busy_end_time is not None and current_time + duration + datetime.timedelta(minutes=30) > nearest_busy_end_time:
                current_time = nearest_busy_end_time + datetime.timedelta(minutes=30)
            else:
                print(f"Available time: {current_time.strftime('%Y-%m-%d %H:%M:%S')} - {(current_time + duration).strftime('%Y-%m-%d %H:%M:%S')}")
                current_time += duration + datetime.timedelta(minutes=30)
        else:
            current_time += datetime.timedelta(minutes=30)
        
        current_time = min(current_time, current_working_end_time)
        
        if current_time >= current_working_end_time:
            current_time = current_working_start_time + datetime.timedelta(days=1)

ついでに前の予定との間隔を最低30分あけてくれとか、繋がってる予定はひとつにしてくれとか、項目に「移動」と書いてあったら移動時間を一時間確保してくれとか、「新幹線」とか「飛行機」と書いてあったらその日はその時間以降の予定は全てブロックしてくれだとか、こういう無茶を言うとこんなコードになった。

def suggest_available_times(start_date, end_date, start_time, end_time, working_start_time, working_end_time, duration):
    start_datetime = datetime.datetime.combine(start_date, start_time)
    end_datetime = datetime.datetime.combine(end_date, end_time)
    current_time = start_datetime
    available_start_time = None
    
    while current_time + duration <= end_datetime:
        current_date = current_time.date()
        current_working_start_time = datetime.datetime.combine(current_date, working_start_time)
        current_working_end_time = datetime.datetime.combine(current_date, working_end_time)
        
        if current_time < current_working_start_time:
            if available_start_time is not None:
                print(f"Available time: {available_start_time.strftime('%Y-%m-%d %H:%M:%S')} - {(current_working_start_time - datetime.timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')}")
                available_start_time = None
            current_time = current_working_start_time
        
        if current_time + duration > current_working_end_time:
            if available_start_time is not None:
                print(f"Available time: {available_start_time.strftime('%Y-%m-%d %H:%M:%S')} - {(current_working_end_time - datetime.timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')}")
                available_start_time = None
            current_time = current_working_start_time + datetime.timedelta(days=1)
            continue
        
        available = True
        nearest_busy_start_time = None
        
        for event in events:
            busy_start_date, busy_start_time, busy_end_date, busy_end_time = event['start_date'], event['start_time'], event['end_date'], event['end_time']
            busy_start_datetime = datetime.datetime.combine(datetime.date.fromisoformat(busy_start_date), datetime.time.fromisoformat(busy_start_time))
            busy_end_datetime = datetime.datetime.combine(datetime.date.fromisoformat(busy_end_date), datetime.time.fromisoformat(busy_end_time))
            
            if "移動" in event['summary']:
                busy_start_datetime -= datetime.timedelta(hours=1)
            if "新幹線" in event['summary'] or "飛行機" in event['summary']:
                if current_time >= busy_end_datetime:
                    print(f"No available times after {busy_end_datetime.strftime('%Y-%m-%d %H:%M:%S')} due to travel.")
                    return
            
            if busy_start_datetime <= current_time < busy_end_datetime:
                available = False
                current_time = busy_end_datetime
                break
            
            if current_time < busy_start_datetime and (nearest_busy_start_time is None or busy_start_datetime < nearest_busy_start_time):
                nearest_busy_start_time = busy_start_datetime
        
        if available:
            if available_start_time is None:
                available_start_time = current_time
            
            if nearest_busy_start_time is not None and current_time + duration > nearest_busy_start_time - datetime.timedelta(minutes=30):
                print(f"Available time: {available_start_time.strftime('%Y-%m-%d %H:%M:%S')} - {(nearest_busy_start_time - datetime.timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')}")
                available_start_time = None
                current_time = nearest_busy_start_time
            else:
                current_time += duration
        else:
            if available_start_time is not None:
                print(f"Available time: {available_start_time.strftime('%Y-%m-%d %H:%M:%S')} - {(min(current_time, current_working_end_time) - datetime.timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')}")
                available_start_time = None
            current_time += datetime.timedelta(minutes=30)
        
        if current_time >= current_working_end_time:
            if available_start_time is not None:
                print(f"Available time: {available_start_time.strftime('%Y-%m-%d %H:%M:%S')} - {(current_working_end_time - datetime.timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')}")
                available_start_time = None
            current_time = current_working_start_time + datetime.timedelta(days=1)

で、このコード、一見動きそうだし、一見動いてしまう。

最初、僕は、「ああ、これでもう僕らはアルゴリズムとかややこしいことを考えることをやめる日が来たのか」と目を細めた。まあそれにしたってアルゴリズムは大事だよな、と思いつつ。

しかし、このプログラムが全くのタコな代物で、重要なクライアントとの予定を無視するわ、予定でギチギチなのに一日あいてますみたいな寝言を言い始めるのを見て怒りが湧いてきた。いや、本当は俺が悪いんだけど。

ダメだ。このポンコツ機械知能が。貴様らはコードがわかるフリしかできてないじゃないか。だいたい、この問題を解くのにアホみたいにループを繰り返している。最初はClaude-3が書いたコードを、学校の先生気分で修正してやろうかと思ったが全然ダメ。これ典型的なゴミコードじゃん。

まあとはいえだいぶ省力化できた。
この手のやつはまずGoogleのAPIとかを調べるところが最初のハードルで、これだけで下手すりゃ半日使ってしまう。それが数分で解決できた。次に、そもそも時間関係を扱うコードは書くのが面倒くさい。それも、このコードではほとんど解決されている。

このプログラムがなぜ全然ダメかはコードを読めばなんとなくわかると思うが、そもそもこのプログラムで使われているアルゴリズムがダメダメなのである。

このプログラムがやりたいことは、「予定の隙間を見つけて報告する」というバカみたいな処理なのに、ループの中でループするという謎の処理をもっともらしく書いている。このあたりが所詮機械知性の限界か。これ以下の自然知能もいるけどな。

やれやれ、まだ人類の出番は当分なくならなそうだぜ。

というわけで僕は久しぶりにアルゴリズム脳を起動し、極めて雑だが確実に成果の出るアルゴリズムに書き直した。


# 予定のある時間を取得# 予定のある時間を取得
slot=[0 for _ in range(365*24*2*2)]

def slotAddr(date,time):
    return (date-datetime.date(2024,1,1)).days*24*2+time.hour*2+time.minute//30

busy_times = []
for event in events:
    start = event['start'].get('dateTime', event['start'].get('date'))
    end = event['end'].get('dateTime', event['end'].get('date'))
    summary = event['summary']
    
    if 'T' in start:
        start_date, start_time = start.split('T')
        start_time = start_time.split('+')[0]  # タイムゾーン情報を削除
    else:
        start_date = start
        start_time = '00:00:00'  # 時刻が指定されていない場合は00:00:00とする
    
    if 'T' in end:
        end_date, end_time = end.split('T')
        end_time = end_time.split('+')[0]  # タイムゾーン情報を削除
    else:
        end_date = end
        end_time = '23:59:59'  # 時刻が指定されていない場合は23:59:59とする
        

    idx1 = slotAddr(datetime.date.fromisoformat(start_date), datetime.time.fromisoformat(start_time))
    idx2 = slotAddr(datetime.date.fromisoformat(end_date), datetime.time.fromisoformat(end_time))

    if "移動" in summary:
        idx1-=2 # 移動がある場合、1時間(2コマ)あける
    if "飛行機" in summary or "新幹線" in summary:
        # 飛行機か新幹線がある場合、半日あける
        end_time= working_end_time
        idx2 = slotAddr(datetime.date.fromisoformat(end_date), end_time)

    print(idx1,idx2)
    for idx in range(idx1, idx2):
        slot[idx]=1
    
    print(f"{start_date} {start_time} - {end_date} {end_time}: {summary}")
    busy_times.append((start_date, start_time, end_date, end_time, summary))


def suggest_available_times(start_date, end_date, start_time, end_time, working_start_time, working_end_time, duration):
    start_datetime = datetime.datetime.combine(start_date, start_time)
    end_datetime = datetime.datetime.combine(end_date, end_time)
    current_time = start_datetime
    available_start_time = None
    
    while current_time + duration <= end_datetime:
        current_date = current_time.date()
        current_working_start_time = datetime.datetime.combine(current_date, working_start_time)
        current_working_end_time = datetime.datetime.combine(current_date, working_end_time)
        
        available = True
        nearest_busy_start_time = None
        #print(f"current_time: {current_time}")
        idx=slotAddr(current_date,current_time)
        
        if  available_start_time is None:
            if slot[idx] == 0:
                available_start_time=current_time
        else:
            if slot[idx] != 0:
                available_end_time=current_time
                print(f"{available_start_time.strftime('%m/%d %H:%M')} - {(available_end_time).strftime('%H:%M')}")
                available_start_time=None
        current_time +=  datetime.timedelta(minutes=30)
        if current_time>=current_working_end_time:
            current_time = datetime.datetime.combine(current_date + datetime.timedelta(days=1), working_start_time)
    

Claude-3が出力したコードは、あくまでも「予定を順番に睨みながら空き時間を減らす」というものだが、これだとループが必要以上に複雑になって見通しが悪い。

そこで僕はシンプルに「30分単位で2年分の空きがある架空のカレンダー」を作り、予定がある時間帯を塗りつぶす第一段階フェーズ・ワンと、日中の連続した空き時間を探索する第二段階フェーズ・ツーに分けるプログラムを書いた。

こっちの方がコードは短いし、移動時間とか飛行機とか新幹線とかに対応しやすい。たとえば「ゴジラの新作が出る日は前日からブロック」みたいなことも簡単にできる。

やっぱりアルゴリズムは人間が考えた方が早いし確実だわ。まだまだ。
まあたぶん数年以内にそんなことも必要なくなるとは思うが。

というわけで、やはりAI時代には、いやもしかすると、AI時代こそ、アルゴリズムを教養として学ぶべきということで、「教養としてのAI講座」シラス特別講義シリーズとして来週から「教養としてのアルゴリズム講座」がスタートします。それと、「AI時代の仕事論II AIを仕事にどう活かしていくか」という新シリーズも今日からスタート。この二本のシリーズを隔週でやっていきます。特にアルゴリズムは本職のプログラマーでもよく知らないとか、聞いたことはあるけど使ったことないという人がいたりするのですが、プログラマーでなくてもアルゴリズムを知っておくと生活のいろんなことに応用できるのでおすすめです。マンガもあるよ

今回、AI使ってないので普通にソースコードは以下

from __future__ import print_function
import datetime
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
import pickle
import os

working_start_time = datetime.time(10, 0)  # 朝8時
working_end_time = datetime.time(18, 0)   # 夜5時


# スコープを設定
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']

creds = None
if os.path.exists('token.pickle'):
    with open('token.pickle', 'rb') as token:
        creds = pickle.load(token)

if not creds or not creds.valid:
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())
    else:
        flow = InstalledAppFlow.from_client_secrets_file('client_secret.json', SCOPES)
        creds = flow.run_local_server(port=64526)
    with open('token.pickle', 'wb') as token:
        pickle.dump(creds, token)

# Google Calendar APIクライアントを作成
service = build('calendar', 'v3', credentials=creds)
# カレンダーIDを指定
calendar_id = 'primary'

# 指定された日時以降の予定を取得
start_time = datetime.datetime(2024, 4, 6, 0, 0, 0).isoformat() + 'Z'
events_result = service.events().list(calendarId=calendar_id, timeMin=start_time,
                                      maxResults=100, singleEvents=True,
                                      orderBy='startTime').execute()
events = events_result.get('items', [])

# 予定のある時間を取得# 予定のある時間を取得
slot=[0 for _ in range(365*24*2*2)]

def slotAddr(date,time):
    return (date-datetime.date(2024,1,1)).days*24*2+time.hour*2+time.minute//30

busy_times = []
for event in events:
    start = event['start'].get('dateTime', event['start'].get('date'))
    end = event['end'].get('dateTime', event['end'].get('date'))
    summary = event['summary']
    
    if 'T' in start:
        start_date, start_time = start.split('T')
        start_time = start_time.split('+')[0]  # タイムゾーン情報を削除
    else:
        start_date = start
        start_time = '00:00:00'  # 時刻が指定されていない場合は00:00:00とする
    
    if 'T' in end:
        end_date, end_time = end.split('T')
        end_time = end_time.split('+')[0]  # タイムゾーン情報を削除
    else:
        end_date = end
        end_time = '23:59:59'  # 時刻が指定されていない場合は23:59:59とする
        

    idx1 = slotAddr(datetime.date.fromisoformat(start_date), datetime.time.fromisoformat(start_time))
    idx2 = slotAddr(datetime.date.fromisoformat(end_date), datetime.time.fromisoformat(end_time))

    if "移動" in summary:
        idx1-=2 # 移動がある場合、1時間(2コマ)あける
    if "飛行機" in summary or "新幹線" in summary:
        # 飛行機か新幹線がある場合、半日あける
        end_time= working_end_time
        idx2 = slotAddr(datetime.date.fromisoformat(end_date), end_time)

    print(idx1,idx2)
    for idx in range(idx1, idx2):
        slot[idx]=1
    
    print(f"{start_date} {start_time} - {end_date} {end_time}: {summary}")
    busy_times.append((start_date, start_time, end_date, end_time, summary))


def suggest_available_times(start_date, end_date, start_time, end_time, working_start_time, working_end_time, duration):
    start_datetime = datetime.datetime.combine(start_date, start_time)
    end_datetime = datetime.datetime.combine(end_date, end_time)
    current_time = start_datetime
    available_start_time = None
    
    while current_time + duration <= end_datetime:
        current_date = current_time.date()
        current_working_start_time = datetime.datetime.combine(current_date, working_start_time)
        current_working_end_time = datetime.datetime.combine(current_date, working_end_time)
        
        available = True
        nearest_busy_start_time = None
        #print(f"current_time: {current_time}")
        idx=slotAddr(current_date,current_time)
        
        if  available_start_time is None:
            if slot[idx] == 0:
                available_start_time=current_time
        else:
            if slot[idx] != 0:
                available_end_time=current_time
                print(f"{available_start_time.strftime('%m/%d %H:%M')} - {(available_end_time).strftime('%H:%M')}")
                available_start_time=None
        current_time +=  datetime.timedelta(minutes=30)
        if current_time>=current_working_end_time:
            current_time = datetime.datetime.combine(current_date + datetime.timedelta(days=1), working_start_time)
        
        
# 使用例
start_date = datetime.date(2024, 4, 7)
end_date = datetime.date(2024, 4, 17)
start_time = datetime.time(9, 0)
end_time = datetime.time(17, 0) 
duration = datetime.timedelta(hours=1)


suggest_available_times(start_date, end_date, start_time, end_time, working_start_time, working_end_time, duration)