<jupyter_start><jupyter_text>https://www.analyticsvidhya.com/blog/2018/01/faq-chatbots-the-future-of-information-searching/http://blog.christianperone.com/2013/09/machine-learning-cosine-similarity-for-vector-space-models-part-iii/http://nlp.town/blog/sentence-similarity/https://spacy.io/usage/training<jupyter_code>import string
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.neighbors import KDTree

# Uncomment to see full cell text
pd.set_option('display.max_colwidth', 0)
# Import local libraries
import sys
sys.path.append('../src')

import processing
faq = pd.read_csv('../data/interim/faq-text-separated.csv', keep_default_na=False)
test = pd.read_csv('../data/test/test-questions.csv')
results = pd.DataFrame()<jupyter_output><empty_output><jupyter_text>Load test set for topic matching<jupyter_code>test = pd.read_excel('../../../Inquire Boulder request data- detailed open and closed - for research purposes.xlsx')
test = test[['Description', 'Topic']]
test = test.rename(index=str, columns={"Description": "test_question", "Topic": "match_topic"})<jupyter_output><empty_output><jupyter_text>QnA Maker(The data used by QnA Maker for this metric is a somewhat unclean version.)6 successes  Google DialogflowUsing the built-in Dialogflow ML in 'hybrid' mode with threshold 0.3 (default settings)4 successes  Semantic Similarity with spaCy1 success<jupyter_code># This is not the full code, so isn't operational in this notebook.
import spacy
# A larger model should be used for better accuracy
nlp = spacy.load('en_core_web_sm')

def max_sim_spacy(q, docs):
    """Returns (index, similarity) of argument q's most similar match in argument docs, all spaCy documents."""
    q = nlp(q)
    max_i = 0
    max_s = 0
    ms = []
    for i, d in enumerate(docs):
        if d.similarity(q) > max_s:
            max_s = d.similarity(q)
            max_i = i
    
    return max_i, max_s    
 
q_docs = [nlp(entry) for entry in faq.question]
a_docs = [nlp(entry) for entry in faq.answer]<jupyter_output><empty_output><jupyter_text>Text Processing<jupyter_code>import string
from sklearn.feature_extraction import stop_words
from nltk.stem import WordNetLemmatizer
from nltk import pos_tag
def test_lem(words):
    """Returns list of lemmas from argument list of words."""
    wordnet_lemmatizer = WordNetLemmatizer()
    lem_sentence=[]
    for word in words:
        lem_sentence.append(wordnet_lemmatizer.lemmatize(word))
    return lem_sentence

def test_text_process(mess):
    """Returns list of tokenized lemmas in argument string mess, with stopwords, punctuation removed."""
    clean = [char if char not in string.punctuation else ' ' for char in mess]
    clean = ''.join(clean)
    clean = [word.lower() for word in clean.split() if word.lower() not in stop_words.ENGLISH_STOP_WORDS] 
    clean = test_lem(clean)
    return clean
from nltk.corpus import wordnet

def get_wordnet_pos(word):
    """Map POS tag to first character lemmatize() accepts"""
    tag = pos_tag([word])[0][1][0].upper()
    tag_dict = {"J": wordnet.ADJ,
                "N": wordnet.NOUN,
                "V": wordnet.VERB,
                "R": wordnet.ADV}

    return tag_dict.get(tag, wordnet.NOUN)


# defines a custom vectorizer class
class CustomVectorizer(CountVectorizer): 
    
    # overwrite the build_analyzer method, allowing one to
    # create a custom analyzer for the vectorizer
    def build_analyzer(self):
        
        # load stop words using CountVectorizer's built in method
        stop_words = self.get_stop_words()
        
        # create the analyzer that will be returned by this method
        def analyser(mess):
            # Remove punctuation, lowercase all letters            
            clean = mess.replace('-','')
            clean = ''.join([char if char not in string.punctuation or '0123456789' else ' ' for char in clean])
            clean = [word.lower() for word in clean.split()] 
        
            wordnet_lemmatizer = WordNetLemmatizer()
            lem_sentence=[]
            for word in clean:
                lem_sentence.append(wordnet_lemmatizer.lemmatize(word, get_wordnet_pos(word)))
            
            # use CountVectorizer's _word_ngrams built in method
            # to remove stop words and extract n-grams
            return(self._word_ngrams(lem_sentence, stop_words))
        return(analyser)
