AI-Assisted Research Ethics Review
This document contains the authored Python source files for the bespoke LLM-assisted research ethics triage system. Environment files, installed dependencies, cached files, generated results and credentials are intentionally excluded.
Contents
1. app.py
1import streamlit as st2 3from analyser import analyse_application4from document_reader import read_document5from result_saver import save_result6 7 8st.set_page_config(9 page_title="AI Ethics Review System",10 page_icon="⚖️",11 layout="wide",12)13 14st.title("⚖️ AI Ethics Review System")15 16st.write(17 "Upload an anonymised ethics application and the system will identify "18 "potential ethical concerns."19)20 21uploaded_file = st.file_uploader(22 "Choose an ethics application",23 type=["txt", "docx"],24)25 26 27def calculate_overall_risk(issues):28 severities = [29 issue.get("severity", "").strip().lower()30 for issue in issues31 ]32 33 if "high" in severities:34 return "High"35 36 if "medium" in severities:37 return "Medium"38 39 return "Low"40 41 42def calculate_overall_recommendation(risk_level):43 if risk_level == "High":44 return (45 "Do not approve until the high-severity issues and required "46 "supporting documentation have been satisfactorily resolved."47 )48 49 if risk_level == "Medium":50 return (51 "Conditional approval may be considered after the identified "52 "issues have been addressed and the missing information supplied."53 )54 55 return (56 "The application appears suitable for approval, subject to routine "57 "checks and confirmation that all required documentation is complete."58 )59 60 61def show_risk_level(risk_level):62 if risk_level == "High":63 st.error("🔴 High Risk")64 elif risk_level == "Medium":65 st.warning("🟠 Medium Risk")66 else:67 st.success("🟢 Low Risk")68 69 70def show_issue(issue, issue_number):71 category = issue.get("category", "Uncategorised Issue")72 severity = issue.get("severity", "Unknown")73 74 with st.expander(75 f"Issue {issue_number}: {category} — {severity}",76 expanded=True,77 ):78 st.markdown(f"**Severity:** {severity}")79 80 st.markdown("### Evidence")81 st.info(issue.get("evidence", "No evidence provided."))82 83 st.markdown("### Explanation")84 st.write(issue.get("explanation", "No explanation provided."))85 86 st.markdown("### Recommendation")87 st.write(issue.get("recommendation", "No recommendation provided."))88 89 90if uploaded_file is not None:91 92 try:93 94 document_text = read_document(uploaded_file)95 96 st.success(f"File uploaded: {uploaded_file.name}")97 98 with st.expander("View Extracted Document Text"):99 st.text_area(100 "Extracted Content",101 document_text,102 height=350,103 )104 105 if st.button("Analyse Application", type="primary"):106 107 with st.spinner("Analysing ethics application..."):108 109 analysis = analyse_application(document_text)110 111 issues = analysis.get("issues", [])112 113 calculated_risk = calculate_overall_risk(issues)114 115 calculated_recommendation = (116 calculate_overall_recommendation(117 calculated_risk118 )119 )120 121 saved_path = save_result(122 uploaded_file.name,123 analysis,124 calculated_risk,125 calculated_recommendation,126 )127 128 st.divider()129 130 st.header("Ethics Review Results")131 132 st.subheader("Overall Risk")133 134 show_risk_level(calculated_risk)135 136 high_count = sum(137 1138 for issue in issues139 if issue.get("severity", "").lower() == "high"140 )141 142 medium_count = sum(143 1144 for issue in issues145 if issue.get("severity", "").lower() == "medium"146 )147 148 low_count = sum(149 1150 for issue in issues151 if issue.get("severity", "").lower() == "low"152 )153 154 col1, col2, col3, col4 = st.columns(4)155 156 col1.metric("Total Issues", len(issues))157 col2.metric("High", high_count)158 col3.metric("Medium", medium_count)159 col4.metric("Low", low_count)160 161 st.subheader("Ethical Issues")162 163 if issues:164 165 for number, issue in enumerate(166 issues,167 start=1,168 ):169 show_issue(issue, number)170 171 else:172 173 st.success(174 "No ethical issues were identified."175 )176 177 st.subheader("Strengths")178 179 for strength in analysis.get(180 "strengths",181 [],182 ):183 st.success(strength)184 185 st.subheader("Missing Information")186 187 for item in analysis.get(188 "missing_information",189 [],190 ):191 st.warning(item)192 193 st.subheader("Overall Recommendation")194 195 st.info(calculated_recommendation)196 197 st.success(198 f"Review automatically saved to:\n\n{saved_path}"199 )200 201 with st.expander(202 "System Decision Rules"203 ):204 205 st.write(206 "Overall Risk is calculated by the system, "207 "not by the AI."208 )209 210 st.write(211 "• Any High issue = High Risk"212 )213 214 st.write(215 "• Otherwise any Medium issue = Medium Risk"216 )217 218 st.write(219 "• Otherwise = Low Risk"220 )221 222 except Exception as error:223 224 st.error(error)2. analyser.py
1import json2import os3 4from dotenv import load_dotenv5from openai import OpenAI6 7from prompts import ETHICS_CATEGORIES, build_category_prompt8 9 10load_dotenv()11 12client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))13 14 15def analyse_single_category(document_text, category):16 prompt = build_category_prompt(category)17 18 response = client.responses.create(19 model="gpt-5-mini",20 instructions=prompt,21 input=document_text,22 )23 24 response_text = response.output_text.strip()25 26 try:27 return json.loads(response_text)28 29 except json.JSONDecodeError as error:30 raise ValueError(31 f"The AI response for '{category}' was not valid JSON."32 ) from error33 34 35def analyse_application(document_text):36 category_results = []37 38 for category in ETHICS_CATEGORIES:39 result = analyse_single_category(40 document_text,41 category,42 )43 44 category_results.append(result)45 46 issues = [47 result48 for result in category_results49 if result.get("issue_found") is True50 ]51 52 strengths = []53 54 if not issues:55 strengths.append(56 "No material ethical concerns were identified across the "57 "predefined review categories."58 )59 60 missing_information = []61 62 for result in category_results:63 if result.get("category") == "Missing Documentation":64 if result.get("issue_found") is True:65 recommendation = result.get(66 "recommendation",67 "",68 ).strip()69 70 if recommendation:71 missing_information.append(recommendation)72 73 return {74 "issues": issues,75 "strengths": strengths,76 "missing_information": missing_information,77 "category_results": category_results,78 }3. document_reader.py
1from docx import Document2 3 4def read_txt(uploaded_file):5 return uploaded_file.read().decode("utf-8")6 7 8def read_docx(uploaded_file):9 document = Document(uploaded_file)10 text_parts = []11 12 for paragraph in document.paragraphs:13 paragraph_text = paragraph.text.strip()14 15 if paragraph_text:16 text_parts.append(paragraph_text)17 18 for table in document.tables:19 for row in table.rows:20 row_text = []21 22 for cell in row.cells:23 cell_text = cell.text.strip()24 25 if cell_text and cell_text not in row_text:26 row_text.append(cell_text)27 28 if row_text:29 text_parts.append(" | ".join(row_text))30 31 return "\n\n".join(text_parts)32 33 34def read_document(uploaded_file):35 if uploaded_file.type == "text/plain":36 return read_txt(uploaded_file)37 38 if uploaded_file.type == (39 "application/vnd.openxmlformats-officedocument."40 "wordprocessingml.document"41 ):42 return read_docx(uploaded_file)43 44 raise ValueError("Unsupported file type.")4. prompts.py
1ETHICS_CATEGORIES = [2 "Consent and Withdrawal",3 "Data Protection and Confidentiality",4 "Participant Welfare and Safeguarding",5 "Recruitment and Payments",6 "Researcher Safety",7 "External Permissions and Legal Compliance",8 "Environmental and Heritage Impact",9 "Documentation and Governance",10]11 12 13CATEGORY_GUIDANCE = {14 "Consent and Withdrawal": (15 "Consider participant information, written or verbal consent, "16 "language accessibility, comprehension, recordings, photographs, "17 "quotations, voluntary participation and the right to withdraw."18 ),19 "Data Protection and Confidentiality": (20 "Consider personal data, anonymity, pseudonymisation, recordings, "21 "photographs, storage, encryption, cloud services, retention, "22 "deletion, access controls and international data transfers."23 ),24 "Participant Welfare and Safeguarding": (25 "Consider physical, emotional, psychological, social, legal or "26 "reputational harm, vulnerable participants, capacity to consent, "27 "children, safeguarding procedures and dependent relationships."28 ),29 "Recruitment and Payments": (30 "Consider recruitment methods, gatekeepers, snowball sampling, "31 "coercion, payments, gifts, reimbursement and undue inducement."32 ),33 "Researcher Safety": (34 "Consider travel, lone working, remote fieldwork, wildlife, emergency "35 "procedures, communications, insurance, first aid and researcher "36 "wellbeing."37 ),38 "External Permissions and Legal Compliance": (39 "Consider organisational permission, site access, research permits, "40 "photography permission, archive access, sample collection licences, "41 "local laws and external ethical approval."42 ),43 "Environmental and Heritage Impact": (44 "Consider environmental damage, protected sites, archaeological or "45 "cultural heritage, sampling, material removal, conservation and "46 "non-destructive methods."47 ),48 "Documentation and Governance": (49 "Consider missing or contradictory supporting documents, risk "50 "assessments, consent materials, data-management plans, permits, "51 "supervisor approval and institutional governance requirements."52 ),53}54 55 56def build_category_prompt(category):57 guidance = CATEGORY_GUIDANCE[category]58 59 return f"""60You are an expert university research ethics reviewer.61 62Analyse the supplied application ONLY for this review domain:63 64{category}65 66Guidance:67 68{guidance}69 70Return ONLY valid JSON using this exact structure:71 72{{73 "category": "{category}",74 "issue_found": false,75 "severity": "None",76 "evidence": "",77 "explanation": "",78 "recommendation": ""79}}80 81Rules:82 83- category must remain exactly "{category}".84- issue_found must be true or false.85- Report an issue only when there is a clear, meaningful concern,86 contradiction or important omission.87- Do not report minor stylistic improvements as ethical issues.88- Do not force an issue to be found.89- If no meaningful issue exists, return issue_found as false.90- If issue_found is false:91 - severity must be "None"92 - evidence must be ""93 - recommendation must be ""94 - explanation must briefly state why no material concern was found.95- If issue_found is true, severity must be exactly:96 "Low", "Medium" or "High".97- Return no more than one combined issue for this domain.98- Do not repeat concerns that belong primarily to another domain.99- Quote evidence from the application where possible.100- Do not invent facts, quotations, laws or missing documents.101- Do not assume an attachment is missing merely because its contents were102 not extracted, unless the application itself makes its status unclear.103 104Severity definitions:105 106High:107A serious legal, safety, safeguarding, consent, data-protection,108environmental or governance concern that should prevent approval until resolved.109 110Medium:111A meaningful concern requiring revision or clarification before full approval.112 113Low:114A minor but genuine ethical or administrative concern that would not normally115prevent approval.116 117Return JSON only.118Do not include markdown.119Do not include introductory or closing comments.120"""5. result_saver.py
1import json2from datetime import datetime3from pathlib import Path4 5 6RESULTS_FOLDER = Path("results")7 8 9def save_result(10 filename,11 analysis,12 calculated_risk,13 calculated_recommendation,14):15 RESULTS_FOLDER.mkdir(exist_ok=True)16 17 issues = analysis.get("issues", [])18 19 result_data = {20 "document_name": filename,21 "analysis_date": datetime.now().isoformat(timespec="seconds"),22 "calculated_risk": calculated_risk,23 "calculated_recommendation": calculated_recommendation,24 "issue_counts": {25 "total": len(issues),26 "high": sum(27 128 for issue in issues29 if issue.get("severity", "").strip().lower() == "high"30 ),31 "medium": sum(32 133 for issue in issues34 if issue.get("severity", "").strip().lower() == "medium"35 ),36 "low": sum(37 138 for issue in issues39 if issue.get("severity", "").strip().lower() == "low"40 ),41 },42 "analysis": analysis,43 }44 45 safe_name = Path(filename).stem.replace(" ", "_")46 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")47 48 output_path = RESULTS_FOLDER / f"{safe_name}_{timestamp}.json"49 50 with output_path.open(51 "w",52 encoding="utf-8",53 ) as file:54 json.dump(55 result_data,56 file,57 indent=4,58 ensure_ascii=False,59 )60 61 return output_path