File size: 13,711 Bytes
f6a99c0
 
 
 
 
 
 
553817d
243f5cb
95e3994
f792567
ea8f271
f792567
76b64ac
424ebdd
95e3994
 
 
 
553817d
 
 
9a3ebb6
f6a99c0
6ac00ea
f6a99c0
95e3994
f6a99c0
 
 
 
 
 
23192ed
 
4716193
 
23192ed
95e3994
23192ed
f6a99c0
 
 
 
 
 
 
424ebdd
 
f6a99c0
 
 
 
 
 
424ebdd
f6a99c0
95e3994
23192ed
 
 
 
 
f6a99c0
23192ed
 
424ebdd
 
 
 
 
 
 
 
 
f6a99c0
01355c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243f5cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6a99c0
76b64ac
f6a99c0
 
 
 
 
424ebdd
f6a99c0
 
 
 
424ebdd
 
f6a99c0
424ebdd
 
 
 
f6a99c0
 
424ebdd
 
 
 
f6a99c0
 
 
 
76b64ac
23192ed
 
ea8f271
 
 
 
 
76b64ac
f6a99c0
 
 
 
 
 
 
 
76b64ac
 
 
 
 
 
 
23192ed
 
f6a99c0
 
424ebdd
 
 
ea8f271
424ebdd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea8f271
 
 
 
95e3994
f6a99c0
243f5cb
 
ea8f271
 
 
 
 
01355c1
ea8f271
 
01355c1
ea8f271
01355c1
 
76b64ac
 
243f5cb
76b64ac
ea8f271
553817d
243f5cb
 
 
95e3994
76b64ac
f792567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5681111
32f45ca
23192ed
 
 
f6a99c0
 
76b64ac
 
23192ed
553817d
76b64ac
243f5cb
 
553817d
243f5cb
553817d
f6a99c0
 
23192ed
9a3ebb6
f792567
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# Import necessary modules
import gradio as gr # For creating the interactive user interface
import os # For accessing environment variables
import pandas as pd # For easier data handling
from openai import OpenAI # For sending the quotes to OpenAI for tagging
import openpyxl # Requirement for reading Excel files into pandas Dataframes
import json # For conversion of OpenAI responses into json/dictionary objects so the contents can be extracted
from dotenv import load_dotenv # For loading environment variables in local environment
from collections import Counter # For tabulating tag occurrences
import logging
import time
import random
from datetime import datetime
from typing import Generator
from concurrent.futures import ThreadPoolExecutor, as_completed

logger = logging.getLogger()
logger.setLevel(logging.INFO)
logging.basicConfig(level=logging.INFO, force=True)

# Load environment variables from local .env file if it exists; otherwise this does nothing
load_dotenv()

# Import prompt for requesting the tags from OpenAI
with open("prompts/prompt_030725.txt", "r") as prompt_file:
    PROMPT = prompt_file.read()
    logger.info(f"Loaded prompt: {PROMPT}")

# Import user instructions for display on screen
with open("user_instructions.txt", "r") as user_instruction_file:
    INSTRUCTIONS = user_instruction_file.read()

#Initialising the OpenAI client
client = OpenAI(
  api_key=os.getenv('OPENAI_KEY'),
  organization=os.getenv('ORG_KEY'),
  project=os.getenv('PROJ_KEY')
)
logger.info("Initialised OpenAI client")

# Function to send the prompt with quote and tag list to OpenAI and get the tags for that quote back
def tag_quote(quote: str, tags_list: list) -> list:
    """
    Generates a list of tags for a given quote based on a predefined list of potential tags.

    This function uses a GPT-based language model to analyze the input quote and determine 
    the most relevant tags from the provided list. The response is parsed from the JSON 
    output of the model and returned as a list of tags. This list is checked to ensure
    all tags tagged are taken from the input tags_list.

    Args:
        quote (str): The quote or text to be analyzed.
        tags_list (list): A list of potential tags to match against the quote.

    Returns:
        valid_tags: A list of tags that are relevant to the quote, as determined by the model.
    """
    logger.info(f"Tagging quote {quote}")
    response = client.chat.completions.create(
        model = "gpt-4o-mini",
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": "You are a helpful assistant designed to output JSON."},
            {"role": "user", "content": PROMPT.format(tags_list=tags_list,  quote=quote)}
        ]
    )

    tags = json.loads(response.choices[0].message.content)['tags']
    valid_tags = []
    for tag in tags: # filter out any hallucinated tags
        if tag in tags_list:
            valid_tags.append(tag)
        else:
            logger.warning(f"Invalid tag {tag} found and has been filtered out.")
    return valid_tags