# Create corpus by joining columns
features = ['Topic', 'Category', 'Department', 'question', 'answer']
corpus = ''
for f in features:
    corpus += faq[f] + ' '<jupyter_output><empty_output><jupyter_text>BOW and TFIDF Vectorization<jupyter_code>count_vect = CustomVectorizer(ngram_range=(1, 2), stop_words='english')
bow_transformer = count_vect.fit(corpus)
# Tranform corpus itself into BOW
bow = bow_transformer.transform(corpus)

# Create TFIDF transformer based on faq.question's BOW
tfidf_transformer = TfidfTransformer().fit(bow)
# Transform faq.question's BOW into TFIDF
tfidf = tfidf_transformer.transform(bow)
#### Print info about text features
def wm2df(wm, feat_names):
    
    # create an index for each row
    doc_names = ['Doc{:d}'.format(idx) for idx, _ in enumerate(wm)]
    df = pd.DataFrame(data=wm.toarray(), index=doc_names,
                      columns=feat_names)
    return(df)
  
# wm2df(bow, count_vect.get_feature_names())
print(len(count_vect.vocabulary_))
# print(count_vect.vocabulary_)
print(bow_transformer.transform(['bear mountain ']))<jupyter_output>(0, 5275)	1
  (0, 5317)	1
  (0, 23356)	1<jupyter_text>Cosine Similarity<jupyter_code>def max_sim_skl(tq):
    """Returns (index, similarity value) of string argument q's most similar match in FAQ, determined by cosine similarity."""
    # Transform test question into BOW using BOW transformer (based on faq.question) 
    tq_bow = bow_transformer.transform([tq])
    # Transform test question's BOW into TFID
    tq_tfidf = tfidf_transformer.transform(tq_bow)

    sims = np.transpose(cosine_similarity(tq_tfidf, tfidf))

    max_s = sims.max()
    max_i = np.argmax(sims)
    
    return max_i, max_s 
# Form cosine similarity matrix
# from sklearn.feature_extraction.text import TfidfVectorizer
# tfidf_vectorizer = TfidfVectorizer()
# tfidf_matrix = tfidf_vectorizer.fit_transform(faq.question)
# print(tfidf_matrix.shape)
def cosine_respond(row):
    """Returns argument row with added columns to match questions in FAQ."""
    query = row.test_question.strip()

    index, sim = max_sim_skl(query)

    if 'match_question' in row.keys():
        match = faq.question.iloc[index]
        response = faq.answer.iloc[index]
                
        row['sim_question'] = match
        row['question_success'] = row.sim_question == row.match_question
#         if 'service request' in response.lower():
#             response += '\n\nIf you would like to submit a service request, please visit https://user.govoutreach.com/boulder/faq.php?cmd=shell or call 303-441-3388.'   
#         row['info'] = response    

    if 'match_topic' in row.keys():
        row['sim_topic'] = faq.Topic.iloc[index]
        row['topic_success'] = row.sim_topic == row.match_topic
    
    row['max_similarity'] = round(sim, 2)
    return row 
t = test.apply(cosine_respond, axis=1)
# Test Topics
results['cosine_similarity'] = t.topic_success
# Test Questions
results['cosine_similarity'] = t.question_success
print('Successes: ', sum(results.cosine_similarity), '/', len(results), '=', round(sum(results.cosine_similarity) / len(results), 2))
t<jupyter_output>Successes:  6 / 20 = 0.3<jupyter_text>Soft Cosine Similarity... https://www.machinelearningplus.com/nlp/cosine-similarity/<jupyter_code># Insert here<jupyter_output><empty_output><jupyter_text>KD Trees Nearest Neighbor7 successes with euclidean distance3 successes with cosine_similarity matrix (if I've implemented it right)Other results below:<jupyter_code>dist_metric_success = {
    'euclidean': 7,
    'l2': 7,
    'minkowski': 7,
    'p': 7,
    'manhattan': 0,
    'cityblock': 0,
    'l1': 0,
    'chebyshev': 5,
    'infinity': 5}
