From 65dbcac398d4d7ef2bff3bc2f045ef003d9909c7 Mon Sep 17 00:00:00 2001 From: sei-policy-reader Date: Thu, 26 Jun 2025 13:46:55 -0400 Subject: [PATCH] 1) Added different OpenAI models 2) Allow for CSV import of variable specification table 3) Place initial instructions in a drop-down/expander --- analysis.py | 29 ++++++++++++------- interface.py | 80 +++++++++++++++++++++++++++++++++++++-------------- main.py | 15 +++++----- query_gpt.py | 25 ++++++++++------ results.docx | Bin 47844 -> 48131 bytes 5 files changed, 99 insertions(+), 50 deletions(-) diff --git a/analysis.py b/analysis.py index c00ebec..4e305ca 100644 --- a/analysis.py +++ b/analysis.py @@ -13,7 +13,7 @@ class GPTAnalyzer: """ def __init__( - self, pdfs, main_query, variable_specs, email, output_fmt, additional_info + self, pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ): """ Initializes the GPTAnalyzer with the given parameters. @@ -24,6 +24,7 @@ def __init__( self.email = email self.output_fmt = output_fmt self.additional_info = additional_info + self.gpt_model = gpt_model def __str__(self): """ @@ -91,6 +92,12 @@ def resp_format_type(self): Returns the response format type. """ return "json_object" + + def get_gpt_model(self): + """ + Returns the gpt model selected by the user (or default is "o4-mini"). + """ + return self.gpt_model class DefaultAnalyzer(GPTAnalyzer): @@ -99,13 +106,13 @@ class DefaultAnalyzer(GPTAnalyzer): """ def __init__( - self, pdfs, main_query, variable_specs, email, output_fmt, additional_info + self, pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ): """ Initializes the DefaultAnalyzer with the given parameters. """ super().__init__( - pdfs, main_query, variable_specs, email, output_fmt, additional_info + pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ) def output_fmt_prompt(self, var_name): @@ -128,13 +135,13 @@ class CustomOutputAnalyzer(GPTAnalyzer): """ def __init__( - self, pdfs, main_query, variable_specs, email, output_fmt, additional_info + self, pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ): """ Initializes the CustomOutputAnalyzer with the given parameters. """ super().__init__( - pdfs, main_query, variable_specs, email, output_fmt, additional_info + pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ) def output_fmt_prompt(self, var_name): @@ -168,13 +175,13 @@ class QuoteAnalyzer(GPTAnalyzer): """ def __init__( - self, pdfs, main_query, variable_specs, email, output_fmt, additional_info + self, pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ): """ Initializes the QuoteAnalyzer with the given parameters. """ super().__init__( - pdfs, main_query, variable_specs, email, output_fmt, additional_info + pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ) def output_fmt_prompt(self, var_name): @@ -335,13 +342,13 @@ class SummaryAnalyzer(GPTAnalyzer): """ def __init__( - self, pdfs, main_query, variable_specs, email, output_fmt, additional_info + self, pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ): """ Initializes the SummaryAnalyzer with the given parameters. """ super().__init__( - pdfs, main_query, variable_specs, email, output_fmt, additional_info + pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ) def output_fmt_prompt(self, var_name): @@ -403,12 +410,12 @@ def get_task_types(): def get_analyzer( - task_type, output_fmt, pdfs, main_query, variable_specs, email, additional_info + task_type, output_fmt, pdfs, main_query, variable_specs, email, additional_info, gpt_model="o4-mini" ): """ Returns an instance of the appropriate analyzer class based on the task type. """ task_analyzer_class = get_task_types()[task_type] return task_analyzer_class( - pdfs, main_query, variable_specs, email, output_fmt, additional_info + pdfs, main_query, variable_specs, email, output_fmt, additional_info, gpt_model ) diff --git a/interface.py b/interface.py index 62b99ad..c9a8f27 100644 --- a/interface.py +++ b/interface.py @@ -35,8 +35,10 @@ def load_header(): st.markdown(html_temp, unsafe_allow_html=True) -def load_text(): - instructions = """ +def load_instructions(): + with st.expander("ℹ️ Instructions", expanded=True): + + instructions = """ ## How to use Reading through each uploaded policy document, this tool will ask ChatGPT the main query template for each data 'variable' specified below. - **Step 0:** IF YOU ARE A NEW USER, FIRST TEST FUNCTIONALITY ON 1-3 DOCUMENTS. @@ -50,9 +52,9 @@ def load_text(): - **Step 8:** Once results are satisfactory, contact aipolicyreader@sei.org for access to full batch-processing functionality. - **Step 9:** Re-run once more on all policy documents.""" - st.markdown(instructions) - # st.warning("Please first run on a subset of PDF's to fine-tune functionality. Repeatedly running on many PDF's causes avoidable AI-borne GHG emissions.", icon="⚠️") - st.markdown("""## Submit your processing request""") + st.markdown(instructions) + # st.warning("Please first run on a subset of PDF's to fine-tune functionality. Repeatedly running on many PDF's causes avoidable AI-borne GHG emissions.", icon="⚠️") + def upload_file(temp_dir): st.subheader("I. Upload Policy Document(s)") @@ -201,13 +203,28 @@ def populate_with_just_transition(): just_transition_df = var_json_to_df("just_trans_var_specs.json") st.session_state["variables_df"] = just_transition_df - def clear_variables(): empty_df = pd.DataFrame( [{"variable_name": None, "variable_description": None, "context": None}] ) st.session_state["variables_df"] = empty_df +def update_var_spec_df_from_csv(): + csv_file = st.session_state["csv_upload"] + if csv_file is None: + return # Don't do anything if no file is uploaded + try: + df = pd.read_csv(csv_file) + if list(df.columns) != ["variable_name", "variable_description"] and list(df.columns) != ["variable_name", "variable_description", "context"]: + df = pd.read_csv(csv_file, header=None) + if df.shape[1] == 2: + df.columns = ["variable_name", "variable_description"] + df["context"] = None # Add a context column with None values + elif df.shape[1] == 3: + df.columns = ["variable_name", "variable_description", "context"] + st.session_state["variables_df"] = df + except Exception as e: + st.error(f"Error reading CSV: {e}") def input_data_specs(): st.markdown("") @@ -220,7 +237,7 @@ def input_data_specs(): ) st.markdown(hdr) st.markdown( - "**Type-in variable details or copy-and-paste from an excel spreadsheet (3 columns, no headers).**" + "**Type-in variable details, upload a csv, or copy-and-paste from an excel spreadsheet (3 columns, no headers).**" ) if "variables_df" not in st.session_state: st.session_state["variables_df"] = var_json_to_df("default_var_specs.json") @@ -237,17 +254,21 @@ def input_data_specs(): hide_index=True, column_order=variable_specification_parameters, ) - btn1, btn2, btn3 = st.columns([1, 1, 1]) + btn1, btn2, _, btn4 = st.columns([5, 5, 2, 3]) with btn1: st.button("Clear", on_click=clear_variables) with btn2: - st.button("Populate with SDGs", on_click=populate_with_SDGs) - with btn3: - st.button( - "Use Just-Transition Themes", - on_click=populate_with_just_transition, - use_container_width=True, - ) + with st.popover("Populate with..."): + st.button("SDGs", on_click=populate_with_SDGs) + st.button("Just-Transition Themes", on_click=populate_with_just_transition) + with btn4: + with st.popover("📤 Upload CSV"): + st.file_uploader( + "Choose a CSV file (headers optional, 2 or 3 columns):", + type=["csv"], + key="csv_upload", + on_change=update_var_spec_df_from_csv + ) with st.expander("Advanced settings"): st.selectbox( "Optional: specify the overall operation type", @@ -342,13 +363,22 @@ def is_valid_email(email): validated = re.match(email_regex, email) is not None return validated -def input_email(): - st.markdown( - "For variables with short descriptions, processing time will be about 1 minute per 100 PDF pages per variable." +def select_gpt_model(): + if "gpt_model" not in st.session_state: + st.session_state["gpt_model"] = "o4-mini" # Default model + model_options = { + "o4-mini": "o4-mini", + "o3": "o3 (slower, smarter, more expensive)", + "gpt-4.1": "4.1", + } + st.session_state["gpt_model"] = st.selectbox( + "Select the OpenAI model to use for processing:", + options=list(model_options.keys()), + format_func=lambda x: model_options[x], ) - email = st.text_input("Enter your email where you'd like to receive the results:") - +def input_email(): + email = st.text_input("Enter your email where'd like to receive the results:") if "email" not in st.session_state: st.session_state["email"] = None # Set to None if email is empty, for warning to user if not is_valid_email(email): @@ -363,7 +393,8 @@ def build_interface(tmp_dir): st.session_state["task_type"] = "Quote extraction" if "is_test_run" not in st.session_state: st.session_state["is_test_run"] = True - load_text() + load_instructions() + st.markdown("""## Submit your processing request""") upload_file(tmp_dir) input_main_query() if "output_format_options" not in st.session_state: @@ -387,6 +418,10 @@ def build_interface(tmp_dir): st.session_state["output_detail_df"] = None input_data_specs() st.divider() + st.markdown( + "For variables with short descriptions, processing time will be about 1 minute per 100 PDF pages per variable (with default model selection)." + ) + select_gpt_model() input_email() @@ -439,8 +474,9 @@ def get_user_inputs(): "custom_output_fmt": st.session_state["custom_output_fmt"], "output_detail": st.session_state["output_detail_df"], } + gpt_model = st.session_state["gpt_model"] return get_analyzer( - task_type, output_fmt, pdfs, main_query, variable_specs, email, additional_info + task_type, output_fmt, pdfs, main_query, variable_specs, email, additional_info, gpt_model ) diff --git a/main.py b/main.py index da87706..f3637d2 100644 --- a/main.py +++ b/main.py @@ -67,6 +67,7 @@ def extract_policy_doc_info( var_embeddings, num_excerpts, openai_apikey, + gpt_model ): """ Extracts policy document information by querying GPT for each variable specified. @@ -86,7 +87,8 @@ def extract_policy_doc_info( """ policy_doc_data = {} text_chunks = input_text_chunks - client, gpt_model, max_num_chars = new_openai_session(openai_apikey) + client, max_num_chars = new_openai_session(openai_apikey) + gpt_model = gpt_analyzer.get_gpt_model() # If the text is short, we don't need to generate embeddings to find "relevant texts" # If the text is long, text_chunks (defined above) will be replaced with the top relevant texts run_on_full_text = char_count < (max_num_chars - 1000) @@ -197,6 +199,7 @@ def main(gpt_analyzer, openai_apikey): total_num_pages = 0 total_start_time = time.time() failed_pdfs = [] + gpt_model = gpt_analyzer.get_gpt_model() for pdf in gpt_analyzer.pdfs: pdf_path = get_resource_path(f"{pdf.replace('.pdf','')}.pdf") try: @@ -211,9 +214,7 @@ def main(gpt_analyzer, openai_apikey): num_pages_in_pdf = 0 num_sections = len(text_sections) ## Most PDFs will only have 1 text_section: this is used to break up long documents (>250 pages) - print(2) for text_section in text_sections: - print(3) text_chunks, num_pages, char_count, section = [ text_section[k] for k in ["text_chunks", "num_pages", "num_chars", "section_num"] @@ -224,19 +225,17 @@ def main(gpt_analyzer, openai_apikey): output_pdf_path = f"{pdf_path}" num_pages_in_pdf += num_pages total_num_pages += num_pages - openai_client, _, _ = new_openai_session(openai_apikey) + openai_client, _ = new_openai_session(openai_apikey) pdf_embeddings, pdf_text_chunks = generate_all_embeddings( openai_client, output_pdf_path, text_chunks, get_resource_path ) - print(4) # 2) Prepare embeddings to grab most relevant text excerpts for each variable - openai_client, _, _ = new_openai_session(openai_apikey) + openai_client, _ = new_openai_session(openai_apikey) var_embeddings = embed_variable_specifications( openai_client, gpt_analyzer.variable_specs ) # i.e. {"var_name": {"embedding": <...>", "variable_description": <...>, "context": <...>}, ...} # 3) Iterate through each variable specification to grab relevant texts and query - print(5) num_excerpts = gpt_analyzer.get_num_excerpts(num_pages) policy_info = extract_policy_doc_info( gpt_analyzer, @@ -246,8 +245,8 @@ def main(gpt_analyzer, openai_apikey): var_embeddings, num_excerpts, openai_apikey, + gpt_model ) - print(6) # 4) Output Results output_results(gpt_analyzer, output_doc, output_pdf_path, policy_info) print_milestone( diff --git a/query_gpt.py b/query_gpt.py index faf5501..ae75084 100644 --- a/query_gpt.py +++ b/query_gpt.py @@ -5,9 +5,8 @@ def new_openai_session(openai_apikey): os.environ["OPENAI_API_KEY"] = openai_apikey client = OpenAI() - gpt_model = "gpt-4o" # "o1-preview" max_num_chars = 25000 - return client, gpt_model, max_num_chars + return client, max_num_chars def create_gpt_messages(query, run_on_full_text): @@ -26,12 +25,20 @@ def create_gpt_messages(query, run_on_full_text): def chat_gpt_query(gpt_client, gpt_model, resp_fmt, msgs): - response = gpt_client.chat.completions.create( - model=gpt_model, - temperature=0, - response_format={"type": resp_fmt}, - messages=msgs, - ) + print(gpt_model) + if gpt_model == "gpt-4.1": + response = gpt_client.chat.completions.create( + model=gpt_model, + temperature=0, + response_format={"type": resp_fmt}, + messages=msgs, + ) + else: + response = gpt_client.chat.completions.create( + model=gpt_model, + response_format={"type": resp_fmt}, + messages=msgs, + ) return response.choices[0].message.content @@ -53,7 +60,7 @@ def query_gpt_for_variable_specification( relevant_texts, run_on_full_text, gpt_client, - gpt_model, + gpt_model="o4-mini", ): query_template = gpt_analyzer.main_query excerpts = "\n".join(relevant_texts) diff --git a/results.docx b/results.docx index 3118a26214066a0b6a837ddeea9edf07e4915519..9df4a8522da2229306efb3338806285363811f2f 100644 GIT binary patch delta 3153 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 delta 2916 zcmY*bc{tSDAD$VE40EX&TVpAS5XM^BbrHkZvW$IAml(@f<2IQgJLyZ=lQ76w$8MU8 zQb@9vaxK|HStFtR%I%N)JAa(#J@51RyytnJ^ZA@}$UBhPEl5oQD+?>xu0ENS5Aw%P zU_a=$0-s|8fwmAp4fHgz`0iQts;k3t?`TGxpCqw$EAD4tm?Bv@Wci&8;j03#-w4#u z9q)UJN_B@GlgDlnYEhTP`}?1##l^+l*xuLR_w1rgV^SN{J_jVBv&%Ai7ynwmH$;{s z%etS2^Z{>dSZT0<03mZk#DreIdcd0PCx6 z5m6|CD5!C*3O=sqCi%rB)`mj~y0TrKXE9XKH@6fz8kD1a-$LMz*9$@3GiqYi=!EWs z!3zx^m!y=x*x6}B_mS*H2pyl9V@vXVFAb=cy>5Op-N)jF1R+lX?j{-F{tIoY*dtvcXBEd5unQA?(o_VpQ}dE*(wHj$F+Cv3;L8W29{W|DAd<8|k1+?#(Y$9igWIyuo|Im4mD zzGKI(23dBa!k;j3WEe}6?i3^WbpLl zD{YipD(l@lM(oT%!_19Y|G<&Tr?vKc16LPEC zQ(s))&HLM$_hSX zv0^y<>rP)l_X0P)xkIuqX*_Y_F=6Yyrh6@j*wosn}EwprP8rdAktr;!+M2u zMX}jqJ5_}~_2HojfNwLD^xTs~F3WL*sZQz;QI`D&=%z2N1Eu1FT$3sZmCM9s!N>m1 zj5LZ^^zc%{#PLd{)? z#m>?jmi#6%cQS)|X!jLPJ3n{a-Nzoi#R2QIRHBUG(ZLT;PEHKT6>V@BwB zjaKFZgLRuCL#CkfD8I#9cz2Rir}4OY=oqZ#B_2zXDII5;ShLr{RjcPNT$P+FWYT}Q zl}i)yV0ReMODRiLGHXJK+cZ4Y$iFQ9ckVAL={yXrmzR)ndry>`bV zQ($I{S$*&LiDeYOmC&ONaK_yPL{{cS(#s_xxF6>jG;MSd>7LP&Roub^9U-EVXJPxw zK5N!!>%!;(=$U@bj4H2|9g3*P_t#MFfHSKE|1H%C2jRi}B+nP2)$FNfKZfOH30rD^ zT{G30+I~+5zX~GlUcY>+GZG&AE&Z0Q(B2mTexvZ3$%Ds{uYGQ%(okG`Zj81`t^%Fz zw9v@!rjy!2NsHb3`nr;d*ME5B^sLd(eH;nHDtu2@)3)IiShW^=_YBcWejVm_${|1pFS8s1k#PBcW6UFN zLk^jl$XD#Y06-&e9}?6UpX zX+i%h>43+}1#yjPoh;Txiezg#nLm0_>2l5)($1<^h)Y2KkDc|O4v^O+;JvYs=-KAQ z@}U66h#i}lo^gtBmN|=p5clTTV~(~i1C*Fqc*;k8>D^-6htm!t3xq&L{ZctpCa+KC zRZ7L#JCAxB#++3f><)Cql}mDE3#SEVngPXT$?GUS4Y88ix$gH_ABK(a?rU4id>C-vyReb`*-O^r;O{@2 zL>J&uHMh4G0ikNs4>zGzSc)f!f_M_&iy6uHMeIM8q4S*b$gUjIH7b}=Iv1X)rTwhh ze#`NFDr%NzP(0khyO|c8ZQ$Ke$r)lyv>FQL?Y9m=I@(_ZLo9_;teMOH z=cA@&+4pQcsTBjSqCUN%4`F9qb_7+Jp&=wjkajOYG%AhHVjk<(x?W^QTRXv8h|0+v zXx-^56BWIjERMi`F)>=EUHaGY387WIbwe%+?#?kY~*^x*C0`i)hyt`H|JrSIUw`WUFN z<$(R?=chWSRy<;18gKrVeS=1b)yAU{VASD9k^|Q=S0aZa@Lv4aXt;VL)ty z4#X%Nc;27|8NUv&H|j!0q5({!1}l{W0s+$%NT8rm8B$6HrkZ$x#YTAuJq9@5qy@pn z1Na0%fY2n(f=LAdZfQt>*2MGssxWWng*avZOwoe`NbBj}o$yP@sPs?>*UBsO z|0M?c&v2js39Z_!#pQ?6BEV9sH004EfUoTwWTp}@Ytw|>t^!iqv>~B2Ky9rE@VSi# J3a