def translate_quote(quote: str) -> str:
    """
    Translates a quote to English.
    """
    logger.info(f"Translating quote {quote}")
    response = client.chat.completions.create(
        model = "gpt-4o-mini",
        messages=[
            {"role": "user", "content": f"Translate the following quote into English. Do not return anything other than the translated quote. {quote}"}
        ]
    )
    logger.info("Content")
    logger.info(response.choices[0].message.content)
    return response.choices[0].message.content

def count_tags(tags_list: list, tags_col: pd.Series )->pd.DataFrame:
    """
    Creates a DataFrame indicating number of occurences of each tag from a DataFrame column containing lists of tags.

    This function also takes in a tags_list; all tags in the tags_list will be in the output 
    DataFrame even if they do not occur in the input tags_col. There may be some tags appearing 
    in the output which were not in the original tag_list; these will be marked with a ! prefix.

    Args:
        tags_list (list): The list of tags given by the user
        tags_col (pd.Series): A column of lists where each list contains tags which are 
        (ideally but not always; depending on OpenAI) selected from the tags_list.
    
    Returns:
        pd.DataFrame: A DataFrame with two columns. The first contains individual tags(str) which have 
        appeared either in the tags_list, the lists within the tags_col, or both. The second
        contains the number of occurrences(int) of that tag within the lists in the tags_col.    
    """
    # Initialise Counter hash table
    tags_counter = Counter({tag: 0 for tag in tags_list})
    # Iterate over the lists in tags_col
    for sublist in tags_col:
        # Iterate over the tags in each list
        for tag in sublist:
            # Update the tags_counter for each tag
            if tag in tags_list:
                tags_counter.update([tag])
            # If the tag was not in the tags_list given by the user, prefix it with a ! before updating
            else:
                tags_counter.update([f"!{tag}"])
    # Convert the tags_counter to a DataFrame and return it
    tags_counter_df = pd.DataFrame(tags_counter.items(), columns=['Tag', 'Count'])
    return tags_counter_df


