AI-Assisted Research Ethics Review

Source-code submission

Author: Kelly Casey

MSc Software Engineering and Artificial Intelligence

Prepared: 6 September 2026

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
  2. analyser.py
  3. document_reader.py
  4. prompts.py
  5. result_saver.py

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)

Back to contents

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    }

Back to contents

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.")

Back to contents

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"""

Back to contents

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

Back to contents