tree = KDTree(tfidf.toarray(), metric='euclidean')
# tree = KDTree(cosine_similarity(tfidf, tfidf))
def kd_sim(query):
    """Returns (index, similarity value) of string argument q's most similar match in FAQ, determined by cosine similarity, determined by nearest neighor in a KDTree of TFIDF vectors."""
    # Transform test question into BOW using BOW transformer (based on faq.question) 
    tq_bow = bow_transformer.transform([query])
    # Transform test question's BOW into TFIDF
    tq_tfidf = tfidf_transformer.transform(tq_bow)

    nearest_dist, nearest_ind = tree.query(tq_tfidf.toarray(), k=2)  # k=2 nearest neighbors where k1 = identity
#     nearest_dist, nearest_ind = tree.query(cosine_similarity(tq_tfidf, tfidf), k=2)  # k=2 nearest neighbors where k1 = identity
        
    return nearest_ind[0][0], nearest_dist[0][0]
def kd_respond(row):
    """Returns argument row with added columns to match questions in FAQ."""
    query = row.test_question.strip()

    index, dis = kd_sim(query)

    row['near_question'] = faq.question.iloc[index]
    row['nearest_distance'] = round(dis, 2)
    row['success'] = row.near_question == row.match_question
    return row   
results['kd_tree'] = test.apply(kd_respond, axis=1).success
print('Successes: ', sum(results.kd_tree))
test.apply(kd_respond, axis=1)<jupyter_output>Successes:  7<jupyter_text>Multinomial Naive-Bayes<jupyter_code>from sklearn.naive_bayes import MultinomialNB
faq_model = MultinomialNB().fit(tfidf, faq['question'])
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report

pipeline = Pipeline([
    ('bow', CountVectorizer(analyzer=processing.text_process, ngram_range=(1, 1))),  # strings to token integer counts
    ('tfidf', TfidfTransformer()),  # integer counts to weighted TF-IDF scores
    ('classifier', MultinomialNB()),  # train on TF-IDF vectors w/ Naive Bayes classifier
])
pipeline.fit(corpus,faq['question'])
predictions = pipeline.predict(test.test_question)
results['multinomial_nb'] = [True if x == test.match_question[i] else False for i, x in enumerate(predictions)]
print('Successes: ', sum(results.multinomial_nb))
print(classification_report(predictions, test.match_question))<jupyter_output>Successes:  0
                                                                                                                                                                                                                                                                                                                                                                       precision    recall  f1-score   support

                                                                                                                                                                                                                                                                                                                        Can I sell my product from a cart or vehicle?       0.00      0.00      0.00        10
                                                                                                                                                                                           [...]<jupyter_text>Random Forests of Decision Trees<jupyter_code># Insert here?<jupyter_output><empty_output><jupyter_text>Pretrained Word2Vec Model from gensim.models import Word2Vec, KeyedVectorspretrainedpath = "/Users/willscott/Documents/Gensim-Word2Vec-models/GoogleNews-vectors-negative300.bin"Load W2V model. This will take some time, but it is a one time effort! %time w2v_model = KeyedVectors.load_word2vec_format(pretrainedpath, binary=True)print('done loading Word2Vec')print(len(w2v_model.vocab)) Number of words in the vocabulary.  Let us examine the model by knowing what the most similar words are, for a given word!%time w2v_model.most_similar('boulder') w2v_model['computer'] What if I am looking for a word that is not in this vocabulary?w2v_model['practicalnlp']  Training Custom Word2Vec Model<jupyter_code>from gensim.models import Word2Vec
from sklearn.feature_extraction import stop_words
def w2v_text_process(mess):
    """Returns list of the cleaned text in argument string mess, with stopwords, punctuation removed and tokens lemmatized."""
    clean = [char if char not in string.punctuation else ' ' for char in mess]
    clean = ''.join(clean)
    clean = [word.lower() for word in clean.split() if word.lower() not in stop_words.ENGLISH_STOP_WORDS] 
    return clean