# Function that takes in a list of tags and an Excel file of quotes, calls tag_quote() on each quote, and returns all the quotes and tags in a DataFrame     
def process_quotes(quotes_file_path: str, quotes_col_name: str, retained_columns: str, tags_string: str) -> Generator[tuple[str, pd.DataFrame, pd.DataFrame, str]]:
    """
    Processes quotes from an Excel file and assigns relevant tags to each quote.

    This function reads an Excel file containing quotes, validates the column containing
    the quotes, and applies the `tag_quote` function to assign tags to each quote.
    The tags are derived from a user-provided newline-separated string.

    Args:
        quotes_file_path (str): Path to the Excel file containing the quotes.
        quotes_col_name (str): The name of the column containing the quotes.
        retained_columns (str): The names of the columns in the Excel file which are to be added to the output file.
        tags_string (str): A newline-separated string of potential tags.

    Yields:
        tuple: A 4-element tuple containing:
        - str: A progress indicator (or "Not running" if tagging is complete)
        - pd.DataFrame: A DataFrame with two columns: (or None if tagging is incomplete)
            - The original column containing the quotes.
            - A new column 'Tags' with the tags assigned to each quote.
        - pd.DataFrame: A DataFrame with two columns: (or None if tagging is incomplete)
            -"Tag" - The list of tags that was passed in.
            -"Count" - The total number of times each tag was used in tagging all the quotes.
        - str: A path to an Excel file containing sheets derived from the previous 2 DataFrames. (or None if tagging is incomplete)

    Raises:
        gr.Error: If the specified column name does not exist or is not unique.
    """
    tags_list = tags_string.split('\n')
    tags_list = [tag.strip() for tag in tags_list]

    if retained_columns:
        retained_cols_list = retained_columns.split(',')
        retained_cols_list = [colname.strip() for colname in retained_cols_list]
    else:
        retained_cols_list = []

    # Transfer quotes data from Excel file into pandas DataFrame, handling potential duplicate column names in the Excel file
    # pd.read_excel will rename duplicates eg foo -> foo.1, causing a mismatch between quotes_col_name and the actual column name
    # Extract the first row(the actual header for the DataFrame) as a DataFrame without header.
    quotes_df_cols= pd.read_excel(quotes_file_path, header=None, nrows=1).values[0] 
    # Extract all the other rows of the Excel file as a DataFrame without header
    quotes_df = pd.read_excel(quotes_file_path, header=None, skiprows=1) 
    # Set the extracted first row as the header for the DataFrame resultant from the other rows
    quotes_df.columns = quotes_df_cols
    # Verify that all column names given are found in the quotes DF exactly once each
    for colname in retained_cols_list + [quotes_col_name]:
        count = quotes_df.columns.tolist().count(colname)
        if count == 0:
            raise gr.Error(f"No columns with name {colname} found, check your inputs")
        elif count > 1:
            raise gr.Error(f"Multiple columns with name {colname} found, please rename these columns to something unique")

    quotes_data = quotes_df[quotes_col_name]
    
    # Tag all the quotes one by one using tag_quote function
    tags_results = [None]*len(quotes_data)

    # Threading execution of tag_quotes with {max_workers} threads: we send {max_workers} requests to the LLM concurrently.
    with ThreadPoolExecutor() as executor:
        # Generate futures for each of the quotes and map them to the quote indices
        future_to_index = {
            executor.submit(tag_quote, quote, tags_list): i for i, quote in enumerate(quotes_data)
        }
        # Enumerate the completed futures(ordered as completed which may be different from submitted order)
        # This step waits for the tag_quote functions to complete
        for completed, future in enumerate(as_completed(future_to_index), 1):
            # Retrieve index of the completed future from above map
            i = future_to_index[future]
            # Insert the result of the completed future into the results list at its quote's original position
            try:
                tags_results[i] = future.result()
            except Exception as e:
                tags_results[i] = f"Error:{e}"
            # Update UI by yielding a status update
            yield (f"Tagged {completed}/{len(quotes_data)} quotes: {quotes_data[i]}", None, None, None)

    quotes_df['Tags'] = tags_results

    # One hot encoding of tagged tags
    for tag in tags_list:
        quotes_df[tag]=quotes_df['Tags'].apply(lambda quote_tags: int(tag in quote_tags))
    logger.info("Quotes tagged")

    # Create hash table of tag occurrences using count_tags function
    tags_counter_df = count_tags(tags_list, quotes_df['Tags'])

    # Retrieve 2 quotes at random for each tag and put them in the tags counter DF
    for tag in tags_counter_df['Tag']:
        tagged_quotes_list = quotes_df.loc[quotes_df[tag]==1, quotes_col_name].tolist()
        sample_quotes = random.sample(tagged_quotes_list, min(2, len(tagged_quotes_list)))
        translated_quotes = [translate_quote(quote) for quote in sample_quotes]
        while len(sample_quotes) < 2:
            sample_quotes.append(None)
            translated_quotes.append(None)
        [tags_counter_df.loc[tags_counter_df['Tag'] == tag, 'Quote 1'], tags_counter_df.loc[tags_counter_df['Tag'] == tag, 'Quote 2']] = sample_quotes
        [tags_counter_df.loc[tags_counter_df['Tag'] == tag, 'Translated Quote 1'], tags_counter_df.loc[tags_counter_df['Tag'] == tag, 'Translated Quote 2']] = translated_quotes

    #Convert values in tags column from list to str
    quotes_df['Tags'] = quotes_df["Tags"].apply(lambda x: ", ".join(x))

    # Return only the quotes column, the new tags column, and any other specified cols to retain
    output_df = quotes_df[retained_cols_list+[quotes_col_name, 'Tags']+tags_list] 
    output_file_path = "output.xlsx"
    with pd.ExcelWriter(output_file_path) as writer:
        output_df.to_excel(writer, sheet_name='Coded Quotes', index=False)
        tags_counter_df.to_excel(writer, sheet_name='Tag Count', index=False)
    logger.info('Results written to Excel')
    yield ("Not running", output_df[[quotes_col_name, 'Tags']], tags_counter_df, output_file_path)

def check_auth(username:str, password:str):
    """
    Authenticate the user.
    
    Verifies the user's credentials against the values stored in the environment variables.
    User may authenticate with permanent username and password(for TFT team) or temporary username and password.
    For temporary username and password, they will only be valid before the expiry time as set in the environment variables.

    Returns True or False depending on authentication success.
    """
    # Check permanent credentials
    if username == os.getenv('APP_USERNAME') and password == os.getenv('APP_PASSWORD'):
        return True

    # Check temporary credentials
    if (
        username == os.getenv('TEMP_USERNAME') and 
        password == os.getenv('TEMP_PASSWORD') and 
        time.time() < datetime.fromisoformat(os.getenv('TEMP_EXPIRY_TIME_SG_ISO_8601').replace("Z", "+08:00")).timestamp()
    ):
        return True

    # Invalid credentials
    return False
    
# Define user interface structure
demo = gr.Interface(
    fn=process_quotes, 
    inputs=[
        gr.File(label="Quotes Excel File"),
        gr.Textbox(label="Name of quotes column"),
        gr.Textbox(label = "Names of columns(eg respondentID) to retain in output, separated by commas"),
        gr.Textbox(label = "List of tags, each tag on a new line"),
    ],
    outputs=[
        gr.Textbox(label="Progress", value = "Not running"),
        gr.Dataframe(headers=["Quote", "Tags"], column_widths=["70%", "30%"], scale=2, label='Coded Quotes'),
        gr.Dataframe(headers=["Tag", "Count"], label='Tag Count'),
        gr.File(label="Output data in file format")
        
    ],
    title="Automated Research Code Tagger",
    description=INSTRUCTIONS 
    )

demo.launch(share=True, auth=check_auth, ssr_mode=False)