アラートメールを判別させたい。 - その x はワイルドカード？ -

こんにちは。NETS1の田中(た)です。

[アラートメールを判別させたい。 - XXXXXはワイルドカードなんです！ -](https://qiita.com/tatsumiya2015/items/515e40a721b23f3300f9) の続きです。

最終日が誰も登録されていないようなので書くことにしました。

# うまく判定してくれないメールの登場

ある時こんなアラートメールが届きました。

```
Dec 14 12:59:12 app001 2020/12/14 12:59:12.449 app001 ERROR ERROR002 ecnxeci-1349 すごいことがおきました。 sugoi[10223] でエラー あぁ、なんという！
```

一覧表をみれば明らかに電話連絡とわかります。

|エラーコード|エラーメッセージ|対応|
|:----------|:--------------|:------------:|
|ERROR002|app00x ERROR ERROR002 xxxxx すごいことがおきました。 XXXXX[10223] でエラー|電話連絡|
|ERROR002|すごいことがおきました。 XXXXX[YYYYY] でエラー|メール連絡|
|ERROR002|すごいことがおきました。 |無視|

でも、前回のコードで結果をみてみると…

![result2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379557/5c20489c-b965-68d7-9389-300d92f8e3f2.png)

ecn が delete 判定されてしまっています。本当なら、ecnxeci-1349 を replace として判定してほしいのですが、
ecnxeci-1349 部分に含まれている x が一覧表側の xxxxx の部分とマッチしたと勘違いしてしまったのです。 

# 頑張って対応する
## 対応方針

 x が一致しちゃうなら x じゃなければいいじゃない！

言うだけなら簡単です。xxxxx にマッチするような x 以外に置き換えればいいのです。
でもどうやって、x がワイルドカードの期待しない一致だと判断させればよいのでしょうか…。

## ワイルドカードの条件

ワイルドカードがどんな場合かを定義しなければ、ワイルドカードだと判断できません。

 * x, X の場合 … 例: app00x の x
 * アルファベットが連続している場合 … 例: xxxxxx yyyyyy

おおよそ、人が見やすいように書いた時のワイルドカードはこんなところだと思います。
difflib で評価したときに replace として評価されていて、かつマニュアル(一覧表)側の
文字列が上記条件に当てはまればワイルドカードといえるでしょう。

## ワイルドカードの期待しない一致かどうかの判断

どうやって判断するかですが、difflib の評価値を活用することにしました。

 1. ワイルドカードと判断された部分はマニュアル側をメール側の文字列と置き換えて評価値を出す。
 2. equal として評価されている文字列がワイルドカード条件に当てはまるか
 3. 当てはまればメール側を適当な文字列に置き換えて再評価する
 4. 再評価したときに 1. の評価値と比較して高くなっていれば予期しない一致と判断

今回の例でいうと、

```
 : Dec 14 12:59:12 app001 2020/12/14 12:59:12.449  |
equal : app00 | app00
replace : 1 | x
equal :  ERROR ERROR002  |  ERROR ERROR002
delete : ecn |
equal : x | x
replace : eci-1349 | xxxx
equal :  すごいことがおきました。  |  すごいことがおきました。
replace : sugoi | XXXXX
equal : [10223] でエラー | [10223] でエラー
 :  あぁ、なんという！ |
```

この結果から、１．では新しく以下のような評価文字列を作成し、評価値を出します。

```
app001 ERROR ERROR002 xeci-1349 すごいことがおきました。 sugoi[10223] でエラー
```

この時、当然 xeci-1349 部分がメール側の文字列と一致しないため、評価値は最大の 1.0 にはなりません。
次に、2., 3. では```equal : x | x```の部分の x を適当な文字列にして、以下のようなメール側文字列を作成します。
(前処理の段階でメールの不要な先頭・後方は削除されてます)

```
app001 ERROR ERROR002 ecnieci-1349 すごいことがおきました。 sugoi[10223] でエラー
```
そして、このメール文字列をつかって 1. から再評価します。
すると、再評価の 1. では新しく以下のような評価文字列を作成し、評価値を出します。

```
app001 ERROR ERROR002 ecnieci-1349 すごいことがおきました。 sugoi[10223] でエラー
```

この時、再評価値は最大の 1.0 となり、評価値があがるので、予期しない一致だったと分かるわけです。

## 実装してみる
main 部分は前と同じなので省略

```python
def search_space(message):
    '''スペースの次の文字位置を返す（都合上先頭はスペースとする）'''
    space_pos = [0]
    index = 0
    for c in message:
        if c == ' ':
            space_pos.append(index + 1)
        index+=1
    return space_pos

def is_replacement(string):
    # x, X なら置換それ以外で一文字なら対象外
    if len(string) <= 1:
        if string.lower() == 'x':
            return True
        return False

    # 連続同文字でないなら対象外
    pre_char = string[1]
    for char in string:
        if pre_char != char:
            return False
    return True

def diff_analyzer(skip_seek, msg, man_msg):
    fix = 0
    fix_man_msg = man_msg
    fix_msg = msg
    opcodes = [('', 0, skip_seek, 0, 0)]

    seq = difflib.SequenceMatcher(None, msg, man_msg)
    ratio = seq.ratio()

    for tag, i1, i2, j1, j2 in seq.get_opcodes():
        fj1 = j1 + fix
        fj2 = j2 + fix

        if tag == 'replace':
            # ワイルドカード置換対象はマニュアル側を変更し、新しいタグ(fix_equal)をつける
            if is_replacement(fix_man_msg[fj1:fj2]):
                fix_man_msg = fix_man_msg[:fj1] + msg[i1:i2] + fix_man_msg[fj2:]
                fix = fix + i2 - i1 - (fj2 - fj1)
                tag = 'fix_equal'
        elif tag == 'equal':
            # メッセージ側のランダム文字列にたまたまマニュアル側のxあたりがマッチすることを想定
            # スペースを除き1文字で等しい場合は強制変更(変更するとまずい場合は再評価時に評価が下がるはず)
            # マッチする場合はメッセージ側を適当な文字で置き換える
            if (fj2 - fj1 == 1 and fix_man_msg[fj1:fj2] != ' ') or is_replacement(fix_man_msg[fj1:fj2]):
                replace_msg = ''
                for letter in msg[i1:i2]:
                    # unicode に 100 足して違う文字に置き換える(超乱暴)
                    replace_msg += chr(ord(letter) + 100)
                fix_msg = fix_msg[:i1] + replace_msg + fix_msg[i2:]

        opcodes.append((tag, skip_seek + i1, skip_seek + i2, j1, j2))
        finish_seek = skip_seek + i2

    # ワイルドカード置き換え時に ratio だけ再評価する
    if fix_man_msg != man_msg:
        ratio = difflib.SequenceMatcher(None, msg, fix_man_msg).ratio()

    # 予期しない一致のときに再評価を掛ける
    # 再評価の結果予期しない一致でない場合は fix_msg を破棄する
    if fix_msg != msg:
        f_seek, f_opcodes, f_ratio = diff_analyzer(skip_seek, fix_msg, man_msg)
        print(f_ratio, ':', fix_msg)
        print(ratio, ':', msg)
        if ratio < f_ratio:
            finish_seek = f_seek
            opcodes = f_opcodes
            ratio = f_ratio
        else:
            fix_msg = msg

    return (finish_seek, opcodes, ratio)

def check_message_by_difflib(manual, message):

    space_pos = search_space(message)

    ratio = 0
    # スペース毎に先頭と考えて評価していく
    for i in space_pos:
        msg = message[i:]
        delete_flag = False

        # 最後が delete で終わっていたら削除して評価する
        tag, i1, i2, j1, j2 = difflib.SequenceMatcher(None, msg, manual).get_opcodes()[-1]
        if tag == 'delete':
            msg = msg[:i1]
            delete_flag = True

        finish_seek, tmp_opcodes, tmp_ratio = diff_analyzer(i, msg, manual)

        if ratio <= tmp_ratio:
            if delete_flag:
                tmp_opcodes.append(('', finish_seek, len(message), 0, 0))
            ratio = tmp_ratio
            opcodes = tmp_opcodes

    return opcodes, ratio
```

## テスト

```
... 省略 ...

 : Dec 14 12:59:12 app001 2020/12/14 12:59:12.449  |
equal : app00 | app00
fix_equal : 1 | x
equal :  ERROR ERROR002  |  ERROR ERROR002
fix_equal : ecnxeci-1349 | xxxxx
equal :  すごいことがおきました。  |  すごいことがおきました。
fix_equal : sugoi | XXXXX
equal : [10223] でエラー | [10223] でエラー
 :  あぁ、なんという！ |
```

ecnxeci-1349 がワイルドカード部分(fix_equal)と評価されていて、なんだか良さそうな感じです。
でも本当にワイルドカード部分だけ判断出来ているのか気になるので、こんな文字列でも比較してみます。

メール側 … app001 ERROR fix_data.sh error
マニュアル側 … app00x ERROR boxdata.sh error

実行結果

```
0.8813559322033898 : app001 ERROR fiÜ_data.sh error
0.9152542372881356 : app001 ERROR fix_data.sh error
0.7307692307692307 : ERROR fiÜ_data.sh error
0.7692307692307693 : ERROR fix_data.sh error
0.5652173913043478 : fiÜ_data.sh error
0.6086956521739131 : fix_data.sh error
app001 ERROR fix_data.sh error

 :  |
equal : app00 | app00
fix_equal : 1 | x
equal :  ERROR  |  ERROR
replace : fi | bo
equal : x | x
delete : _ |
equal : data.sh error | data.sh error
```

正しく```equal : x | x```のままになっています。
1，2行目をみても狙い通り置き換えた後の評価値は落ちています。
なんだか良さそうな雰囲気です。

# 評価値を出したし判定させてみる

評価値が 1.0 で一番長くマッチしているものが判定結果となるように main を変えればいいと思います。
こんな感じ。

```python
mail = 'Dec 14 12:59:12 app001 2020/12/14 12:59:12.449 app001 ERROR ERROR002 ecnxeci-1349 すごいことがおきました。 sugoi[10223] でエラー あぁ、なんという！'
manual1 = 'app00x ERROR ERROR002 xxxxx すごいことがおきました。 XXXXX[10223] でエラー'
manual2 = 'すごいことがおきました。 XXXXX[YYYYY] でエラー'
manual3 = 'すごいことがおきました。'

max_match_length = 0
result = '該当なし'
for manual in manuals:
    opcodes, ratio = check_message_by_difflib(manual, mail)
    match_length = sum([opcode[2] - opcode[1] for opcode in opcodes if opcode[0] == 'fix_equal' or opcode[0] == 'equal'])

    if ratio == 1:
        if max_match_length < match_length:
            max_match_length = match_length
            result = manual

print('結果：' + result)
```

実行結果

```
結果：app00x ERROR ERROR002 xxxxx すごいことがおきました。 XXXXX[10223] でエラー
```

# さいごに

頑張って機械的にそこそこ判定できるようにしましたが、結局最後は目視確認です。
ツールのバグもあるだろうし、「xxx って文字数も一致してないとだめなんだよねー」とかは対応してないですし。~~（じつはマニュアル側に誤字があったり）~~
機械を信じすぎて、本当に重要なアラートの判定を間違わないように・・・。

将来的には、opcodes の結果と実際の判断結果から判定間違いを補正していけるようになったらいいなぁと考えていたりもします。