# Creating a feature vector by averaging all embeddings for all sentences
def embedding_feats(list_of_lists):
    DIMENSION = 300
    zero_vector = np.zeros(DIMENSION)
    feats = []
    for list_of_tokens in list_of_lists:
        feat_for_this = zero_vector
        count_for_this = 0
        for token in list_of_tokens:
            if token in our_model:
                feat_for_this += our_model[token]
                count_for_this += 1
        feats.append(feat_for_this/count_for_this) 
    return feats
def max_sim_w2v(tq):
    """Returns (index, similarity value) of string argument q's most similar match in FAQ, determined by cosine similarity."""
    query = [w2v_text_process(tq)]
    query = embedding_feats(query)
 
    sims = np.transpose(cosine_similarity(query, corpus_vectors))

    max_s = sims.max()
    max_i = np.argmax(sims)
    
    return max_i, max_s 
def cosine_respond_w2v(row):
    """Returns argument row with new info columns to match with questions in FAQ."""
    query = row.test_question.strip()

    index, sim = max_sim_w2v(query)

    row['sim_question'] = faq.question.iloc[index]
    row['max_similarity'] = round(sim, 2)
    row['success'] = row.sim_question == row.match_question
    row['info'] = faq.answer.iloc[index]
    return row   
# Create corpus by joining columns
features = ['Topic', 'Category', 'Department', 'question', 'answer']
corpus = ''
for f in features:
    corpus += faq[f] + ' '
    
corpus = [w2v_text_process(x) for x in corpus]
#Build the model, by selecting the parameters. 
our_model = Word2Vec(corpus, size=300, window=5, min_count=1, workers=4)
#Save the model
our_model.save("../models/tempmodel.w2v")
#Inspect the model by looking for the most similar words for a test word. 
# [print(x) for x in our_model.wv.most_similar('dogs', topn=5)]
corpus_vectors = embedding_feats(corpus)
results['w2v'] = test.apply(cosine_respond_w2v, axis=1).success
print('Successes: ', sum(results.w2v))
test.apply(cosine_respond_w2v, axis=1)<jupyter_output>/Users/willscott/anaconda3/lib/python3.6/site-packages/ipykernel_launcher.py:10: DeprecationWarning: Call to deprecated `__contains__` (Method will be removed in 4.0.0, use self.wv.__contains__() instead).
  # Remove the CWD from sys.path while we load stuff.
/Users/willscott/anaconda3/lib/python3.6/site-packages/ipykernel_launcher.py:11: DeprecationWarning: Call to deprecated `__getitem__` (Method will be removed in 4.0.0, use self.wv.__getitem__() instead).
  # This is added back by InteractiveShellApp.init_path()<jupyter_text>Doc2Vec with GenSimMake sure you have a C compiler before installing Gensim, to use the optimized doc2vec routines - 70x speedup compared to plain NumPy implementation, https://rare-technologies.com/parallelizing-word2vec-in-python/<jupyter_code>import gensim
from gensim.models import Doc2Vec
from gensim.models.doc2vec import TaggedDocument
from nltk import word_tokenize
from nltk.stem.porter import *
def d2v_process(mess):
    """
    Takes in a string of text, then performs the following:
    1. Remove all punctuation
    2. Remove all stopwords
    3. Lemmatizes all words
    4. Returns a string of the cleaned text
    """
    # Check characters to see if they are in punctuation
    clean = [char if char not in string.punctuation else ' ' for char in mess]
    # Join the characters again to form the string.
    clean = ''.join(clean)
    # Now just remove any stopwords
    clean = [word.lower() for word in clean.split() if word.lower() not in stop_words.ENGLISH_STOP_WORDS]
    # Lemmatize
    clean = lem(clean)
    return ' '.join(clean)
 
def default_clean(text, stem=True, stemmer = PorterStemmer()):
    '''
        Removes default bad characters, Removes stopwords and does stemming
    '''
    # Check characters to see if they are in punctuation
    text = [char if char not in string.punctuation else ' ' for char in text]
    # Join the characters again to form the string.
    text = ''.join(text)
    # Now just remove any stopwords
    text = [word.lower() for word in text.split() if word.lower() not in stop_words.ENGLISH_STOP_WORDS]
    text = ' '.join(text)
    
    if stem:
        text_stemmed = [wordnet_lemmatizer.lemmatize(word) for word in word_tokenize(text) if len(word) > 3]
