ãã®èšäºã¯BASE Advent Calendar 2020ã®11æ¥ç®ã®èšäºã§ãã devblog.thebase.in BASEæ ªåŒäŒç€Ÿ Data Strategy ããŒã ã® @tawamura ã§ãã BASEã§ã¯ãªãŒããŒã®çæ§ã賌å
¥è
æ§ã®ãåãåããã«å¯ŸããŠãCustomer SupportããŒã ãäž»ãšãªã£ãŠå¯Ÿå¿ãããŠããŸãããã®äžã§ãããã€ãã®æè¡çãªãåãåããã«å¯ŸããŠã¯ã以äžã®ããã«Slackã®å°çšãã£ã³ãã«ãéããŠéçºãšã³ãžãã¢ã«è³ªåãæããŠåçãäœæããããšã«ãªã£ãŠããŸãã CSããŒã ãã調æ»ãäŸé Œããããåãåããã®äŸ ãããã®CSåãåãã察å¿ã¯æ¥ã
ããã€ãçºçããŠããã CSãåãåãã察å¿ãåœçªå¶ã«ããŠéçšããŠã¿ã話 ã§ãããããã«é±ããšã«æã¡åãå¶ã§åéšéã®ãšã³ãžãã¢ã察å¿ããŠããã®ã§ãããã©ãããŠã調æ»ã察å¿ã«æéãåãããŠããŸããšããåé¡ãçºçããŠããŸããã devblog.thebase.in ãã ãããã€ãã®æ°èŠåãåããã«é¢ããŠã¯éå»ã«åæ§ã»é¡äŒŒã®ãåãåããäºäŸãããã調æ»ãåçã®åèã«ãªãå ŽåããããŸããããããªãã°ãéå»ã®é¡äŒŒã®æçš¿ãèªåã§åã£ãŠããŠBotãæç€ºããããšã§ãåãåãã察å¿ã®äžå©ãšãªãããšæããŸããã Botã®æç€ºäŸïŒç€Ÿå
æ
å ±ãå€ãããã¹ã¯ã°ããã§æçž®ã§ãïŒ éå»ã®é¡äŒŒãããåãåããèª¿æ» é¢é£ãã瀟å
ããã¥ã¡ã³ã ä»åã¯ããã®åãåãã察å¿åèªååã®ã·ã¹ãã æ§ç¯ã«ã€ããŠã話ããããŠããã ããŸããã¡ãªã¿ã«ããã®å
容㯠Data Strategy ããŒã ã® HackWeek ã®å°å
¥ãšãã®å¹æ ã§è¡ãªã£ã1é±éã®å®æœã¿ã¹ã¯ã§ããŠãä»åã¯ããã§ã®çµæã«ããå°ãæ©èœè¿œå ãªã©ãè¡ãæŽããèšäºã«ãªããŸãã devblog.thebase.in æè¡éžå®ãã·ã¹ãã æŠèг SlackãAPI Gatewayãªã©ã®é£æºèšå® éå»ã®Slackæçš¿ã®ååŸãšè§£æãElasticsearchãžã®ä¿å Slackæçš¿ã®ååŸ åãåããææžã®è§£æ Elasticsearchã«éå»åãåãããä¿å åŠçLambdaã®å®è£
Slackåšã æ€çŽ¢ã¯ãšãªã®æœåºããžã㯠Kibelaæ€çŽ¢ã¯ãšãªã«ã€ã㊠ããã〠çµããã« æè¡éžå®ãã·ã¹ãã æŠèг åŒç€Ÿã¯AWSã§ç°å¢æ§ç¯ãè¡ãªã£ãŠããéšåãå€ããããä»åãAWSç°å¢ãåæã«é²ããŸãã çµæçã«ä»¥äžã®ãããªã·ã¹ãã ãæ§ç¯ããŸããã åèæ
å ±èªåæçš¿ã·ã¹ãã ãŸããç¹å®ãã£ã³ãã«ã«ãããŠSlackã«æ°èŠåãåããããã£ãæã«ãããã€ãã³ããšããŠæŸããŸããããã§æŸã£ãã€ãã³ããAWSäžã§æŽ»çšããããããã«API Gatewayã䜿çšããŸãã API Gatewayã§ã€ãã³ããåãåã£ãåŸã«ãå®éã«ãã®å
容ã«å¯ŸããŠåŠçãè¡ãã®ã§ãããåãåããã¯å€ããšèšã£ãŠããã€ãªãŒã§æ°åä»¶ã«ã¯äžããªãçšåºŠã®çºçé »åºŠã§ãããããŸãéãåŠçãè¡ãããã§ã¯ãªãããšãããAWS Lambdaã䜿çšããããšã«ããŸããã Lambdaå
ã§ã¯ãåãåããå
容ã«ã€ããŠé¡äŒŒããéå»ã®åãåããã®æ€åºãšãåŒç€Ÿã§ããã¥ã¡ã³ããµãŒãã¹ãšããŠäœ¿çšããŠããKibelaã®èšäºã®ãã¡é¢é£ããèšäºãååŸããåŠçãè¡ããŸããéå»ã®åãåããæ€åºã«ã¯ã倧éã®Slackããã¥ã¡ã³ãããå
šææ€çŽ¢ã«ããååŸããããšãèããŠElasticsearchã䜿çšããŸããã åãåã£ãæ
å ±ããã¬ã¹ãã³ã¹ãæŽåœ¢ããSlackã®ç¹å®ã®ãã£ã³ãã«ã«æçš¿ããŸããä»åã¯ãããŸã§ãµãæ©èœãšããŠã®æäŸã§èããŠããã®ã§ãå¥éèªååçãã£ã³ãã«ãèšããŠãã¡ãã«æžã蟌ã¿ããããŸããã åŒç€Ÿã®å ŽåãSlackã«æçš¿ããéšåã«ã€ããŠã¯ãSNSã«ç¹å®ã®ã€ãã³ããæããããšã§å®çµãããããªã·ã¹ãã ããã§ã«ãããŸãã®ã§ãå
·äœçã«ã¯ä»¥äžã®ãããªãããŒã«ãªã£ãŠããŸãããã¡ãã®Slackæçš¿ã·ã¹ãã ã«ã€ããŠã¯æ¬èšäºã§ã¯å²æããçŽæ¥Slackã«æçš¿ã§ãããããªãã®ã玹ä»ããŸãã åèæ
å ±èªåæçš¿ã·ã¹ãã ïŒSlackæçš¿éšååé¢ïŒ SlackãAPI Gatewayãªã©ã®é£æºèšå® Slackã®æçš¿å
容ãLambdaã§è§£æããããã«ã¯ãSlackã®æçš¿ã€ãã³ããæŸãAPI GatewayãéããŠAWSå
ã§æ±ããããã«ããLambdaã«çŽã€ããããšã§ã€ãã³ãå
容ãåŠçãããšããäžé£ã®é£æºã®èšå®ãå¿
èŠã«ãªããŸãã ãã¡ãã®å
容ã«ã€ããŠã¯ã以äžã®èšäºã§åæ§ã®é£æºèšå®ããŸãšããŠãããŸãã®ã§ããã¡ããåèã«æ§ç¯ããŠããã ããã°ãšæããŸããå³è§£ã詳ãããšãŠãããããããã§ãã qiita.com äžèšé£æºãè¡ãããšã§ãBotã远å ãããã£ã³ãã«ã§ã®æ°èŠæçš¿ã«ã€ããŠãLambdaã§ã®åå¥åŠçãè¡ãããšãã§ããŸãã ä»åã¯Pythonã§ã®åŠçãæ³å®ããŠããã®ã§ãLambdaäœææã®ã©ã³ã¿ã€ã ã§ãããèšäºã«ããããã«Python 3.6ãPython 3.7ãªã©ã«æå®ããŠãã ããã éå»ã®Slackæçš¿ã®ååŸãšè§£æãElasticsearchãžã®ä¿å Slackæçš¿ã®ååŸ äœæããBotã®tokenãå©çšããŠããŸãã¯éå»ã®Slackã®åãåããæçš¿ã®ååŸãè¡ããŸããJupyter Notebookãªã©ã§å®æœããã®ããå§ãããŸãã headers = { "Content-type" : "application/json" , "Authorization" : f "Bearer {token}" # ååŸããslack botã®token } # 決ãã§2018幎以éã®æçš¿ãååŸ def fetch_messages_by_channel (channel_id): oldest_ts = None start_date = pd.to_datetime( '2018-01-01' ) endpoint = 'https://slack.com/api/conversations.history' ls_messages = [] while True : payload = { 'channel' : channel_id, 'latest' : oldest_ts, 'count' : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() messages = data[ 'messages' ] ls_messages.extend(messages) if data[ 'has_more' ]: time.sleep( 1 ) oldest_ts = messages[- 1 ][ 'ts' ] oldest_datetime = pd.to_datetime(oldest_ts, unit= 's' ) sys.stdout.write(f " \r {oldest_datetime}" ) sys.stdout.flush() if oldest_datetime < start_date: sys.stdout.write(f " \r finish!" + ' ' * 50 ) break else : break df = pd.DataFrame(ls_messages) df[ 'channel_id' ] = channel_id return df 察象ãšãªããã£ã³ãã«ã®IDã¯ä»¥äžã®ãšã³ããã€ã³ãããååŸå¯èœïŒãã£ã³ãã«æ°ã1000ãè¶
ããå Žåã¯ãé©å®ã«ãŒãååŸãããŠãã ããïŒã以äžã¯å¯Ÿè±¡ãã£ã³ãã«ã #ãåãåãããã£ã³ãã« ã®å Žåã®äŸã§ãã endpoint = 'https://slack.com/api/conversations.list' payload = { "limit" : 1000 } data = requests.get(endpoint, headers=headers, params=payload).json() channel_df = pd.DataFrame(data[ 'channels' ]) display(channel_df.query( "name == 'ãåãåãããã£ã³ãã«'" )) ååŸããããã£ã³ãã«ã®IDãããã£ãããå
ã»ã©ã®é¢æ°ã«æž¡ãããšã§ååž°çã«éå»ã®æçš¿ãååŸã§ããŸãã messages_df = fetch_messages_by_channel(channel_id) 2018幎以éã®æçš¿ãååŸããã®ã§ããã1000ä»¶ãã€ååŸããŠãã£ãŠ2018幎以åã«ãªã£ãŠãããçµäºãããšããã«ãŒããªã®ã§ãå³å¯ã«ã¯2017幎åŸåã«äžéšãå«ãŸããŸãã ç¶ããŠãååŸããããŒã¿ãæŽåœ¢ããŠãããŸããåæã«è©²åœã®æçš¿ãžãªãã€ã¬ã¯ãã§ãããªã³ã¯ãååŸæ
å ±ããäœæããŸãã # typeã®éžå¥ not_message_types = [ 'channel_join' , 'channel_leave' , 'channel_topic' , 'channel_archive' , 'channel_purpose' , 'sh_room_created' , 'channel_name' , 'pinned_item' , 'reminder_add' , 'app_conversation_join' ] messages = messages[~messages[ 'subtype' ].isin(not_message_types)] # slack linkã®äœæïŒãã¡ã€ã³åã¯é©å®å€æŽããŠãã ããïŒ messages[ "link" ] = "https://xxx.slack.com/archives/" + messages[ "channel_id" ] + "/p" + messages[ "ts" ].str.replace( '.' , '' ) 次ã«åãåããæçš¿ã®æœåºãè¡ããŸããåŒç€Ÿã®å Žåã @cs_dev_team ãšããã¡ã³ã·ã§ã³ã°ã«ãŒãã§æçš¿ãããŠãããã®ãããã«è©²åœããããããããã®æçš¿ãæœåºããŸãã # subteamã®IDã¯messages_dfã®äžèº«ã確èªãããªã©ããŠä»£å
¥ããŠãã ãã cs_res_df = messages[messages[ "text" ].str.contains( "<!subteam^*********|@cs_dev_team>" )] ããã§æéäžã®å
šæçš¿ messages_df ãšåãåããæçš¿ã® cs_res_df ãäœãããããšã«ãªããŸãã åãåããææžã®è§£æ åãåããæçš¿ã®é¡äŒŒæçš¿ã®æ€çŽ¢ãã瀟å
ããã¥ã¡ã³ãã®æ€çŽ¢ã®ããã«ãæ°èŠåãåããã®æç« ãé©åã«åŠçããŠè¡ããããäºåã«è§£æããŠãããŸãã import re import mojimoji # äºåã«äžèŠãªé
ç®ãtextããåé€ãã颿° def filter_contents (text): # ã¿ã°ã»emojiã¯åé€ subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # ãã®ä»ã瀟å
ç¬èªIDç³»ãªã©ã®åé€åŠç # ******** return subed # åèªã®æ£èŠå def normalize (text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text å
šæçš¿æããåè©ã®ã¿æœåºããŸãã圢æ
çŽ è§£æåšã¯Janomeã䜿çšããŸãããããšã§ãè§ŠããŸãããJanomeã¯èŸæžãå«ããŠpipã§ç°¡åã«ã€ã³ã¹ããŒã«ããããšãã§ããã®ã§ãLambdaã®ãããªç°å¢ã§ç°¡åã«åœ¢æ
çŽ è§£æãè¡ãã®ã«é©ããŠããŸãã from janome.tokenizer import Tokenizer t = Tokenizer() ndocs = [] for line in tqdm(messages_df[ "text" ]): parsed = [] subed = normalize(filter_contents(line)) # janomeã¯æ¹è¡ãç¡èŠã§ããªã for token in t.tokenize( " " .join(subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # æ°åã®ã¿ã¯åè©ã ãã¹ããã if tok.isdecimal(): continue if hinsi == "åè©" : parsed.append(tok) ndocs.append( " " .join(parsed)) ããã§ ndocs ã«åè©åã®ã¹ããŒã¹åºåãæååãæçš¿æ°åã ãlistãšããŠæ ŒçŽãããŸãã ãããå©çšããŠãã£ã³ãã«å
šäœã§ã®åèªã®TFIDFå€ïŒéèŠåºŠã®ãããªãã®ïŒãèšç®ããŸããTDIDFã«ã€ããŠã¯ä»¥äžã®èšäºãªã©ããåç
§ãã ãããä»åã¯ç°¡åã®ããã«scikit-learnã«å«ãŸããTfidfVectorizerãèšç®ã«äœ¿çšããŸããã qiita.com from sklearn.feature_extraction.text import TfidfVectorizer # ææžå
šäœã®90%以äžã§åºçŸããåèªã¯ç¡èŠãèªåœæ°ã¯10000ã®ã¿ vectorizer = TfidfVectorizer(max_df= 0.9 , max_features= 10000 ) X = vectorizer.fit_transform(ndocs) words = noun_vectorizer.get_feature_names() idf_list = [ " \t " .join( map ( str , line)) for line in list ( zip (vectorizer.get_feature_names(), vectorizer.idf_))] pd.DataFrame(idf_list).to_csv( "./idf.tsv" , index= False , header= False ) ããã§ idf.tsv ãšããIDFèŸæžãäœæã§ããŸãã åå 9.973116006811027 å¿å 9.839584614186505 ã«ãŒãœã« 9.21097595476413 ã·ã§ãã 3.3278392845417772 ïŒ TFå€ã¯æ°èŠåãåããæã«ããããŸãã®ã§ãããã§æ°èŠåãåããã«ã€ããŠã®åè©ã®TFIDFå€ãèšç®ã§ããããšã«ãªããŸãã Elasticsearchã«éå»åãåãããä¿å éå»ã®åãåãããElasticsearchã«ä¿åããŠãæ°èŠåãåãããæ¥ãæã«æ€çŽ¢ããããããããã«ããŸãããŸãElasticsearchã«éå»åãåããçšã®ã€ã³ããã¯ã¹ãäœæããŸããäºåã«boto3ã§äœ¿çšããcredentialsã以äžã®èšäºãªã©åèã«ååŸã§ããããèšå®ããŠããå¿
èŠããããŸãã qiita.com import io import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection region = 'ap-northeast-1' service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token) es = Elasticsearch( hosts = [{ 'host' : '{Elasticsearchã®host}' , 'port' : 443 }], http_auth = awsauth, use_ssl = True , verify_certs = True , connection_class = RequestsHttpConnection ) index_name = "autores_index" mappings = { 'properties' : { 'orig_text' : { 'type' : 'text' }, 'text' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'keyword' : { 'type' : 'text' , 'analyzer' : 'autores_kuromoji_analyzer' }, 'ts' : { 'type' : 'text' }, 'thread_ts' : { 'type' : 'text' }, 'link' : { 'type' : 'text' }, } } settings = { 'analysis' : { 'analyzer' : { 'autores_kuromoji_analyzer' : { 'type' : 'custom' , 'tokenizer' : 'kuromoji_tokenizer' } }, 'autores_kuromoji_tokenizer' : { 'kuromoji' : { 'type' : 'kuromoji_tokenizer' } } } } es.indices.create(index=index_name, body={ 'settings' : settings, 'mappings' : mappings}) ç»é²ããtextãªã©ã«ã€ããŠãanalyzerã« kuromoji_tokenizer ã䜿çšããããšã§ãå
šææ€çŽ¢æã«åœ¢æ
çŽ è§£æã掻çšããæ€çŽ¢ãããããšãã§ããŸãã éå»ã®åãåããæçš¿ã«ã€ããŠãElasticsearchçšã®äºååŠçãè¡ããŸãã bulk_dataset = [] for index, row in tqdm(cs_res_df.iterrows(), total=cs_res_df.shape[ 0 ]): text = row[ "text" ] ts = row[ "ts" ] thread_ts = row[ "thread_ts" ] link = row[ "link" ] # ã¿ã°é€å»ïŒã¡ã³ã·ã§ã³ããªã³ã¯ïŒ subed_text = filter_contents(text) # tokenize keyword = " " .join(tp.make_tokens(subed_text)) tfidf_score, unique_keys, shop_ids, phrase_list = tp.process(text) # skip too short message if len (subed_text) < 30 : continue # bulk bulk_dataset.append({ "index" : { "_index" : index_name}}) bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : keyword, "ts" : ts, "thread_ts" : thread_ts, "link" : link, }) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) 以äžã§200ä»¶ãã€ããŒã¿ãinsertããŠãããŸãã rest_dataset= bulk_dataset insert_rows_len = 200 while len (rest_dataset) > 0 : data = rest_dataset[:insert_rows_len] bulk(data) rest_dataset = rest_dataset[insert_rows_len:] ããã§Elasticsearchã® autores_index ã«ããŒã¿ã远å ãããŸãã以äžã®ã¯ãšãªãªã©ã§ããŒã¿ã確èªã§ããŸãã es.search( scroll= '5m' , index=index_name, body={ "query" : { "match_all" : { } }, "_source" : [ "orig_text" , "link" ], }, ) åŠçLambdaã®å®è£
Lambdaã§å®è£
ããã®ã¯äž»ã«ä»¥äžã®åŠçã§ãã API GatewayããæµããŠããã€ãã³ããæ£ããåãåã£ãŠåŠçå¯èœãªç¶æ
ã«ãã ããã¹ãè§£æãè¡ãElasticsearchãKibelaã®æ€çŽ¢ãè¡ã BotãšããŠSlackãžåçãæçš¿ãã ããã€ãLambdaã§æ°ããå°å
¥ããå¿
èŠã®ããã¢ãžã¥ãŒã«ããããŸãããããã¯Lambda Layerãšãã圢ã§äºåã«åºç€ã®ãããªãã®ãçšæããŠããããšã«ãªããŸãã docs.aws.amazon.com requirements.txtã«è¿œå ã§å¿
èŠãªã¢ãžã¥ãŒã«ãèšèŒãããããã®ãã¡ã€ã«ãäžã€ã®zipãšããŠãŸãšããå¿
èŠããããŸããåæã«ãäºåã«äœæããIDFèŸæžãããã§Layerå
ã«å«ããŸãã以äžã®ãããªMakefileãåéå±€ã«äœã£ãŠæ§ç¯ãããšæ¥œã§ãã janome requests_aws4auth elasticsearch mojimoji build-layer: rm -rf python && mkdir -p python docker run --rm -v ${PWD} :/var/task lambci/lambda:build-python3.7 \ pip install -r requirements.txt -t python cp idf.tsv python zip -r autores_layer.zip python rm -rf python 以äžã®ã³ãã³ãã§ãLayerã«é©çšå¯èœãªzipãã¡ã€ã« autores_layer.zip ãäœæãããŸãããããLayerãšããŠæ°èŠç»é²ããŸããããWebã§ãã§ããŸãããCLIããŒã«ã§ãå¯èœã ãšæããŸãã $ make build-layer ä»åãLambdaäžã§åœ¢æ
çŽ è§£æãè¡ãããã«Janomeãæ¡çšãããšèšããŸããããçç±ãšããŠåäœã軜ãã®ãšãã·ã³ãã«ãªpipã«ããã€ã³ã¹ããŒã«ã§å®çµããŠããããšãçç±ãšããŠæããããŸããä»ã®åœ¢æ
çŽ è§£æåšã§ãLayerãEFSãªã©ã掻çšããããšã§Lambdaäžã§ã䜿çšã§ãããããªã®ã§ãããæã£åãæ©ãæ±ãã¥ããã£ãããä»åã¯èŠéããŸããã Lambdaã®Web管çç»é¢ã«è¡ããæ°èŠé¢æ°ãäœæããŠãããŸããããã¯åã«èª¬æããAPI Gatewayãšã®é£æºèšå®ãããŠããã°ããã®Lambda颿°ãéžæããã®ã§åé¡ãªãã§ãã ãŸãLayerãšããŠå
ã»ã©æ°èŠäœæããLayerã䜿çšããŸãã Layerã®èšå® ãã¶ã€ããŒã¿ãå
ã®Layersãã¯ãªãã¯ãããšãäžéšã«ã¬ã€ã€ãŒã管çããã¿ããåºãŸãã®ã§ãããã§ã¬ã€ã€ãŒã®è¿œå ãè¡ããŸããLayerè¿œå æã«äºææ§ã®ããã©ã³ã¿ã€ã ã§Python3.7ãªã©ãæå®ããŠããã°ã«ã¹ã¿ã ã¬ã€ã€ãŒã§éžæã§ããŸãããçŽæ¥Layerã®ARNãæå®ããã®ã§ãåé¡ãããŸããïŒARNã¯Layerã®ç»é¢å³äžãªã©ã«ãããŸãïŒã Lambdaã®èšå®ã§ããã䜿çšã¡ã¢ãªã512MBãšããŠããŸããJanomeã§ã®åœ¢æ
çŽ è§£æãè¡ãã®ã«å°ãäœè£ãå¿
èŠã«ãªãããã§ããããã®ããã確ä¿ããŠããã°å€§æµã¯åäœããããšæããŸãã æ¬¡ã«function.pyã®äžèº«ãå®è£
ããŸãã import boto3 from requests_aws4auth import AWS4Auth from elasticsearch import Elasticsearch, RequestsHttpConnection import json import logging import io import requests import urllib import mojimoji import re from janome.tokenizer import Tokenizer import collections import time import hmac import hashlib logger = logging.getLogger() logger.setLevel(logging.INFO) KIBELA_DOMAIN = "****.kibe.la" # Kibelaã®ãã¡ã€ã³ KIBELA_KEY = "secret/***************" # Kibelaã®APIã㌠ES_DOMAIN = "********.es.amazonaws.com" # Elasticsearchã®ãã¡ã€ã³ SLACK_DOMAIN = "******.slack.com" # Slackã®ãã¡ã€ã³ SLACK_SIGNING_SECRET = "************" # Slackã®Signing Secret BOT_TOKEN = "xoxb-********************" # Slack Botã®token BOT_USER_ID = "U*********" # Slack Botã®user_id INDEX_NAME = "autores_index" # elasticsearchã®ã€ã³ããã¯ã¹å REGION = "us-east-1" # å©çšäžã®region OUT_CHANNEL = "#autores_bot" # Botã®æçš¿å
ãã£ã³ãã« service = 'es' credentials = boto3.Session().get_credentials() awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, REGION, service, session_token=credentials.token) es = Elasticsearch( hosts=[{ 'host' : ES_DOMAIN, 'port' : 443 }], http_auth=awsauth, use_ssl= True , verify_certs= True , connection_class=RequestsHttpConnection ) def is_valid (event): if SLACK_SIGNING_SECRET is None : return False if "x-slack-signature" not in event[ "headers" ] or "x-slack-request-timestamp" not in event[ "headers" ]: return False timestamp = int (event[ "headers" ][ "x-slack-request-timestamp" ]) if abs (time.time() - timestamp) > 60 * 5 : return False request_body = event[ "body" ] sig_basestring = f "v0:{timestamp}:{request_body}" digest = hmac.new( SLACK_SIGNING_SECRET.encode(), sig_basestring.encode( "utf-8" ), hashlib.sha256).hexdigest() my_sig = f "v0={digest}" if hmac.compare_digest(my_sig, event[ "headers" ][ "x-slack-signature" ]): return True else : return False def event_to_json (event): # API GatewayããæµããŠããã€ãã³ãããæ¬æãæœåº if 'body' in event: body = json.loads(event.get( 'body' )) return body elif 'token' in event: body = event return body else : logger.error( 'unexpected event format' ) exit class TfidfProcessor (): def __init__ (self): self.word2idf = {} self.subed = "" self.tokens = [] self.tfidf_score = {} self.phrase_list = [] # Layerå
ã®IDFèŸæžãèªã¿èŸŒã¿ with open ( "/opt/python/idf.tsv" ) as f: for line in f: word, score = line.rstrip().split( " \t " ) self.word2idf[word] = score self.t = Tokenizer() def process (self, text): self.filter_contents(text) self.make_tokens() self.calc_tfidf() self.extract_phrase() return self.phrase_list def normalize (self, text): text = text.strip() text = mojimoji.zen_to_han(text, kana= False ) text = mojimoji.han_to_zen(text, digit= False , ascii = False ) text = text.lower() return text def filter_contents (self, text): # ã¿ã°ã»emojiã¯åé€ subed = re.sub( "<.*?>" , "" , text) subed = re.sub( ":.*?:" , "" , subed) # ãã®ä»ã瀟å
ç¬èªIDç³»ãªã©ã®åé€åŠç # ******** self.subed = self.normalize(subed) def make_tokens (self): self.tokens = list (self.t.tokenize( " " .join(self.subed.split( " \n " )), wakati= True )) def calc_tfidf (self): tf_dic = self.calc_tf() idf_dic = self.calc_idf() for token, idf_score in idf_dic.items(): self.tfidf_score[token] = float (tf_dic[token]) * float (idf_score) def calc_tf (self): tf_tokens = {} tokens_len = len (self.tokens) counts = collections.Counter(self.tokens) for token, freq in counts.items(): tf_tokens[token] = freq / tokens_len return tf_tokens def calc_idf (self): idf_tokens = {} for token in self.tokens: if token in self.word2idf: idf_tokens[token] = self.word2idf[token] return idf_tokens def extract_phrase (self): phrase = {} tmp_phrase = [] for token in self.t.tokenize( " " .join(self.subed.split( " \n " ))): tok = token.surface hinsi, sub, _ = token.part_of_speech.split( "," , 2 ) # ã¹ãããã¯ãŒããªã©ããã°ããã®èŸºãã§åŠçããŠãã self.tokens.append(tok) if tok in self.tfidf_score and hinsi == "åè©" : tmp_phrase.append(tok) elif len (tmp_phrase) > 0 : phrase[ "" .join(tmp_phrase)] = sum ( [self.tfidf_score[tp] for tp in tmp_phrase] ) tmp_phrase = [] phrase_score_list = sorted (phrase.items(), key= lambda x: x[ 1 ], reverse= True ) self.phrase_list = [phrase[ 0 ] for phrase in phrase_score_list] # for link with slack event subscriptions class ChallangeJson ( object ): def data (self, key): return { 'isBase64Encoded' : 'false' , 'statusCode' : 200 , 'headers' : {}, 'body' : key } def search_es (phrase_list): res = es.search( scroll= '5m' , index=INDEX_NAME, body={ "query" : { "match" : { "text" : " " .join(phrase_list[: 20 ]), } }, "_source" : [ "text" , "ts" , "link" , ], "size" : 3 , }, ) return res def search_kibela (phrase_list): # äºãã«å«æããªãtfidfå€ã®é«ãæå€§2åèªãã¯ãšãªãšããŠäœ¿çš q = phrase_list[ 0 ] for p in phrase_list[ 1 :]: if p in q: continue q = q + " " + p break # å
šæ€çŽ¢ïŒåèšã§æå€§3ä»¶ã«ãªããŸã§ïŒ res_text_list = [] query = """ query { search(query: \ """" + q + """ \ ", first: 10) { edges { node { title, url, folder { fullName } } } } } """ res = get_kibela(query) for edge in res[ "data" ][ "search" ][ "edges" ]: title = edge[ "node" ][ "title" ] url = edge[ "node" ][ "url" ] # é©å®åããããªãèšäºã¯ã«ãŒã«ããŒã¹ã§ã¹ããã if re.search( r'hoge|fuga' , title, re.IGNORECASE): continue res_text_list.append(f "{title} \n {url}" ) if len (res_text_list) > 2 : break return " \n\n " .join(res_text_list) def get_kibela (query): endpoint = f "https://{KIBELA_DOMAIN}/api/v1" headers = { "Authorization" : f "Bearer {KIBELA_KEY}" , "Content-Type" : "application/json" , "Accept" : "application/json" , } payloads = { "query" : query } r = requests.post(endpoint, data=json.dumps(payloads), headers=headers) res = r.json() return res def save_to_es (text, tp, ts, thread_ts, target_link): bulk_dataset = [] bulk_dataset.append({ "index" : { "_index" : INDEX_NAME}}) subed_text = tp.subed phrase_list = tp.phrase_list bulk_dataset.append({ "orig_text" : text, "text" : subed_text, "keyword" : phrase_list, "ts" : ts, "thread_ts" : thread_ts, "link" : target_link, }) bulk(bulk_dataset) def bulk (data, dry= False ): if len (data) == 0 : return buf = io.StringIO() for d in data: buf.write(json.dumps(d, ensure_ascii= False )) buf.write( " \n " ) buf.seek( 0 ) if dry: print (buf.read()) else : body = buf.read() es.bulk(body) def send_slack (channel, text): headers = { 'Content-Type' : 'application/json; charset=UTF-8' , 'Authorization' : 'Bearer {0}' .format(BOT_TOKEN) } post_data = { 'channel' : channel, 'text' : text, } url = 'https://slack.com/api/chat.postMessage' req = urllib.request.Request( url, data=json.dumps(post_data).encode( 'utf-8' ), method= 'POST' , headers=headers ) urllib.request.urlopen(req) time.sleep( 3 ) def lambda_handler (event, context): # verify slack if not is_valid(event): return body = event_to_json(event) # return if it was challange-event if 'challenge' in body: challenge_key = body.get( 'challenge' ) logging.info( 'return challenge key %s:' , challenge_key) return ChallangeJson().data(challenge_key) # skip timeout retry, http_error retry if "x-slack-retry-reason" in event[ "headers" ] and event[ "headers" ][ "x-slack-retry-reason" ] in ( "http_timeout" , "http_error" ): return # SlackMessageã«ç¹å®ã®ããŒã¯ãŒããå
¥ã£ãŠãããšãã®åŠç if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) # @cs_dev_teamã®ã¿æŸã if "<!subteam^*********|@cs_dev_team>" not in text: return channel = body.get( "event" ).get( "channel" ) ts = body.get( "event" ).get( "ts" ) thread_ts = body.get( "event" ).get( "thread_ts" ) target_link = "https://{}/archives/{}/p{}" .format(SLACK_DOMAIN, channel, ts.replace( '.' , '' )) # tokenizer tp = TfidfProcessor() phrase_list = tp.process(text) # search es es_res = search_es(phrase_list) es_list = [] for hit in es_res[ "hits" ][ "hits" ]: link = hit[ "_source" ][ "link" ] es_list.append(link) if len (es_list) > 2 : break es_list_text = "é¢é£ïŒ \n " + " \n " .join(es_list) # search kibela kibela_res = search_kibela(phrase_list) # post message to main send_slack(OUT_CHANNEL, target_link) # post ref data message to main send_slack(OUT_CHANNEL, es_list_text) # post kibela message to main send_slack(OUT_CHANNEL, kibela_res) # save message for future questions save_to_es(text, tp, ts, thread_ts, target_link) return ã³ãŒãäžã®åçš®ããŒããã¡ã€ã³åãªã©ã¯ãåäœç°å¢ã«åãããŠé©å®ä¿®æ£ããŠãã ããã 倧æ ã®æµãã¯ã API Gatewayããåãåã£ãã€ãã³ããåŠçããåŠçãã¹ãã€ãã³ãã倿 Janomeã䜿çšããŠæ¬æãè§£æããElasticsearchãKibelaãæ€çŽ¢ããããã®åèªãæœåº ããããæ€çŽ¢ãè¡ããçµæãSlackã«æçš¿ åãåã£ãæ°èŠåãåããã¯ã次åã®éå»åãåãããšãªãã®ã§Elasticsearchã«éå»åãåãããšããŠç»é² ãšããæµãã§åŠçãè¡ããŸãã 以äžã«äžéšã³ãŒããæç²ããŠèª¬æããŸãã Slackåšã API Gatewayããã€ãã³ããåãåããšãã«ãSlackããããã€ãã³ãã ãšããã®ãå€å¥ãããã is_valid ãšãã颿°ã§åŠçããŠããŸããäžèšãªã³ã¯ã詳现ã«ãªããŸãã SLACK_SIGNING_SECRET ã«ã¯Slack Botã®ç®¡çç»é¢ã®ãSigning Secretãã代å
¥ããŠãã ããã api.slack.com ãŸããBotã䜿ã£ãèªåæçš¿ã·ã¹ãã ãäœããšãã«ãèªåèªèº«ã®æçš¿ãåå¿å¯Ÿè±¡ãšããŠæŸã£ãŠããŸããšç¡éã«æçš¿ããç¶ããŠããŸããšããåé¡ãçºçããŠããŸããŸããä»åã¯ç¹å®ã®ã¡ã³ã·ã§ã³ãå«ããšããå¯Ÿè±¡ã«æœåºããã®ã§åé¡ã¯ãªããããããŸããããã¬ã¹ãã³ã¹å
容ã倿Žããå Žåã¯äºåã«Botããã®æçš¿ã¯ç¡èŠãããšããåŠçãããŠãããšè¯ãã§ãããã if "type" in body.get( "event" ) and body.get( "event" ).get( "type" ) == "message" \ and "user" in body.get( "event" ) and body.get( "event" ).get( "user" ) != BOT_USER_ID: text = body.get( "event" ).get( "text" ) ããã®éšåã§ããBotã®user_idã¯ã©ãã«ããããããã«ããã§ããããã¡ãã§Botã®tokenãæž¡ãããšã§ååŸã§ãããšæããŸãã api.slack.com ãã®ä»ãæçš¿ããå
容ã«ã€ããŠã¯ send_slack å
ã®post_dataã«è¿œèšããããšã§è²ã
ã§ããããã§ãããŸããæçš¿ã«ã€ããŠã§ããAPI Limitãªã©ã®äœè£ãæã£ãŠ3ç§ã»ã©sleepãããŠããŸãã æ€çŽ¢ã¯ãšãªã®æœåºããžã㯠åãåããã®ããã¹ããè§£æããæ€çŽ¢ã«äœ¿çšããåèªãæœåºããã®ã¯ TfidfProcessor ã¯ã©ã¹ã§è¡ããŸãã å
·äœçãªåŠçã®å
容ãšããŠã¯ã çµµæåããªã³ã¯ãªã©ãåé€ åœ¢æ
çŽ è§£æãããåè©ã«ã€ããŠã®ã¿åœ¢æ
çŽ çŸ€ãæœåºããããããã®TFIDFå€ãèšç® 圢æ
çŽ è§£ææã«é£ç¶ããåè©ã¯åè©å¥ãšããŠæ±ããTFIDFå€ã¯åç®å€ãšãã ãæ¯èŸŒ/ç³è«ããšãã£ãå Žåã¯ããæ¯èŸŒç³è«ããšããŠãæ¯èŸŒããšãç³è«ãã®TFIDFå€ãåç®ãããã®ã䜿çšãã TDIFDå€ã®é«ãé ã«åè©ã»åè©å¥ãäžŠã³æ¿ããŠãã ãšããæµãã«ãªããŸããããã«ããTFIDFå€ã®é«ãåè©ã»åè©å¥ãç²åŸã§ããããã«ãªã£ãã®ã§ã以äžã®ããã«ããããæ€çŽ¢ãè¡ã£ãŠããŸãã Elasticsearchã§ã¯äžäœ20ä»¶ã®åè©ã»åè©å¥ã®ç©ºçœåºåãããã¹ãããéå»åãåããã®textã«å¯ŸããŠå
šææ€çŽ¢ãã ïŒçµæ§é©åœã§ãããŸããã£ãŠãããã€ã¡ãŒãžã§ãïŒ Kibelaã§ã¯äžäœ2ä»¶ã®åè©ã»åè©å¥ãã¹ããŒã¹åºåãã§ã¯ãšãªãšããŠå
šææ€çŽ¢ãã ãæ¯èŸŒç³è«ããæ¯èŸŒããæ³šæããšç¶ãå Žåã¯ãæ¯èŸŒç³è«ããæ³šæããéžæãããããªåŠçãå
¥ããŠããŸã éå»åãåãããšKibelaããã¥ã¡ã³ãã®ã©ã¡ããæå€§3ä»¶ãŸã§ãšããŸããã Kibelaæ€çŽ¢ã¯ãšãªã«ã€ã㊠Kibela APIã®æ€çŽ¢ã¯ãšãªã¯GraphQLã§æžãå¿
èŠããããŸãã github.com 以äžã®schemaã«æ²¿ã£ãŠã¯ãšãªãäœæããŸãã github.com ç¹å®ã®ãã©ã«ãçŽäžã§æ€çŽ¢ããããšããªã©ã¯ã query { search(query: "hoge fuga", first: 10), folderIds: ["{ãã©ã«ãã®ID}"]) { edges { node { title, url, folder { fullName } } } } } ãšããé¢šã«æžããšçµã蟌ã¿ãã§ãããããŸãã®ã§ãåŒç€Ÿã®å Žåã¯ãã®æ€çŽ¢ã䜵çšããŠãããããŸãïŒãã©ã«ãIDã¯APIãå©ããŠåŸããããã®ãæã§å
¥ããŸããïŒã ããã〠ããšã¯ä¿åãããŠãããã€ãè¡ãã ãã§ããããŸãããã°ãç¹å®ãã£ã³ãã«ã®å¯Ÿè±¡ã°ã«ãŒããžã®ã¡ã³ã·ã§ã³æçš¿ã«å¯ŸããŠã ãã®ã¡ã³ã·ã§ã³ãžã®ãªã³ã¯ïŒå±éãããŸãïŒ Elasticsearchãå©çšããéå»åãåãã3ä»¶ãžã®ãªã³ã¯ïŒå±éãããŸãïŒ Kibelaã§æ€çŽ¢ããé¢é£èšäº3ä»¶ã®ã¿ã€ãã«ãšãªã³ã¯ ãæå®ãããã£ã³ãã«ã«é 次Botããæçš¿ãããããšæããŸããã€ã¡ãŒãžã¯åé ã®ç»åã®éãã§ãã å®éã«ã¯ç¶ºéºãªåºåã«ããŠããããã«ã¯ãã¹ãããã¯ãŒãã人æã§ç»é²ããããKibelaã®æ€çŽ¢ã现ããèšå®ã§åããããã£ã«ã¿ãŒãããŸãããšã§çµæã®èšäºãè¯ãæãã«èª¿æŽãããããªæ³¥èãäœæ¥ãéèŠã«ãªã£ãŠããŸããæ¬èšäºã§ã¯å
·äœäŸã¯å²æããŸãããããã®èŸºãã¯ããã€ããµã³ãã«ã§è©ŠããŠã¿ãŠèŠã€ãã€ã€ãéçšãå§ãŸã£ããå®éã®åºåãªã©ãã¿ã€ã€ããã«èª¿æŽããæããè¯ãããšæããŸãã çµããã« ãšã³ãžãã¢ã«ãã調æ»ãå¿
èŠã ã£ããããæè¡çãªãåãåããã«å¯ŸããŠãéå»ã®é¡äŒŒãããåãåãããé¢é£ãã瀟å
ããã¥ã¡ã³ãã®æšèŠãè¡ãã·ã¹ãã ã瀟å
ã§å°å
¥ããä»¶ã«ã€ããŠãŸãšããŸããããŸã ãŸã 粟床æ¹åã®äœå°ãªã©ã¯ãããŸããããã以åããã£ããããªæ°ããããªã»ã»ïŒãšãããããªãåãåããã«ã€ããŠã¯éå»ã®äºäŸãåç
§ããããšã§ãåæ§ã®èª¿æ»ãåçã®äœæã楜ã«è¡ããããã«ãªã£ãããšæããŸããå®éã«ãèªåã§åºããŠããã£ãåçããã®ãŸãŸäœ¿ããŸããïŒããšãããããªå£°ãããã ãããããŸããã åŒç€Ÿã®å Žåã¯Slackéç¥éšåãSNSãªã©ãå©çšããŠæ±çšã·ã¹ãã ãšããŠçšæããŠããããåãµãŒãã¹ã®é£æºã倿°ãªã©ã¯æ§æç®¡çããŒã«ã§ç®¡çãããŠãããããŸãããŸããå®éã«ã¯æ§ç¯æžã¿ã®VPCå
ã§å®è£
ãè¡ã£ãŠããŸãããããããã®èª¬æã«ã€ããŠã¯ä»åã¯çç¥ããŸãããBASEã§ã¯çŸåšãäžæ£æ€ç¥ãšã³ãžãã¢ãäžå¿ã«æ©æ¢°åŠç¿ãšã³ãžãã¢ãåéããŠããŸãã詳ããã話ãªã©ã¯æ¯éãé¢è«çã§ã話ã䌺ãã§ããã°ãšæããŸãïŒ ä»åŸãåŒãç¶ãæ¹åãè¡ããããæ©ããåãåããã®è¿ä¿¡ãè¡ããããåªåããŠãããã°ãšæããŸãã ææ¥ã¯Service DevããŒã ã®çç°ããã§ãïŒã楜ãã¿ã«ïŒ