#         text_stemmed = [stemmer.stem(word) for word in word_tokenize(text) if len(word) > 3]
    else:
        text_stemmed = [word for word in word_tokenize(text) if len(word) > 3]
    
    text = ' '.join(text_stemmed)
    return text
# Add Dept, Category, Topic as LABELS???
class TaggedDocumentIterator(object):
    def __init__(self, doc_list, labels_list):
        self.labels_list = labels_list
        self.doc_list = doc_list
    def __iter__(self):
        for idx, doc in enumerate(self.doc_list):
            yield TaggedDocument(words=doc.split(), tags=[self.labels_list[idx]])
def test_d2v(test_sample):
    """Passes parameter test_sample into Doc2Vec model and returns the most similar entry in training set."""
    #Clean the document using the utility functions used in train phase
    test_sample = default_clean(test_sample)

    #Convert the sample document into a list and use the infer_vector method to get a vector representation for it
    new_doc_words = test_sample.split()
    new_doc_vec = model.infer_vector(new_doc_words)

    #use the most_similar utility to find the most similar documents.
    return model.docvecs.most_similar(positive=[new_doc_vec])
def respond_d2v(row):
    """Passes parameter row of test dataframe to Doc2Vec prediction function and returns row with results added."""
    query = row.test_question.strip()

    sims = test_d2v(query)
    
    best = sims[0][0]

    row['sim_question'] = best
    row['similarity'] = round(sims[0][1], 2)
    row['success'] = (best == row.match_question)
    return row<jupyter_output><empty_output><jupyter_text>Train on Answer, test with Question<jupyter_code>sample = faq[['question', 'answer']]
sample = sample.sample(frac=1).reset_index(drop=True)
print ('The shape of the input data frame: {}'.format(sample.shape))
sample['answer'] = sample['answer'].apply(default_clean)
docLabels = list(sample['question'])
data = list(sample['answer'])
sentences = TaggedDocumentIterator(data, docLabels)
# This is a simple gridsearch-like process to automate parameter testing. 
runs = 100 
res = pd.DataFrame(columns=['params', 'avg successes'])
e = 42
for i in range(5):
    v_s = 50
    for j in range(10):
        m_c = 0
        for k in range(3):
            model = Doc2Vec(vector_size=v_s, min_count=m_c, epochs=e)
            model.build_vocab(sentences)
            model.train(sentences,total_examples=model.corpus_count, epochs=model.epochs)

            avg = 0
            for i in range(runs):
                t = test.apply(respond_d2v, axis=1)
                avg += sum(t.success)

            res = res.append({'params' : 'vector_size={}, min_count={}, epochs={}'.format(v_s, m_c, e), 'avg successes' : avg / runs}, ignore_index=True)

            m_c += 1
            print('mc')
        v_s += 50
        print('vs')
    e += 10
    print('e')
res.tail()
res[res['avg successes'] == max(res['avg successes'])]
# Avg num of sucessses (out of 20) : model parameters

# no stemming
# 2.50 : model = Doc2Vec(vector_size=100, min_count=1, epochs=100)
# 2.70 : model = Doc2Vec(vector_size=100, min_count=0, epochs=100)
# 1.27 : model = Doc2Vec(vector_size=100, min_count=1, epochs=25)
# 1.23 : model = Doc2Vec(vector_size=100, min_count=0 epochs=25)
# 1.18 : model = Doc2Vec( min_count=0, epochs=25)

# with stemming
# 1.74 : model = Doc2Vec(vector_size=100, min_count=1, epochs=100)
# 1.21 : model = Doc2Vec(vector_size=100, min_count=0, epochs=100)
# 1.28 : model = Doc2Vec(vector_size=100, min_count=1, epochs=25)
# 1.15 : model = Doc2Vec(vector_size=100, min_count=0, epochs=25)

# with lemming
# 0.58 : model = Doc2Vec(vector_size=100, min_count=1, epochs=100)
# 1.04 : model = Doc2Vec(vector_size=100, min_count=0, epochs=100)
# 0.93 : model = Doc2Vec(vector_size=100, min_count=1, epochs=25)
# 0.99 : model = Doc2Vec(vector_size=100, min_count=0, epochs=25)
# 1.54 : vector_size=130, min_count=14, epochs=10
# 2.14 : vector_size=230, min_count=0, epochs=10
# 2.63 : vector_size=260, min_count=1, epochs=12
# Tested up to 500, 2, 42
# Individual model testing
model = Doc2Vec(vector_size=100, min_count=0, epochs=25)
model.build_vocab(sentences)
model.train(sentences,total_examples=model.corpus_count, epochs=model.epochs)

runs = 1000 
avg = 0
for i in range(runs):
    t = test.apply(respond_d2v, axis=1)
    avg += sum(t.success)
print('Avg. Successes: ', avg / runs)
# Store the model to mmap-able files
model.save('../models/model_answer.doc2vec')
# Load the model
model = Doc2Vec.load('../models/model_answer.doc2vec')<jupyter_output><empty_output><jupyter_text>Train on Question+Answer, test with Question<jupyter_code>sample = pd.DataFrame(corpus, columns=['qna']) 
sample['question'] = faq['question']
sample['answer'] = faq['answer']
sample = sample.sample(frac=1).reset_index(drop=True)
print ('The shape of the input data frame: {}'.format(sample.shape))
sample['qna'] = sample['qna'].apply(default_clean)
docLabels = list(sample['question'])
data = list(sample['qna'])
sentences = TaggedDocumentIterator(data, docLabels)
# This is a simple gridsearch-like process to automate parameter testing. 
runs = 100 
res = pd.DataFrame(columns=['params', 'avg successes'])
e = 10
for i in range(5):
    v_s = 50
    for j in range(10):
        m_c = 0
        for k in range(3):
            model = Doc2Vec(vector_size=v_s, min_count=m_c, epochs=e)
            model.build_vocab(sentences)
            model.train(sentences,total_examples=model.corpus_count, epochs=model.epochs)

            avg = 0
            for i in range(runs):
                t = test.apply(respond_d2v, axis=1)
                avg += sum(t.success)

            res = res.append({'params' : 'vector_size={}, min_count={}, epochs={}'.format(v_s, m_c, e), 'avg successes' : avg / runs}, ignore_index=True)

            m_c += 1
            print('mc')
        v_s += 50
        print('vs')
    e += 5
    print('e')
res.tail()
res[res['avg successes'] == max(res['avg successes'])]
# Individual model testing
model = Doc2Vec(vector_size=100, min_count=0, epochs=100)
model.build_vocab(sentences)
model.train(sentences,total_examples=model.corpus_count, epochs=model.epochs)

runs = 100 
avg = 0
for i in range(runs):
    t = test.apply(respond_d2v, axis=1)
    avg += sum(t.success)
print('Avg. Successes: ', avg / runs)
# with tutourial cleaning, no stemming, taggedDocument
#  model = Doc2Vec(vector_size=100, min_count=0, epochs=100)
# 0-2 model = Doc2Vec(vector_size=100, min_count=1, epochs=30)

# with lemming
# 0-2 model = Doc2Vec(vector_size=100, min_count=1, epochs=25)
# 0-2 model = Doc2Vec(vector_size=100, min_count=1, epochs=30)
# 3.07 : vector_size=100, min_count=1, epochs=82
# Store the model to mmap-able files
model.save('../models/model_qna.doc2vec')
# Load the model
model = Doc2Vec.load('../models/model_qna.doc2vec')<jupyter_output><empty_output><jupyter_text>Analyze Similarity Comparisons<jupyter_code>grid = pd.DataFrame(index=results.columns, columns=['Matched'])
for c in results.columns:
    grid.Matched[c] = sum(results[c])
grid<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.11104190025331175, 'fraction_numerical': 0.02034799219301524, 'mean_word_length': 3.4235855988243937, 'pattern_counts': {'":': 6, '<': 56, '<?xml version=': 0, '>': 60, 'https://': 5, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 42, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '15', 'dir': 'jupyter-structured-clean-dedup', 'id': '593308', 'license': 'BSD-3-Clause', 'n_tokens_mistral': 8169, 'n_tokens_neox': 7659, 'n_words': 2001, 'path': 'notebooks/faq-chatbot-sandbox.ipynb', 'repo_name': 'mfused/cc-therapist', 'size': '24081'}