Spaces:
Sleeping
Sleeping
Deploy Second Life Flask app
Browse files- .gitattributes +1 -0
- .hfignore +16 -0
- Architecture Diagrams/secondlife high level architecture.drawio +163 -0
- Architecture Diagrams/secondlife high level architecture.png +3 -0
- Architecture Diagrams/secondlife low level architecture.drawio +306 -0
- Architecture Diagrams/secondlife workflow architecture diagrams.html +828 -0
- Dockerfile +16 -0
- README.md +27 -5
- Second_Life_Project_Documentation.docx +0 -0
- app.py +760 -0
- claude_handoff.md +65 -0
- database.py +702 -0
- hf_space_bootstrap.py +58 -0
- llm.md +575 -0
- login.md +38 -0
- model_cache.pkl +3 -0
- pipeline.py +1153 -0
- requirements.txt +6 -0
- secondlife.db +0 -0
- static/.gitkeep +1 -0
- templates/hospital.html +665 -0
- templates/landing.html +202 -0
- templates/patient.html +866 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
Architecture[[:space:]]Diagrams/secondlife[[:space:]]high[[:space:]]level[[:space:]]architecture.png filter=lfs diff=lfs merge=lfs -text
|
.hfignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.claude/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.venv/
|
| 5 |
+
.python_packages/
|
| 6 |
+
.tmp/
|
| 7 |
+
|
| 8 |
+
Final Clinical Trails Data/
|
| 9 |
+
Final Patients Synthea Data/
|
| 10 |
+
mimic-iv-clinical-database-demo-2.2/
|
| 11 |
+
*.zip
|
| 12 |
+
|
| 13 |
+
server.log
|
| 14 |
+
*.log
|
| 15 |
+
*.tmp
|
| 16 |
+
UI/
|
Architecture Diagrams/secondlife high level architecture.drawio
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<mxfile host="app.diagrams.net">
|
| 2 |
+
<diagram id="E15GttQApXdkw6PukwBz" name="Page-1">
|
| 3 |
+
<mxGraphModel dx="2322" dy="2022" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" pageWidth="1600" pageHeight="900" math="0" shadow="0">
|
| 4 |
+
<root>
|
| 5 |
+
<mxCell id="0" />
|
| 6 |
+
<mxCell id="1" parent="0" />
|
| 7 |
+
<mxCell id="userGroup" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#EEF4FF;strokeColor=#C7D8FF;strokeWidth=3;arcSize=18;opacity=70;fontSize=12;" value="" vertex="1">
|
| 8 |
+
<mxGeometry height="356.6470588235295" width="1824.563758389262" x="142.5503355704698" y="18.999411764705883" as="geometry" />
|
| 9 |
+
</mxCell>
|
| 10 |
+
<mxCell id="bg1" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#DCEBFF;strokeColor=none;opacity=55;" value="" vertex="1">
|
| 11 |
+
<mxGeometry height="394.59" width="383.76" x="1676.24" y="-110" as="geometry" />
|
| 12 |
+
</mxCell>
|
| 13 |
+
<mxCell id="bg2" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#DFF7EC;strokeColor=none;opacity=60;" value="" vertex="1">
|
| 14 |
+
<mxGeometry height="364.24" width="367.32" y="815.76" as="geometry" />
|
| 15 |
+
</mxCell>
|
| 16 |
+
<mxCell id="bg3" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFF1D7;strokeColor=none;opacity=65;" value="" vertex="1">
|
| 17 |
+
<mxGeometry height="273.1764705882353" width="237.98657718120808" x="1464.6979865771812" y="815.7647058823532" as="geometry" />
|
| 18 |
+
</mxCell>
|
| 19 |
+
<mxCell id="title" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=40;fontStyle=1;fontColor=#152641;align=center;verticalAlign=middle;" value="Second Life - High-Level Architecture" vertex="1">
|
| 20 |
+
<mxGeometry height="60.70588235294119" width="1216.3758389261745" x="446.64429530201346" y="-106.96470588235294" as="geometry" />
|
| 21 |
+
</mxCell>
|
| 22 |
+
<mxCell id="sec1" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=18;fontStyle=1;fontColor=#61728E;letterSpacing=1;" value="USER EXPERIENCE" vertex="1">
|
| 23 |
+
<mxGeometry height="36.423529411764704" width="224.76510067114094" x="261.5436241610738" y="49.35294117647058" as="geometry" />
|
| 24 |
+
</mxCell>
|
| 25 |
+
<mxCell id="sec3" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=18;fontStyle=1;fontColor=#61728E;letterSpacing=1;" value="DATA AND INTELLIGENCE" vertex="1">
|
| 26 |
+
<mxGeometry height="36.423529411764704" width="290.8724832214765" x="119.99624161073825" y="703.4629411764703" as="geometry" />
|
| 27 |
+
</mxCell>
|
| 28 |
+
<mxCell id="patientCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#EEF4FF;strokeColor=#5C8CF0;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 29 |
+
<mxGeometry height="197.29411764705884" width="661.0738255033558" x="248.32214765100673" y="87.29411764705884" as="geometry" />
|
| 30 |
+
</mxCell>
|
| 31 |
+
<mxCell id="patientBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#5C8CF0;strokeColor=#5C8CF0;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="P" vertex="1">
|
| 32 |
+
<mxGeometry height="72.85" width="74.67" x="290" y="132.82" as="geometry" />
|
| 33 |
+
</mxCell>
|
| 34 |
+
<mxCell id="patientTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=28;fontStyle=1;fontColor=#152641;align=left;" value="Patient Portal" vertex="1">
|
| 35 |
+
<mxGeometry height="51.60000000000001" width="343.758389261745" x="387.1476510067114" y="144.96470588235297" as="geometry" />
|
| 36 |
+
</mxCell>
|
| 37 |
+
<mxCell id="patientBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="Profile, suggested trials, saved interests<br>Connections, inbox chat, document uploads" vertex="1">
|
| 38 |
+
<mxGeometry height="69.81176470588235" width="436.30872483221475" x="387.1476510067114" y="205.67058823529413" as="geometry" />
|
| 39 |
+
</mxCell>
|
| 40 |
+
<mxCell id="hospitalCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#EEF8F2;strokeColor=#41A56C;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 41 |
+
<mxGeometry height="197.29411764705884" width="661.0738255033558" x="1200.268456375839" y="87.29411764705884" as="geometry" />
|
| 42 |
+
</mxCell>
|
| 43 |
+
<mxCell id="hospitalBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#41A56C;strokeColor=#41A56C;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="H" vertex="1">
|
| 44 |
+
<mxGeometry height="72.85" width="76.62" x="1240" y="132.82" as="geometry" />
|
| 45 |
+
</mxCell>
|
| 46 |
+
<mxCell id="hospitalTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=28;fontStyle=1;fontColor=#152641;align=left;" value="Hospital Portal" vertex="1">
|
| 47 |
+
<mxGeometry height="51.60000000000001" width="370.2013422818792" x="1339.0939597315437" y="144.96470588235297" as="geometry" />
|
| 48 |
+
</mxCell>
|
| 49 |
+
<mxCell id="hospitalBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="Available patients, search, and trial view<br>Connections, inbox chat, and profile tools" vertex="1">
|
| 50 |
+
<mxGeometry height="69.81176470588235" width="462.751677852349" x="1339.0939597315437" y="205.67058823529413" as="geometry" />
|
| 51 |
+
</mxCell>
|
| 52 |
+
<mxCell id="flaskCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#F3EEFF;strokeColor=#7A5CD3;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 53 |
+
<mxGeometry height="265.5882352941176" width="819.7315436241612" x="644.9664429530202" y="375.6470588235295" as="geometry" />
|
| 54 |
+
</mxCell>
|
| 55 |
+
<mxCell id="flaskBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#7A5CD3;strokeColor=#7A5CD3;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="W" vertex="1">
|
| 56 |
+
<mxGeometry height="78.92" width="81.89" x="700" y="419.38" as="geometry" />
|
| 57 |
+
</mxCell>
|
| 58 |
+
<mxCell id="flaskTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=30;fontStyle=1;fontColor=#152641;align=left;" value="Flask Web Application" vertex="1">
|
| 59 |
+
<mxGeometry height="57.67058823529412" width="502.41610738255036" x="814.203422818792" y="430.00352941176465" as="geometry" />
|
| 60 |
+
</mxCell>
|
| 61 |
+
<mxCell id="flaskBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="Renders patient and hospital pages<br>Handles login, APIs, sessions, and requests<br>Coordinates matching, inbox, connections, and uploads" vertex="1">
|
| 62 |
+
<mxGeometry height="80.8" width="402.99" x="797.01" y="509.2" as="geometry" />
|
| 63 |
+
</mxCell>
|
| 64 |
+
<mxCell id="dbCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#EFF9F3;strokeColor=#39A66E;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 65 |
+
<mxGeometry height="273.1764705882353" width="502.41610738255036" x="155.7718120805369" y="770.2352941176471" as="geometry" />
|
| 66 |
+
</mxCell>
|
| 67 |
+
<mxCell id="dbBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#39A66E;strokeColor=#39A66E;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="D" vertex="1">
|
| 68 |
+
<mxGeometry height="72.85" width="78.73" x="195.44" y="815.77" as="geometry" />
|
| 69 |
+
</mxCell>
|
| 70 |
+
<mxCell id="dbTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=28;fontStyle=1;fontColor=#152641;align=left;" value="App Database + Files" vertex="1">
|
| 71 |
+
<mxGeometry height="51.60000000000001" width="383.4228187919463" x="289.9980536912752" y="826.3858823529412" as="geometry" />
|
| 72 |
+
</mxCell>
|
| 73 |
+
<mxCell id="dbBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="SQLite for accounts, connections, and messages<br>Uploads folder for patient documents<br>Keeps local portal state for the demo" vertex="1">
|
| 74 |
+
<mxGeometry height="109.27058823529411" width="383.4228187919463" x="202.00993288590604" y="910.0011764705881" as="geometry" />
|
| 75 |
+
</mxCell>
|
| 76 |
+
<mxCell id="mlCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF5E5;strokeColor=#DD8E1D;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 77 |
+
<mxGeometry height="273.1764705882353" width="502.41610738255036" x="803.6241610738255" y="770.2352941176471" as="geometry" />
|
| 78 |
+
</mxCell>
|
| 79 |
+
<mxCell id="mlBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#DD8E1D;strokeColor=#DD8E1D;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="M" vertex="1">
|
| 80 |
+
<mxGeometry height="72.85" width="77.46" x="852.54" y="815.76" as="geometry" />
|
| 81 |
+
</mxCell>
|
| 82 |
+
<mxCell id="mlTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=28;fontStyle=1;fontColor=#152641;align=left;" value="Trial Matching Engine" vertex="1">
|
| 83 |
+
<mxGeometry height="51.6" width="310" x="960" y="826.39" as="geometry" />
|
| 84 |
+
</mxCell>
|
| 85 |
+
<mxCell id="mlBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="Pipeline loads patient and trial datasets<br>Feature engineering + Random Forest model<br>Returns ranked trial matches and hospital trial lists" vertex="1">
|
| 86 |
+
<mxGeometry height="109.27058823529411" width="403.255033557047" x="852.5422818791948" y="910.0011764705881" as="geometry" />
|
| 87 |
+
</mxCell>
|
| 88 |
+
<mxCell id="srcCard" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#EDF9F9;strokeColor=#2D9FA1;strokeWidth=3;arcSize=18;shadow=1;" value="" vertex="1">
|
| 89 |
+
<mxGeometry height="273.1764705882353" width="502.41610738255036" x="1451.476510067114" y="770.2352941176471" as="geometry" />
|
| 90 |
+
</mxCell>
|
| 91 |
+
<mxCell id="srcBadge" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#2D9FA1;strokeColor=#2D9FA1;fontColor=#FFFFFF;fontSize=18;fontStyle=1;align=center;verticalAlign=middle;" value="S" vertex="1">
|
| 92 |
+
<mxGeometry height="72.85" width="78.4" x="1491.61" y="815.76" as="geometry" />
|
| 93 |
+
</mxCell>
|
| 94 |
+
<mxCell id="srcTitle" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=28;fontStyle=1;fontColor=#152641;align=left;" value="Source Data" vertex="1">
|
| 95 |
+
<mxGeometry height="51.60000000000001" width="290.8724832214765" x="1600.0027516778523" y="826.3858823529412" as="geometry" />
|
| 96 |
+
</mxCell>
|
| 97 |
+
<mxCell id="srcBody" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=20;fontColor=#5E708C;align=left;verticalAlign=top;" value="Synthea patient CSVs<br>ClinicalTrials.gov / AACT trial CSVs<br>MIMIC demo data for validation" vertex="1">
|
| 98 |
+
<mxGeometry height="109.27058823529411" width="383.4228187919463" x="1507.4546308724832" y="897.7211764705881" as="geometry" />
|
| 99 |
+
</mxCell>
|
| 100 |
+
<mxCell id="lblUser" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;fontSize=18;fontStyle=1;fontColor=#5C6E89;align=center;verticalAlign=middle;" value="user requests" vertex="1">
|
| 101 |
+
<mxGeometry height="48.56470588235294" width="156.01342281879195" x="976.8232214765102" y="287.6205882352941" as="geometry" />
|
| 102 |
+
</mxCell>
|
| 103 |
+
<mxCell id="lblRead" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;fontSize=18;fontStyle=1;fontColor=#5C6E89;align=center;verticalAlign=middle;" value="read / write" vertex="1">
|
| 104 |
+
<mxGeometry height="48.56470588235294" width="145.43624161073825" x="439.9987919463087" y="660.0011764705882" as="geometry" />
|
| 105 |
+
</mxCell>
|
| 106 |
+
<mxCell id="lblMatch" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;fontSize=18;fontStyle=1;fontColor=#5C6E89;align=center;verticalAlign=middle;" value="match" vertex="1">
|
| 107 |
+
<mxGeometry height="48.56470588235294" width="105.7718120805369" x="930.0036912751677" y="690.0011764705882" as="geometry" />
|
| 108 |
+
</mxCell>
|
| 109 |
+
<mxCell id="lblRank" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;fontSize=18;fontStyle=1;fontColor=#5C6E89;align=center;verticalAlign=middle;" value="ranked results" vertex="1">
|
| 110 |
+
<mxGeometry height="48.56470588235294" width="166.59060402684565" x="1150.029932885906" y="689.9982352941176" as="geometry" />
|
| 111 |
+
</mxCell>
|
| 112 |
+
<mxCell id="lblFeed" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;fontSize=18;fontStyle=1;fontColor=#5C6E89;align=center;verticalAlign=middle;" value="data feed" vertex="1">
|
| 113 |
+
<mxGeometry height="48.56470588235294" width="137.50335570469798" x="1633.9364429530203" y="697.3929411764708" as="geometry" />
|
| 114 |
+
</mxCell>
|
| 115 |
+
<mxCell id="e1" edge="1" parent="1" source="patientCard" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;" target="flaskCard" value="">
|
| 116 |
+
<mxGeometry relative="1" as="geometry">
|
| 117 |
+
<Array as="points">
|
| 118 |
+
<mxPoint x="730" y="320" />
|
| 119 |
+
<mxPoint x="730" y="320" />
|
| 120 |
+
</Array>
|
| 121 |
+
</mxGeometry>
|
| 122 |
+
</mxCell>
|
| 123 |
+
<mxCell id="e2" edge="1" parent="1" source="hospitalCard" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;" target="flaskCard" value="">
|
| 124 |
+
<mxGeometry relative="1" as="geometry">
|
| 125 |
+
<Array as="points">
|
| 126 |
+
<mxPoint x="1380" y="330" />
|
| 127 |
+
<mxPoint x="1380" y="330" />
|
| 128 |
+
</Array>
|
| 129 |
+
</mxGeometry>
|
| 130 |
+
</mxCell>
|
| 131 |
+
<mxCell id="e4" edge="1" parent="1" source="flaskCard" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;" target="mlCard" value="">
|
| 132 |
+
<mxGeometry relative="1" as="geometry" />
|
| 133 |
+
</mxCell>
|
| 134 |
+
<mxCell id="e5" edge="1" parent="1" source="srcCard" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;" target="mlCard" value="">
|
| 135 |
+
<mxGeometry relative="1" as="geometry" />
|
| 136 |
+
</mxCell>
|
| 137 |
+
<mxCell id="e6" edge="1" parent="1" source="mlCard" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;" target="flaskCard" value="">
|
| 138 |
+
<mxGeometry relative="1" as="geometry">
|
| 139 |
+
<Array as="points">
|
| 140 |
+
<mxPoint x="1130" y="700" />
|
| 141 |
+
<mxPoint x="1130" y="700" />
|
| 142 |
+
</Array>
|
| 143 |
+
</mxGeometry>
|
| 144 |
+
</mxCell>
|
| 145 |
+
<mxCell id="footer" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=18;fontStyle=1;fontColor=#667892;align=center;" value="FLOW: portals -&gt; Flask app -&gt; matching engine -&gt; recommendations, with app state stored in SQLite" vertex="1">
|
| 146 |
+
<mxGeometry height="36.423529411764704" width="1322.1476510067116" x="393.758389261745" y="1113.2235294117647" as="geometry" />
|
| 147 |
+
</mxCell>
|
| 148 |
+
<mxCell id="sec2" parent="1" style="text;html=1;strokeColor=none;fillColor=none;fontSize=18;fontStyle=1;fontColor=#61728E;letterSpacing=1;" value="APPLICATION LAYER" vertex="1">
|
| 149 |
+
<mxGeometry height="36.423529411764704" width="251.20805369127518" x="499.9986577181208" y="319.99823529411765" as="geometry" />
|
| 150 |
+
</mxCell>
|
| 151 |
+
<mxCell id="7qRbrwoMCzgDvgHPFRhy-5" edge="1" parent="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=1;strokeColor=#42536F;strokeWidth=3;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" target="dbCard" value="">
|
| 152 |
+
<mxGeometry relative="1" as="geometry">
|
| 153 |
+
<Array as="points">
|
| 154 |
+
<mxPoint x="407" y="550" />
|
| 155 |
+
</Array>
|
| 156 |
+
<mxPoint x="640" y="550" as="sourcePoint" />
|
| 157 |
+
<mxPoint x="400" y="709" as="targetPoint" />
|
| 158 |
+
</mxGeometry>
|
| 159 |
+
</mxCell>
|
| 160 |
+
</root>
|
| 161 |
+
</mxGraphModel>
|
| 162 |
+
</diagram>
|
| 163 |
+
</mxfile>
|
Architecture Diagrams/secondlife high level architecture.png
ADDED
|
Git LFS Details
|
Architecture Diagrams/secondlife low level architecture.drawio
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<mxGraphModel dx="1422" dy="762" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1700" pageHeight="1100" math="0" shadow="0">
|
| 3 |
+
<root>
|
| 4 |
+
<mxCell id="0" />
|
| 5 |
+
<mxCell id="1" parent="0" />
|
| 6 |
+
|
| 7 |
+
<!-- ============================================================
|
| 8 |
+
TITLE
|
| 9 |
+
============================================================ -->
|
| 10 |
+
<mxCell id="TITLE" value="Second Life β System Architecture | DSCI 5260 Β· Group 7 | AI-Powered Clinical Trial Matching" style="text;html=1;strokeColor=none;fillColor=none;fontSize=16;fontStyle=1;fontColor=#003366;align=center;" vertex="1" parent="1">
|
| 11 |
+
<mxGeometry x="10" y="1078" width="1680" height="30" as="geometry" />
|
| 12 |
+
</mxCell>
|
| 13 |
+
|
| 14 |
+
<!-- ============================================================
|
| 15 |
+
LAYER 1 β BROWSER
|
| 16 |
+
============================================================ -->
|
| 17 |
+
<mxCell id="L1" value="π Browser (Client)" style="swimlane;startSize=32;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;fontStyle=1;fontColor=#003366;swimlaneLine=1;" vertex="1" parent="1">
|
| 18 |
+
<mxGeometry x="10" y="10" width="1680" height="235" as="geometry" />
|
| 19 |
+
</mxCell>
|
| 20 |
+
|
| 21 |
+
<!-- Patient Portal -->
|
| 22 |
+
<mxCell id="PPG" value="Patient Portal β patient.html" style="swimlane;startSize=24;fillColor=#cce5ff;strokeColor=#0066cc;fontSize=11;fontStyle=1;" vertex="1" parent="L1">
|
| 23 |
+
<mxGeometry x="20" y="37" width="574" height="183" as="geometry" />
|
| 24 |
+
</mxCell>
|
| 25 |
+
<mxCell id="PP1" value="My Profile
conditions Β· meds
documents upload" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f4fd;strokeColor=#0066cc;fontSize=9;" vertex="1" parent="PPG">
|
| 26 |
+
<mxGeometry x="10" y="30" width="120" height="65" as="geometry" />
|
| 27 |
+
</mxCell>
|
| 28 |
+
<mxCell id="PP2" value="Suggested Trials
AI-ranked top-20
combined_score" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f4fd;strokeColor=#0066cc;fontSize=9;" vertex="1" parent="PPG">
|
| 29 |
+
<mxGeometry x="140" y="30" width="120" height="65" as="geometry" />
|
| 30 |
+
</mxCell>
|
| 31 |
+
<mxCell id="PP3" value="My Connections
status Β· chat modal
view history" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f4fd;strokeColor=#0066cc;fontSize=9;" vertex="1" parent="PPG">
|
| 32 |
+
<mxGeometry x="270" y="30" width="120" height="65" as="geometry" />
|
| 33 |
+
</mxCell>
|
| 34 |
+
<mxCell id="PP4" value="Inbox π΄
unread count badge
all message threads" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f4fd;strokeColor=#0066cc;fontSize=9;" vertex="1" parent="PPG">
|
| 35 |
+
<mxGeometry x="400" y="30" width="120" height="65" as="geometry" />
|
| 36 |
+
</mxCell>
|
| 37 |
+
<mxCell id="PP_NOTE" value="Bootstrap 5.3 dark SPA Β· escH() / escA() XSS guards Β· DOM API only (no innerHTML for user data) Β· startStatusPoll() 5s interval Β· cookie session auth" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#555;align=left;" vertex="1" parent="PPG">
|
| 38 |
+
<mxGeometry x="10" y="106" width="554" height="16" as="geometry" />
|
| 39 |
+
</mxCell>
|
| 40 |
+
|
| 41 |
+
<!-- Landing Page -->
|
| 42 |
+
<mxCell id="LP" value="landing.html

Login Β· Register
Patient & Hospital" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e6f3ff;strokeColor=#3385ff;fontSize=10;fontStyle=1;" vertex="1" parent="L1">
|
| 43 |
+
<mxGeometry x="651" y="70" width="158" height="95" as="geometry" />
|
| 44 |
+
</mxCell>
|
| 45 |
+
|
| 46 |
+
<!-- Hospital Portal -->
|
| 47 |
+
<mxCell id="HPG" value="Hospital Portal β hospital.html" style="swimlane;startSize=24;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=11;fontStyle=1;" vertex="1" parent="L1">
|
| 48 |
+
<mxGeometry x="866" y="37" width="804" height="183" as="geometry" />
|
| 49 |
+
</mxCell>
|
| 50 |
+
<mxCell id="HP1" value="Available
Patients
opt-in only" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 51 |
+
<mxGeometry x="10" y="30" width="113" height="65" as="geometry" />
|
| 52 |
+
</mxCell>
|
| 53 |
+
<mxCell id="HP2" value="Search by
Condition
incl. connected" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 54 |
+
<mxGeometry x="133" y="30" width="113" height="65" as="geometry" />
|
| 55 |
+
</mxCell>
|
| 56 |
+
<mxCell id="HP3" value="My Trials
3-tier active
match" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 57 |
+
<mxGeometry x="256" y="30" width="113" height="65" as="geometry" />
|
| 58 |
+
</mxCell>
|
| 59 |
+
<mxCell id="HP4" value="Inbox π΄
unread badge
message threads" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 60 |
+
<mxGeometry x="379" y="30" width="113" height="65" as="geometry" />
|
| 61 |
+
</mxCell>
|
| 62 |
+
<mxCell id="HP5" value="My
Connections
accept / reject" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 63 |
+
<mxGeometry x="502" y="30" width="113" height="65" as="geometry" />
|
| 64 |
+
</mxCell>
|
| 65 |
+
<mxCell id="HP6" value="My Profile
name Β· location
conditions" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="HPG">
|
| 66 |
+
<mxGeometry x="625" y="30" width="113" height="65" as="geometry" />
|
| 67 |
+
</mxCell>
|
| 68 |
+
<mxCell id="HP_NOTE" value="Bootstrap 5.3 dark SPA Β· escH() / escA() XSS guards Β· navbar name updates live on profile save Β· Search includes already-connected toggle" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#555;align=left;" vertex="1" parent="HPG">
|
| 69 |
+
<mxGeometry x="10" y="106" width="784" height="16" as="geometry" />
|
| 70 |
+
</mxCell>
|
| 71 |
+
|
| 72 |
+
<!-- ============================================================
|
| 73 |
+
LAYER 2 β FLASK SERVER
|
| 74 |
+
============================================================ -->
|
| 75 |
+
<mxCell id="L2" value="βοΈ Flask Server β app.py (port 5000) Β· TEMPLATES_AUTO_RELOAD=True Β· MAX_CONTENT_LENGTH=10 MB Β· server-side cookie sessions" style="swimlane;startSize=32;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=13;fontStyle=1;fontColor=#3d0066;" vertex="1" parent="1">
|
| 76 |
+
<mxGeometry x="10" y="255" width="1680" height="280" as="geometry" />
|
| 77 |
+
</mxCell>
|
| 78 |
+
|
| 79 |
+
<mxCell id="AR" value="π Auth Routes
(no session required)

POST /auth/patient/register
POST /auth/patient/login
POST /auth/hospital/register
POST /auth/hospital/login
POST /auth/logout" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#9673a6;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 80 |
+
<mxGeometry x="20" y="40" width="215" height="220" as="geometry" />
|
| 81 |
+
</mxCell>
|
| 82 |
+
|
| 83 |
+
<mxCell id="PAR" value="π€ Patient API
(patient session)

GET | POST /api/patient/profile
GET /api/patient/matches
GET | POST | DELETE
 /api/patient/interest
GET /api/patient/connections
POST /api/patient/connect
GET /api/patient/hospitals-for-trial" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#cce5ff;strokeColor=#0066cc;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 84 |
+
<mxGeometry x="248" y="40" width="250" height="220" as="geometry" />
|
| 85 |
+
</mxCell>
|
| 86 |
+
|
| 87 |
+
<mxCell id="HAR" value="π₯ Hospital API
(hospital session)

GET | POST /api/hospital/profile
GET /api/hospital/patients
GET /api/hospital/trials
POST /api/hospital/connect
GET /api/hospital/connections
PUT /api/hospital/connections/
 <cid>/status" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 88 |
+
<mxGeometry x="511" y="40" width="265" height="220" as="geometry" />
|
| 89 |
+
</mxCell>
|
| 90 |
+
|
| 91 |
+
<mxCell id="MR" value="π¬ Messaging + Inbox

GET | POST
 /api/patient/connections/<cid>/messages
GET /api/patient/inbox

GET | POST
 /api/hospital/connections/<cid>/messages
GET /api/hospital/inbox

β mark_messages_read() on GET
β get_*_inbox_threads() + unread_count" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 92 |
+
<mxGeometry x="789" y="40" width="280" height="220" as="geometry" />
|
| 93 |
+
</mxCell>
|
| 94 |
+
|
| 95 |
+
<mxCell id="DR" value="π Documents

GET /api/patient/documents
POST /api/patient/documents
 β multipart/form-data
 β secure_filename()
 β ALLOWED: .pdf .docx .doc
 .txt .png .jpg .jpeg
 β max 10 MB
DELETE /api/patient/documents/<id>
GET /api/patient/documents/<id>/download" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 96 |
+
<mxGeometry x="1082" y="40" width="255" height="220" as="geometry" />
|
| 97 |
+
</mxCell>
|
| 98 |
+
|
| 99 |
+
<mxCell id="SR" value="π§ Shared + Boot Thread

GET /api/status
 β ready: true|false + stats
GET /api/conditions/autocomplete

βββββββββββββββββββββ
π Boot Thread (background)
β db.init_db()
 β create 5 tables
 β seed 25 patients + 3 hospitals
β‘ pipeline.load()
 β read 11 CSV files into memory
β’ pipeline.train()
 β build RF or load model_cache.pkl" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L2">
|
| 100 |
+
<mxGeometry x="1350" y="40" width="310" height="220" as="geometry" />
|
| 101 |
+
</mxCell>
|
| 102 |
+
|
| 103 |
+
<!-- ============================================================
|
| 104 |
+
LAYER 3 β BUSINESS LOGIC
|
| 105 |
+
============================================================ -->
|
| 106 |
+
<mxCell id="L3" value="π§ Business Logic" style="swimlane;startSize=32;fillColor=#fff8e1;strokeColor=#d6a000;fontSize=14;fontStyle=1;fontColor=#5a3e00;" vertex="1" parent="1">
|
| 107 |
+
<mxGeometry x="10" y="545" width="1680" height="300" as="geometry" />
|
| 108 |
+
</mxCell>
|
| 109 |
+
|
| 110 |
+
<!-- database.py -->
|
| 111 |
+
<mxCell id="DBG" value="database.py β SQLite Layer" style="swimlane;startSize=24;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=11;fontStyle=1;" vertex="1" parent="L3">
|
| 112 |
+
<mxGeometry x="20" y="38" width="705" height="252" as="geometry" />
|
| 113 |
+
</mxCell>
|
| 114 |
+
<mxCell id="T1" value="patient_accounts
25 rows
5 demo + 20 Synthea" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=12;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="DBG">
|
| 115 |
+
<mxGeometry x="10" y="32" width="122" height="68" as="geometry" />
|
| 116 |
+
</mxCell>
|
| 117 |
+
<mxCell id="T2" value="hospital_accounts
3 rows
mgh, cleveland, jhopkins" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=12;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="DBG">
|
| 118 |
+
<mxGeometry x="144" y="32" width="122" height="68" as="geometry" />
|
| 119 |
+
</mxCell>
|
| 120 |
+
<mxCell id="T3" value="patient_trial_interests
UNIQUE(patient_id,
trial_id)" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=12;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="DBG">
|
| 121 |
+
<mxGeometry x="278" y="32" width="122" height="68" as="geometry" />
|
| 122 |
+
</mxCell>
|
| 123 |
+
<mxCell id="T4" value="connections
UNIQUE(patient, hospital,
COALESCE(trial,''))" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=12;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="DBG">
|
| 124 |
+
<mxGeometry x="412" y="32" width="132" height="68" as="geometry" />
|
| 125 |
+
</mxCell>
|
| 126 |
+
<mxCell id="T5" value="connection_messages
sender_role: patient|hospital
is_read: 0 | 1" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=12;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontSize=9;" vertex="1" parent="DBG">
|
| 127 |
+
<mxGeometry x="556" y="32" width="132" height="68" as="geometry" />
|
| 128 |
+
</mxCell>
|
| 129 |
+
<mxCell id="DBF1" value="Key functions: authenticate_patient / authenticate_hospital Β· update_patient_profile / update_hospital_profile Β· get_open_patients_for_hospital(condition, include_connected) Β· create_connection / connection_exists Β· update_connection_status Β· create_connection_message Β· mark_messages_read Β· get_hospital_inbox_threads / get_patient_inbox_threads Β· _seed_dataset_patients(20)" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#333;align=left;" vertex="1" parent="DBG">
|
| 130 |
+
<mxGeometry x="10" y="112" width="685" height="30" as="geometry" />
|
| 131 |
+
</mxCell>
|
| 132 |
+
<mxCell id="DBF2" value="JSON columns (conditions, medications, documents, research_conditions) stored as TEXT Β· parsed by _row_to_dict() Β· passwords: SHA-256 via _hash() Β· idx_conn_unique handles NULL trial_id Β· idx_msgs_conn on (connection_id, created_at)" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#555;align=left;" vertex="1" parent="DBG">
|
| 133 |
+
<mxGeometry x="10" y="148" width="685" height="30" as="geometry" />
|
| 134 |
+
</mxCell>
|
| 135 |
+
|
| 136 |
+
<!-- pipeline.py -->
|
| 137 |
+
<mxCell id="PLG" value="pipeline.py β SecondLifePipeline (ML Engine)" style="swimlane;startSize=24;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=11;fontStyle=1;" vertex="1" parent="L3">
|
| 138 |
+
<mxGeometry x="745" y="38" width="925" height="252" as="geometry" />
|
| 139 |
+
</mxCell>
|
| 140 |
+
<mxCell id="PL1" value="load()
Reads 11 CSV files β memory
trial_profiles Β· active_trial_ids
trial_us_states Β· facility_tokens
drug_keywords Β· summaries
patient_conditions Β· meds Β· labs
~2β3 min on first boot" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;" vertex="1" parent="PLG">
|
| 141 |
+
<mxGeometry x="10" y="32" width="165" height="115" as="geometry" />
|
| 142 |
+
</mxCell>
|
| 143 |
+
<mxCell id="PL2" value="train()
3000 patients Γ 30 trials
_compute_features() 17 feats
pseudo-label: 6-feat weighted score
GroupShuffleSplit 80/20 (patient-level)
RandomForest n=200, depth=12
CalibratedClassifierCV isotonic cv=3
β save model_cache.pkl" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;" vertex="1" parent="PLG">
|
| 144 |
+
<mxGeometry x="188" y="32" width="180" height="115" as="geometry" />
|
| 145 |
+
</mxCell>
|
| 146 |
+
<mxCell id="PL3" value="match_patient()
Input: conditions, age,
 gender, patient_id
Scores all active trials
combined = 0.6 Γ RF_prob
 + 0.4 Γ match_score
Returns top-20 ranked trials" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;" vertex="1" parent="PLG">
|
| 147 |
+
<mxGeometry x="382" y="32" width="170" height="115" as="geometry" />
|
| 148 |
+
</mxCell>
|
| 149 |
+
<mxCell id="PL4" value="trials_for_hospital()
Input: name, location,
 research_conditions
Tier1: facility Jaccard β₯ 0.25
Tier2: same US state
Tier3: condition overlap
Active-only filter every tier" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;" vertex="1" parent="PLG">
|
| 150 |
+
<mxGeometry x="566" y="32" width="165" height="115" as="geometry" />
|
| 151 |
+
</mxCell>
|
| 152 |
+
<mxCell id="PL5" value="hospitals_for_trial()
Input: trial_id
Tier1: Jaccard β₯ 0.25 β π’
Tier2: same state β β¬
Tier3: cond overlap β β¬
Tier4: fallback β β¬
Modal: green vs grey sections" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;" vertex="1" parent="PLG">
|
| 153 |
+
<mxGeometry x="745" y="32" width="165" height="115" as="geometry" />
|
| 154 |
+
</mxCell>
|
| 155 |
+
<mxCell id="PLM" value="17 Features: condition_overlap Β· jaccard_similarity Β· overlap_ratio_trial Β· overlap_ratio_patient Β· condition_rarity_score Β· trial_specificity Β· condition_burden Β· active_ratio Β· resolved_ratio Β· age_distance Β· age_centered Β· age_compatibility Β· gender_compatibility Β· geo_feasibility Β· med_compatibility Β· lab_availability Β· data_completeness" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#333;align=left;" vertex="1" parent="PLG">
|
| 156 |
+
<mxGeometry x="10" y="158" width="905" height="20" as="geometry" />
|
| 157 |
+
</mxCell>
|
| 158 |
+
<mxCell id="PLM2" value="AUC-ROC: 0.60 Β· Accuracy: 85.1% Β· F1: 0.917 Β· Precision: 0.852 Β· Recall: 0.993 Β· 90,994 training pairs Β· 82.1% pseudo-label positive rate Β· GroupShuffleSplit prevents patient-level data leakage" style="text;html=1;strokeColor=none;fillColor=none;fontSize=8;fontColor=#555;align=left;" vertex="1" parent="PLG">
|
| 159 |
+
<mxGeometry x="10" y="182" width="905" height="20" as="geometry" />
|
| 160 |
+
</mxCell>
|
| 161 |
+
|
| 162 |
+
<!-- ============================================================
|
| 163 |
+
LAYER 4 β STORAGE
|
| 164 |
+
============================================================ -->
|
| 165 |
+
<mxCell id="L4" value="πΎ Storage" style="swimlane;startSize=32;fillColor=#f5f5f5;strokeColor=#666666;fontSize=14;fontStyle=1;fontColor=#333;" vertex="1" parent="1">
|
| 166 |
+
<mxGeometry x="10" y="855" width="1680" height="215" as="geometry" />
|
| 167 |
+
</mxCell>
|
| 168 |
+
|
| 169 |
+
<mxCell id="S1" value="secondlife.db
SQLite file
5 tables Β· auto-created
on init_db()" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=14;fillColor=#d5e8d4;strokeColor=#5b9e5b;fontStyle=1;fontSize=10;" vertex="1" parent="L4">
|
| 170 |
+
<mxGeometry x="75" y="45" width="155" height="120" as="geometry" />
|
| 171 |
+
</mxCell>
|
| 172 |
+
|
| 173 |
+
<mxCell id="S2" value="model_cache.pkl
RandomForest +
CalibratedClassifierCV
delete to force retrain" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=14;fillColor=#ffe6cc;strokeColor=#d79b00;fontStyle=1;fontSize=10;" vertex="1" parent="L4">
|
| 174 |
+
<mxGeometry x="325" y="45" width="155" height="120" as="geometry" />
|
| 175 |
+
</mxCell>
|
| 176 |
+
|
| 177 |
+
<mxCell id="S3" value="uploads/patient_docs/
<patient_id>/
<doc_id>_filename
10 MB max Β· auto-created" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=14;fillColor=#cce5ff;strokeColor=#0066cc;fontStyle=1;fontSize=10;" vertex="1" parent="L4">
|
| 178 |
+
<mxGeometry x="575" y="45" width="155" height="120" as="geometry" />
|
| 179 |
+
</mxCell>
|
| 180 |
+
|
| 181 |
+
<mxCell id="S4" value="Synthea Patient CSVs

patients_details.csv
 265,893 patients Β· demographics
final_patients_conditions.csv
 967k rows Β· 106 conditions
patients_medications.csv
 213,182 patients
patients_observations.csv
 23,231 patients with lab data" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e8;strokeColor=#5b9e5b;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L4">
|
| 182 |
+
<mxGeometry x="855" y="38" width="275" height="158" as="geometry" />
|
| 183 |
+
</mxCell>
|
| 184 |
+
|
| 185 |
+
<mxCell id="S5" value="ClinicalTrials.gov / AACT CSVs

trail_studies.csv β 65k recruiting trials
trail_conditions.csv β 34k with conditions
trail_eligibilities.csv β age Β· gender
trail_facilities.csv β 189k with US geo
trail_interventions.csv β 196k with drugs
trail_brief_summaries.csv β summaries
trail_keywords.csv β keyword index" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#d79b00;fontSize=9;align=left;spacingLeft=8;verticalAlign=top;spacingTop=8;" vertex="1" parent="L4">
|
| 186 |
+
<mxGeometry x="1230" y="38" width="310" height="158" as="geometry" />
|
| 187 |
+
</mxCell>
|
| 188 |
+
|
| 189 |
+
<!-- ============================================================
|
| 190 |
+
LEGEND
|
| 191 |
+
============================================================ -->
|
| 192 |
+
<mxCell id="LEG" value="Legend" style="swimlane;startSize=22;fillColor=#ffffff;strokeColor=#999999;fontSize=10;fontStyle=1;" vertex="1" parent="1">
|
| 193 |
+
<mxGeometry x="10" y="1085" width="1680" height="0" as="geometry" />
|
| 194 |
+
</mxCell>
|
| 195 |
+
|
| 196 |
+
<!-- ============================================================
|
| 197 |
+
ARROWS β DATA FLOW
|
| 198 |
+
============================================================ -->
|
| 199 |
+
|
| 200 |
+
<!-- Patient Portal β Patient API (REST/JSON) -->
|
| 201 |
+
<mxCell id="E1" value="REST/JSON" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#0066cc;strokeWidth=2;fontStyle=2;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PPG" target="PAR" parent="1">
|
| 202 |
+
<mxGeometry relative="1" as="geometry" />
|
| 203 |
+
</mxCell>
|
| 204 |
+
|
| 205 |
+
<!-- Patient Portal β Messaging/Inbox (dashed) -->
|
| 206 |
+
<mxCell id="E2" value="chat / inbox" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d6b656;strokeWidth=1.5;dashed=1;fontStyle=2;fontSize=8;" edge="1" source="PP4" target="MR" parent="1">
|
| 207 |
+
<mxGeometry relative="1" as="geometry" />
|
| 208 |
+
</mxCell>
|
| 209 |
+
|
| 210 |
+
<!-- Patient Portal β Documents (dashed) -->
|
| 211 |
+
<mxCell id="E3" value="upload / download" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=1.5;dashed=1;fontStyle=2;fontSize=8;" edge="1" source="PP1" target="DR" parent="1">
|
| 212 |
+
<mxGeometry relative="1" as="geometry" />
|
| 213 |
+
</mxCell>
|
| 214 |
+
|
| 215 |
+
<!-- Landing β Auth -->
|
| 216 |
+
<mxCell id="E4" value="login / register" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#9673a6;strokeWidth=2;fontStyle=2;fontSize=8;" edge="1" source="LP" target="AR" parent="1">
|
| 217 |
+
<mxGeometry relative="1" as="geometry" />
|
| 218 |
+
</mxCell>
|
| 219 |
+
|
| 220 |
+
<!-- Hospital Portal β Hospital API -->
|
| 221 |
+
<mxCell id="E5" value="REST/JSON" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#5b9e5b;strokeWidth=2;fontStyle=2;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="HPG" target="HAR" parent="1">
|
| 222 |
+
<mxGeometry relative="1" as="geometry" />
|
| 223 |
+
</mxCell>
|
| 224 |
+
|
| 225 |
+
<!-- Hospital Portal β Messaging (dashed) -->
|
| 226 |
+
<mxCell id="E6" value="chat / inbox" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d6b656;strokeWidth=1.5;dashed=1;fontStyle=2;fontSize=8;" edge="1" source="HP4" target="MR" parent="1">
|
| 227 |
+
<mxGeometry relative="1" as="geometry" />
|
| 228 |
+
</mxCell>
|
| 229 |
+
|
| 230 |
+
<!-- Auth β DB -->
|
| 231 |
+
<mxCell id="E7" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#9673a6;strokeWidth=1.5;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.1;entryY=0;entryDx=0;entryDy=0;" edge="1" source="AR" target="DBG" parent="1">
|
| 232 |
+
<mxGeometry relative="1" as="geometry" />
|
| 233 |
+
</mxCell>
|
| 234 |
+
|
| 235 |
+
<!-- Patient API β DB -->
|
| 236 |
+
<mxCell id="E8" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#0066cc;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.3;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PAR" target="DBG" parent="1">
|
| 237 |
+
<mxGeometry relative="1" as="geometry" />
|
| 238 |
+
</mxCell>
|
| 239 |
+
|
| 240 |
+
<!-- Patient API β match_patient() -->
|
| 241 |
+
<mxCell id="E9" value="match_patient()" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=2;fontStyle=3;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PAR" target="PL3" parent="1">
|
| 242 |
+
<mxGeometry relative="1" as="geometry" />
|
| 243 |
+
</mxCell>
|
| 244 |
+
|
| 245 |
+
<!-- Patient API β hospitals_for_trial() -->
|
| 246 |
+
<mxCell id="E10" value="hospitals_for_trial()" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PAR" target="PL5" parent="1">
|
| 247 |
+
<mxGeometry relative="1" as="geometry" />
|
| 248 |
+
</mxCell>
|
| 249 |
+
|
| 250 |
+
<!-- Hospital API β DB -->
|
| 251 |
+
<mxCell id="E11" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#5b9e5b;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="HAR" target="DBG" parent="1">
|
| 252 |
+
<mxGeometry relative="1" as="geometry" />
|
| 253 |
+
</mxCell>
|
| 254 |
+
|
| 255 |
+
<!-- Hospital API β trials_for_hospital() -->
|
| 256 |
+
<mxCell id="E12" value="trials_for_hospital()" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=2;fontStyle=3;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="HAR" target="PL4" parent="1">
|
| 257 |
+
<mxGeometry relative="1" as="geometry" />
|
| 258 |
+
</mxCell>
|
| 259 |
+
|
| 260 |
+
<!-- Messaging β DB (connections + messages tables) -->
|
| 261 |
+
<mxCell id="E13" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d6b656;strokeWidth=1.5;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=0;entryDx=0;entryDy=0;" edge="1" source="MR" target="DBG" parent="1">
|
| 262 |
+
<mxGeometry relative="1" as="geometry" />
|
| 263 |
+
</mxCell>
|
| 264 |
+
|
| 265 |
+
<!-- Documents β DB (updates patient documents JSON) -->
|
| 266 |
+
<mxCell id="E14" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=1.5;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.88;entryY=0;entryDx=0;entryDy=0;" edge="1" source="DR" target="DBG" parent="1">
|
| 267 |
+
<mxGeometry relative="1" as="geometry" />
|
| 268 |
+
</mxCell>
|
| 269 |
+
|
| 270 |
+
<!-- Documents β File Storage -->
|
| 271 |
+
<mxCell id="E15" value="file I/O" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;" edge="1" source="DR" target="S3" parent="1">
|
| 272 |
+
<mxGeometry relative="1" as="geometry" />
|
| 273 |
+
</mxCell>
|
| 274 |
+
|
| 275 |
+
<!-- Boot Thread β DB (seed data) -->
|
| 276 |
+
<mxCell id="E16" value="β seed" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#666;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0;entryDx=0;entryDy=0;" edge="1" source="SR" target="DBG" parent="1">
|
| 277 |
+
<mxGeometry relative="1" as="geometry" />
|
| 278 |
+
</mxCell>
|
| 279 |
+
|
| 280 |
+
<!-- Boot Thread β Pipeline (load + train) -->
|
| 281 |
+
<mxCell id="E17" value="β‘ load β’ train" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#666;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0;entryDx=0;entryDy=0;" edge="1" source="SR" target="PLG" parent="1">
|
| 282 |
+
<mxGeometry relative="1" as="geometry" />
|
| 283 |
+
</mxCell>
|
| 284 |
+
|
| 285 |
+
<!-- DB β secondlife.db file -->
|
| 286 |
+
<mxCell id="E18" value="read / write" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#5b9e5b;strokeWidth=2;fontStyle=2;fontSize=8;" edge="1" source="DBG" target="S1" parent="1">
|
| 287 |
+
<mxGeometry relative="1" as="geometry" />
|
| 288 |
+
</mxCell>
|
| 289 |
+
|
| 290 |
+
<!-- Pipeline β model_cache.pkl -->
|
| 291 |
+
<mxCell id="E19" value="load / save" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=2;fontStyle=2;fontSize=8;" edge="1" source="PLG" target="S2" parent="1">
|
| 292 |
+
<mxGeometry relative="1" as="geometry" />
|
| 293 |
+
</mxCell>
|
| 294 |
+
|
| 295 |
+
<!-- pipeline.load() β Synthea CSVs -->
|
| 296 |
+
<mxCell id="E20" value="read on boot" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#5b9e5b;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;exitX=0.2;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PL1" target="S4" parent="1">
|
| 297 |
+
<mxGeometry relative="1" as="geometry" />
|
| 298 |
+
</mxCell>
|
| 299 |
+
|
| 300 |
+
<!-- pipeline.load() β Trial CSVs -->
|
| 301 |
+
<mxCell id="E21" value="read on boot" style="edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;strokeColor=#d79b00;strokeWidth=1.5;dashed=1;fontStyle=3;fontSize=8;exitX=0.8;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="PL1" target="S5" parent="1">
|
| 302 |
+
<mxGeometry relative="1" as="geometry" />
|
| 303 |
+
</mxCell>
|
| 304 |
+
|
| 305 |
+
</root>
|
| 306 |
+
</mxGraphModel>
|
Architecture Diagrams/secondlife workflow architecture diagrams.html
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Second Life β Architecture Diagrams</title>
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
| 8 |
+
<style>
|
| 9 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
|
| 11 |
+
body {
|
| 12 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
| 13 |
+
background: #0d1117;
|
| 14 |
+
color: #c9d1d9;
|
| 15 |
+
padding: 2rem;
|
| 16 |
+
max-width: 1600px;
|
| 17 |
+
margin: 0 auto;
|
| 18 |
+
line-height: 1.6;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
header {
|
| 22 |
+
border-bottom: 1px solid #30363d;
|
| 23 |
+
padding-bottom: 1.5rem;
|
| 24 |
+
margin-bottom: 2rem;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
header h1 {
|
| 28 |
+
font-size: 2rem;
|
| 29 |
+
color: #58a6ff;
|
| 30 |
+
margin-bottom: 0.25rem;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
header p { color: #8b949e; font-size: 0.95rem; }
|
| 34 |
+
|
| 35 |
+
.toc {
|
| 36 |
+
background: #161b22;
|
| 37 |
+
border: 1px solid #30363d;
|
| 38 |
+
border-radius: 8px;
|
| 39 |
+
padding: 1.25rem 1.5rem;
|
| 40 |
+
margin-bottom: 3rem;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.toc h3 { color: #79c0ff; font-size: 0.875rem; text-transform: uppercase;
|
| 44 |
+
letter-spacing: 0.05em; margin-bottom: 0.75rem; }
|
| 45 |
+
|
| 46 |
+
.toc ol { padding-left: 1.25rem; }
|
| 47 |
+
|
| 48 |
+
.toc li { margin-bottom: 0.3rem; }
|
| 49 |
+
|
| 50 |
+
.toc a { color: #58a6ff; text-decoration: none; font-size: 0.9rem; }
|
| 51 |
+
.toc a:hover { text-decoration: underline; }
|
| 52 |
+
|
| 53 |
+
section {
|
| 54 |
+
margin-bottom: 4rem;
|
| 55 |
+
scroll-margin-top: 2rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
section h2 {
|
| 59 |
+
font-size: 1.4rem;
|
| 60 |
+
color: #79c0ff;
|
| 61 |
+
margin-bottom: 0.4rem;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.subtitle {
|
| 65 |
+
color: #8b949e;
|
| 66 |
+
font-size: 0.875rem;
|
| 67 |
+
margin-bottom: 1.25rem;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.diagram-wrap {
|
| 71 |
+
background: #161b22;
|
| 72 |
+
border: 1px solid #30363d;
|
| 73 |
+
border-radius: 10px;
|
| 74 |
+
padding: 2rem;
|
| 75 |
+
overflow-x: auto;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.mermaid {
|
| 79 |
+
display: flex;
|
| 80 |
+
justify-content: center;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.legend {
|
| 84 |
+
margin-top: 1.25rem;
|
| 85 |
+
display: flex;
|
| 86 |
+
flex-wrap: wrap;
|
| 87 |
+
gap: 0.75rem;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.legend-item {
|
| 91 |
+
display: flex;
|
| 92 |
+
align-items: center;
|
| 93 |
+
gap: 0.4rem;
|
| 94 |
+
font-size: 0.8rem;
|
| 95 |
+
color: #8b949e;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.legend-dot {
|
| 99 |
+
width: 10px;
|
| 100 |
+
height: 10px;
|
| 101 |
+
border-radius: 50%;
|
| 102 |
+
flex-shrink: 0;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.note {
|
| 106 |
+
margin-top: 1rem;
|
| 107 |
+
padding: 0.75rem 1rem;
|
| 108 |
+
background: #1c2128;
|
| 109 |
+
border-left: 3px solid #388bfd;
|
| 110 |
+
border-radius: 0 6px 6px 0;
|
| 111 |
+
font-size: 0.85rem;
|
| 112 |
+
color: #8b949e;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.note strong { color: #c9d1d9; }
|
| 116 |
+
|
| 117 |
+
.grid-2 {
|
| 118 |
+
display: grid;
|
| 119 |
+
grid-template-columns: 1fr 1fr;
|
| 120 |
+
gap: 1.5rem;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@media (max-width: 900px) {
|
| 124 |
+
.grid-2 { grid-template-columns: 1fr; }
|
| 125 |
+
body { padding: 1rem; }
|
| 126 |
+
}
|
| 127 |
+
</style>
|
| 128 |
+
</head>
|
| 129 |
+
<body>
|
| 130 |
+
|
| 131 |
+
<header>
|
| 132 |
+
<h1>βοΈ Second Life β Architecture Diagrams</h1>
|
| 133 |
+
<p>DSCI 5260 Β· Group 7 Β· AI-powered Patient-to-Clinical-Trial Matching Platform Β· Flask + SQLite + Random Forest</p>
|
| 134 |
+
</header>
|
| 135 |
+
|
| 136 |
+
<div class="toc">
|
| 137 |
+
<h3>Contents</h3>
|
| 138 |
+
<ol>
|
| 139 |
+
<li><a href="#s1">System Architecture Overview</a></li>
|
| 140 |
+
<li><a href="#s2">Database Schema (ER Diagram)</a></li>
|
| 141 |
+
<li><a href="#s3">ML Pipeline β Boot, Train, Infer</a></li>
|
| 142 |
+
<li><a href="#s4">Patient User Journey (Sequence)</a></li>
|
| 143 |
+
<li><a href="#s5">Hospital User Journey (Sequence)</a></li>
|
| 144 |
+
<li><a href="#s6">Tiered Matching Logic</a></li>
|
| 145 |
+
<li><a href="#s7">Feature Engineering β 17 Features</a></li>
|
| 146 |
+
</ol>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- =========================================================
|
| 150 |
+
1. SYSTEM ARCHITECTURE OVERVIEW
|
| 151 |
+
========================================================= -->
|
| 152 |
+
<section id="s1">
|
| 153 |
+
<h2>1. System Architecture Overview</h2>
|
| 154 |
+
<p class="subtitle">
|
| 155 |
+
Four-layer architecture: Browser → Flask Server → Business Logic (DB + ML) → Storage.
|
| 156 |
+
The pipeline boots in a background thread so the server is immediately available.
|
| 157 |
+
</p>
|
| 158 |
+
<div class="diagram-wrap">
|
| 159 |
+
<div class="mermaid">
|
| 160 |
+
flowchart LR
|
| 161 |
+
subgraph BROWSER["π Browser (Client)"]
|
| 162 |
+
direction TB
|
| 163 |
+
LAND["<b>landing.html</b><br/>Login Β· Register<br/>(Patient & Hospital)"]
|
| 164 |
+
subgraph PAT_SPA["Patient Portal β patient.html"]
|
| 165 |
+
direction TB
|
| 166 |
+
PA["My Profile<br/><small>conditions Β· meds Β· docs upload</small>"]
|
| 167 |
+
PB["Suggested Trials<br/><small>AI-ranked top-20</small>"]
|
| 168 |
+
PC["My Connections<br/><small>status Β· chat</small>"]
|
| 169 |
+
PD["Inbox π΄<br/><small>unread count badge</small>"]
|
| 170 |
+
end
|
| 171 |
+
subgraph HOSP_SPA["Hospital Portal β hospital.html"]
|
| 172 |
+
direction TB
|
| 173 |
+
HA["Available Patients<br/><small>opt-in only, excl. connected</small>"]
|
| 174 |
+
HB["Search by Condition<br/><small>incl. connected toggle</small>"]
|
| 175 |
+
HC["My Trials<br/><small>active trials matched to hospital</small>"]
|
| 176 |
+
HD["Inbox π΄<br/><small>unread count badge</small>"]
|
| 177 |
+
HE["My Connections<br/><small>accept Β· reject Β· complete Β· chat</small>"]
|
| 178 |
+
HF["My Profile<br/><small>name Β· location Β· conditions</small>"]
|
| 179 |
+
end
|
| 180 |
+
end
|
| 181 |
+
|
| 182 |
+
subgraph SERVER["βοΈ Flask Server β app.py (:5000)"]
|
| 183 |
+
direction TB
|
| 184 |
+
subgraph AUTH_GRP["Auth (no session required)"]
|
| 185 |
+
AU["/auth/patient/register<br/>/auth/patient/login<br/>/auth/hospital/register<br/>/auth/hospital/login<br/>/auth/logout"]
|
| 186 |
+
end
|
| 187 |
+
subgraph PAT_GRP["Patient API"]
|
| 188 |
+
PAR["/api/patient/profile GET Β· POST<br/>/api/patient/matches GET<br/>/api/patient/interests GET Β· POST Β· DELETE<br/>/api/patient/connections GET<br/>/api/patient/connect POST<br/>/api/patient/hospitals-for-trial GET"]
|
| 189 |
+
end
|
| 190 |
+
subgraph MSG_GRP["Messaging + Inbox"]
|
| 191 |
+
MR["/api/patient/connections/<cid>/messages GET Β· POST<br/>/api/patient/inbox GET<br/>/api/hospital/connections/<cid>/messages GET Β· POST<br/>/api/hospital/inbox GET"]
|
| 192 |
+
end
|
| 193 |
+
subgraph HOSP_GRP["Hospital API"]
|
| 194 |
+
HAR["/api/hospital/profile GET Β· POST<br/>/api/hospital/patients GET<br/>/api/hospital/trials GET<br/>/api/hospital/connect POST<br/>/api/hospital/connections GET<br/>/api/hospital/connections/<cid>/status PUT"]
|
| 195 |
+
end
|
| 196 |
+
subgraph DOC_GRP["Documents"]
|
| 197 |
+
DR["/api/patient/documents GET Β· POST<br/>/api/patient/documents/<id> DELETE<br/>/api/patient/documents/<id>/download GET"]
|
| 198 |
+
end
|
| 199 |
+
subgraph SHARED_GRP["Shared"]
|
| 200 |
+
SR["/api/status GET<br/>/api/conditions/autocomplete GET"]
|
| 201 |
+
end
|
| 202 |
+
BOOT["<b>Boot Thread</b><br/>init_db()<br/>pipeline.load()<br/>pipeline.train()"]
|
| 203 |
+
end
|
| 204 |
+
|
| 205 |
+
subgraph LOGIC["π§ Business Logic"]
|
| 206 |
+
direction TB
|
| 207 |
+
subgraph DB_LAYER["database.py β SQLite Layer"]
|
| 208 |
+
T1[("patient_accounts<br/><small>25 rows</small>")]
|
| 209 |
+
T2[("hospital_accounts<br/><small>3 rows</small>")]
|
| 210 |
+
T3[("patient_trial_interests")]
|
| 211 |
+
T4[("connections")]
|
| 212 |
+
T5[("connection_messages")]
|
| 213 |
+
end
|
| 214 |
+
subgraph ML_LAYER["pipeline.py β ML Engine"]
|
| 215 |
+
ML1["Data Loader<br/><small>CSV β in-memory dicts</small>"]
|
| 216 |
+
ML2["Feature Engineer<br/><small>17 features per patient-trial pair</small>"]
|
| 217 |
+
ML3["RF + Isotonic Cal.<br/><small>90k pairs Β· AUC 0.60</small>"]
|
| 218 |
+
ML4["match_patient()"]
|
| 219 |
+
ML5["trials_for_hospital()"]
|
| 220 |
+
ML6["hospitals_for_trial()"]
|
| 221 |
+
end
|
| 222 |
+
end
|
| 223 |
+
|
| 224 |
+
subgraph STORAGE["πΎ Storage"]
|
| 225 |
+
direction TB
|
| 226 |
+
SDB[("secondlife.db<br/><small>SQLite file</small>")]
|
| 227 |
+
SCACHE[("model_cache.pkl<br/><small>RF + calibrator</small>")]
|
| 228 |
+
SFILES["uploads/patient_docs/<br/><small>per-patient dirs</small>"]
|
| 229 |
+
SCSV_P["Synthea CSVs<br/><small>4 files Β· ~1M rows</small>"]
|
| 230 |
+
SCSV_T["ClinicalTrials CSVs<br/><small>7 files Β· ~2M rows</small>"]
|
| 231 |
+
end
|
| 232 |
+
|
| 233 |
+
LAND -->|"POST /auth/*"| AU
|
| 234 |
+
PAT_SPA -->|"REST/JSON"| PAR
|
| 235 |
+
PAT_SPA -->|"REST/JSON"| MSG_GRP
|
| 236 |
+
PAT_SPA -->|"multipart"| DOC_GRP
|
| 237 |
+
HOSP_SPA -->|"REST/JSON"| HAR
|
| 238 |
+
HOSP_SPA -->|"REST/JSON"| MSG_GRP
|
| 239 |
+
PAT_SPA & HOSP_SPA -->|"GET"| SHARED_GRP
|
| 240 |
+
|
| 241 |
+
AU & PAR & HAR --> DB_LAYER
|
| 242 |
+
MSG_GRP --> T4 & T5
|
| 243 |
+
DOC_GRP --> T1
|
| 244 |
+
DOC_GRP -->|"file I/O"| SFILES
|
| 245 |
+
|
| 246 |
+
PAR -->|"match_patient()"| ML4
|
| 247 |
+
PAR -->|"hospitals_for_trial()"| ML6
|
| 248 |
+
HAR -->|"trials_for_hospital()"| ML5
|
| 249 |
+
|
| 250 |
+
BOOT -->|"seeds data"| T1
|
| 251 |
+
BOOT -->|"load + train"| ML_LAYER
|
| 252 |
+
|
| 253 |
+
DB_LAYER <-->|"read/write"| SDB
|
| 254 |
+
ML3 <-->|"load/save"| SCACHE
|
| 255 |
+
ML1 -->|"read on boot"| SCSV_P
|
| 256 |
+
ML1 -->|"read on boot"| SCSV_T
|
| 257 |
+
|
| 258 |
+
ML1 --> ML2 --> ML3 --> ML4 & ML5 & ML6
|
| 259 |
+
</div>
|
| 260 |
+
<div class="note">
|
| 261 |
+
<strong>Boot sequence:</strong> On startup a background thread calls <code>init_db()</code> (seeds 25 patients + 3 hospitals),
|
| 262 |
+
then <code>pipeline.load()</code> (reads all CSVs into memory), then <code>pipeline.train()</code>
|
| 263 |
+
(builds RF model or loads <code>model_cache.pkl</code>). The Flask server is immediately available during boot;
|
| 264 |
+
<code>GET /api/status</code> returns <code>ready: false</code> until the thread completes.
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</section>
|
| 268 |
+
|
| 269 |
+
<!-- =========================================================
|
| 270 |
+
2. DATABASE SCHEMA
|
| 271 |
+
========================================================= -->
|
| 272 |
+
<section id="s2">
|
| 273 |
+
<h2>2. Database Schema</h2>
|
| 274 |
+
<p class="subtitle">
|
| 275 |
+
SQLite database (<code>secondlife.db</code>) with 5 tables.
|
| 276 |
+
JSON columns (conditions, medications, documents, research_conditions) are stored as TEXT and parsed by
|
| 277 |
+
<code>_row_to_dict()</code> in database.py.
|
| 278 |
+
</p>
|
| 279 |
+
<div class="diagram-wrap">
|
| 280 |
+
<div class="mermaid">
|
| 281 |
+
erDiagram
|
| 282 |
+
patient_accounts {
|
| 283 |
+
TEXT id PK
|
| 284 |
+
TEXT username UK
|
| 285 |
+
TEXT password_hash
|
| 286 |
+
TEXT synthea_id "null for hand-made patients"
|
| 287 |
+
TEXT first_name
|
| 288 |
+
TEXT last_name
|
| 289 |
+
TEXT dob
|
| 290 |
+
TEXT gender
|
| 291 |
+
TEXT address
|
| 292 |
+
TEXT conditions "JSON array of strings"
|
| 293 |
+
TEXT medications "JSON array of strings"
|
| 294 |
+
TEXT documents "JSON array of file metadata dicts"
|
| 295 |
+
INTEGER open_to_trials "0 or 1"
|
| 296 |
+
TEXT created_at
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
hospital_accounts {
|
| 300 |
+
TEXT id PK
|
| 301 |
+
TEXT username UK
|
| 302 |
+
TEXT password_hash
|
| 303 |
+
TEXT hospital_name
|
| 304 |
+
TEXT location "City, ST format"
|
| 305 |
+
TEXT research_conditions "JSON array of strings"
|
| 306 |
+
TEXT created_at
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
patient_trial_interests {
|
| 310 |
+
TEXT id PK
|
| 311 |
+
TEXT patient_id FK
|
| 312 |
+
TEXT trial_id "NCT number"
|
| 313 |
+
TEXT trial_title
|
| 314 |
+
REAL match_score
|
| 315 |
+
TEXT status "interested or withdrawn"
|
| 316 |
+
TEXT created_at
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
connections {
|
| 320 |
+
TEXT id PK
|
| 321 |
+
TEXT patient_id FK
|
| 322 |
+
TEXT hospital_id FK
|
| 323 |
+
TEXT trial_id "nullable NCT number"
|
| 324 |
+
TEXT trial_title
|
| 325 |
+
TEXT initiated_by "patient or hospital"
|
| 326 |
+
TEXT status "pending accepted rejected completed"
|
| 327 |
+
TEXT message
|
| 328 |
+
TEXT created_at
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
connection_messages {
|
| 332 |
+
TEXT id PK
|
| 333 |
+
TEXT connection_id FK
|
| 334 |
+
TEXT sender_role "patient or hospital"
|
| 335 |
+
TEXT sender_id
|
| 336 |
+
TEXT body "max 2000 chars"
|
| 337 |
+
TEXT created_at
|
| 338 |
+
INTEGER is_read "0 or 1"
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
patient_accounts ||--o{ patient_trial_interests : "saves interests"
|
| 342 |
+
patient_accounts ||--o{ connections : "initiates or receives"
|
| 343 |
+
hospital_accounts ||--o{ connections : "initiates or receives"
|
| 344 |
+
connections ||--o{ connection_messages : "contains messages"
|
| 345 |
+
</div>
|
| 346 |
+
<div class="note">
|
| 347 |
+
<strong>Uniqueness:</strong> <code>connections</code> has a composite unique index
|
| 348 |
+
<code>idx_conn_unique ON connections(patient_id, hospital_id, COALESCE(trial_id, ''))</code>
|
| 349 |
+
to prevent duplicate connection requests even when <code>trial_id</code> is NULL.
|
| 350 |
+
<code>patient_trial_interests</code> has <code>UNIQUE(patient_id, trial_id)</code>.
|
| 351 |
+
<code>connection_messages</code> has index <code>idx_msgs_conn ON (connection_id, created_at)</code>.
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</section>
|
| 355 |
+
|
| 356 |
+
<!-- =========================================================
|
| 357 |
+
3. ML PIPELINE
|
| 358 |
+
========================================================= -->
|
| 359 |
+
<section id="s3">
|
| 360 |
+
<h2>3. ML Pipeline β Boot, Train, Infer</h2>
|
| 361 |
+
<p class="subtitle">
|
| 362 |
+
<code>pipeline.py</code> β Data loading from 11 CSV files, pseudo-label generation,
|
| 363 |
+
Random Forest training with isotonic calibration, and 3 inference functions.
|
| 364 |
+
</p>
|
| 365 |
+
<div class="diagram-wrap">
|
| 366 |
+
<div class="mermaid">
|
| 367 |
+
flowchart TD
|
| 368 |
+
subgraph BOOT_TH["Boot Thread (background, server stays live during this)"]
|
| 369 |
+
direction LR
|
| 370 |
+
BT1["pipeline.load()"] --> BT2["pipeline.train()"]
|
| 371 |
+
end
|
| 372 |
+
|
| 373 |
+
subgraph LOAD_PHASE["load() β CSV Ingestion into Memory"]
|
| 374 |
+
direction LR
|
| 375 |
+
subgraph TRIAL_CSV["ClinicalTrials.gov CSVs"]
|
| 376 |
+
TC1["trail_studies.csv<br/><small>β trial_profiles dict<br/>β active_trial_ids set<br/>65k recruiting trials</small>"]
|
| 377 |
+
TC2["trail_conditions.csv<br/><small>β trial_conditions dict<br/>34k trials with conditions</small>"]
|
| 378 |
+
TC3["trail_eligibilities.csv<br/><small>β age range + gender per trial</small>"]
|
| 379 |
+
TC4["trail_facilities.csv<br/><small>β trial_us_states dict<br/>β trial_facility_tokens dict<br/>189k trials with US geo</small>"]
|
| 380 |
+
TC5["trail_interventions.csv<br/><small>β trial_drug_keywords dict<br/>196k trials with drug data</small>"]
|
| 381 |
+
TC6["trail_brief_summaries.csv<br/><small>β trial_summaries dict</small>"]
|
| 382 |
+
TC7["trail_keywords.csv<br/><small>β trial_keywords dict</small>"]
|
| 383 |
+
end
|
| 384 |
+
subgraph SYNTHEA_CSV["Synthea Patient CSVs"]
|
| 385 |
+
SC1["patients_details.csv<br/><small>β patient_demographics dict<br/>265k patients</small>"]
|
| 386 |
+
SC2["final_patients_conditions.csv<br/><small>β patient_conditions dict<br/>106 overlapping conditions</small>"]
|
| 387 |
+
SC3["patients_medications.csv<br/><small>β patient_medications dict<br/>213k patients</small>"]
|
| 388 |
+
SC4["patients_observations.csv<br/><small>β patient_lab_types dict<br/>23k patients</small>"]
|
| 389 |
+
end
|
| 390 |
+
end
|
| 391 |
+
|
| 392 |
+
subgraph TRAIN_PHASE["train() β Model Training"]
|
| 393 |
+
direction TB
|
| 394 |
+
TR1["Sample 3000 patients Γ 30 trials<br/>+ random negatives<br/><small>GroupShuffleSplit 80/20 patient-level</small>"]
|
| 395 |
+
TR2["_compute_features()<br/><small>17-feature vector per patient-trial pair<br/>~90k training pairs total</small>"]
|
| 396 |
+
TR3["Pseudo-label generation<br/><small>6-feature weighted score >= 0.5<br/>+ 15% hash-deterministic noise<br/>β 82.1% positive rate</small>"]
|
| 397 |
+
TR4["RandomForestClassifier<br/><small>n_estimators=200, max_depth=12<br/>class_weight=balanced</small>"]
|
| 398 |
+
TR5["CalibratedClassifierCV<br/><small>method=isotonic, cv=3</small>"]
|
| 399 |
+
TR6["Save model_cache.pkl<br/><small>skip training on next boot</small>"]
|
| 400 |
+
TR1 --> TR2 --> TR3 --> TR4 --> TR5 --> TR6
|
| 401 |
+
end
|
| 402 |
+
|
| 403 |
+
subgraph INFER["Inference β 3 Matching Functions"]
|
| 404 |
+
direction LR
|
| 405 |
+
IN1["<b>match_patient()</b><br/><small>Input: conditions, age, gender, patient_id<br/>Output: top-k trials ranked by<br/>combined_score = 0.6Γeligibility_prob + 0.4Γmatch_score</small>"]
|
| 406 |
+
IN2["<b>trials_for_hospital()</b><br/><small>Input: hospital name, location, research_conditions<br/>Output: active trials in 3 tiers<br/>Tier1: facility name Jaccard >= 0.25<br/>Tier2: same US state<br/>Tier3: condition overlap</small>"]
|
| 407 |
+
IN3["<b>hospitals_for_trial()</b><br/><small>Input: trial_id<br/>Output: DB hospitals in 4 tiers<br/>Tier1: name Jaccard >= 0.25<br/>Tier2: same state<br/>Tier3: condition overlap<br/>Tier4: fallback</small>"]
|
| 408 |
+
end
|
| 409 |
+
|
| 410 |
+
BOOT_TH --> LOAD_PHASE
|
| 411 |
+
BOOT_TH --> TRAIN_PHASE
|
| 412 |
+
LOAD_PHASE --> TR1
|
| 413 |
+
TR5 --> IN1
|
| 414 |
+
LOAD_PHASE --> IN2
|
| 415 |
+
LOAD_PHASE --> IN3
|
| 416 |
+
</div>
|
| 417 |
+
<div class="note">
|
| 418 |
+
<strong>Cache behaviour:</strong> If <code>model_cache.pkl</code> exists on disk, <code>train()</code> loads the cached RF + calibrator
|
| 419 |
+
and skips the ~2-3 min training step. Delete the file to force a full retrain (required after changing features or pipeline logic).
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
</section>
|
| 423 |
+
|
| 424 |
+
<!-- =========================================================
|
| 425 |
+
4. PATIENT JOURNEY
|
| 426 |
+
========================================================= -->
|
| 427 |
+
<section id="s4">
|
| 428 |
+
<h2>4. Patient User Journey</h2>
|
| 429 |
+
<p class="subtitle">
|
| 430 |
+
Key request-response flows through the patient portal, from registration to messaging.
|
| 431 |
+
</p>
|
| 432 |
+
<div class="diagram-wrap">
|
| 433 |
+
<div class="mermaid">
|
| 434 |
+
sequenceDiagram
|
| 435 |
+
actor Patient
|
| 436 |
+
participant UI as patient.html
|
| 437 |
+
participant Flask as Flask (app.py)
|
| 438 |
+
participant DB as database.py
|
| 439 |
+
participant ML as pipeline.py
|
| 440 |
+
participant FS as File System
|
| 441 |
+
|
| 442 |
+
Note over Patient,FS: Registration / Login
|
| 443 |
+
Patient->>UI: Fill register form
|
| 444 |
+
UI->>Flask: POST /auth/patient/register
|
| 445 |
+
Flask->>DB: create_patient()
|
| 446 |
+
DB-->>Flask: patient dict (or 409 username taken)
|
| 447 |
+
Flask-->>UI: session cookie + patient JSON
|
| 448 |
+
UI->>UI: Render portal, call checkStatus()
|
| 449 |
+
|
| 450 |
+
Note over Patient,FS: Pipeline ready check
|
| 451 |
+
UI->>Flask: GET /api/status
|
| 452 |
+
Flask-->>UI: ready: false (202) or ready: true + stats
|
| 453 |
+
UI->>UI: Show yellow banner if not ready; startStatusPoll() every 5s
|
| 454 |
+
|
| 455 |
+
Note over Patient,FS: Profile update
|
| 456 |
+
Patient->>UI: Edit conditions / medications / toggle open_to_trials
|
| 457 |
+
UI->>Flask: POST /api/patient/profile
|
| 458 |
+
Flask->>DB: update_patient_profile()
|
| 459 |
+
|
| 460 |
+
Note over Patient,FS: Get AI trial matches
|
| 461 |
+
Patient->>UI: Click "Suggested Trials" tab
|
| 462 |
+
UI->>Flask: GET /api/patient/matches
|
| 463 |
+
Flask->>ML: match_patient(conditions, age, gender, patient_id)
|
| 464 |
+
ML->>ML: _compute_features() x all active trials
|
| 465 |
+
ML->>ML: RF.predict_proba() β calibrated eligibility prob
|
| 466 |
+
ML->>ML: combined_score = 0.6Γprob + 0.4Γmatch_score
|
| 467 |
+
ML-->>Flask: top-20 trials sorted by combined_score
|
| 468 |
+
Flask-->>UI: results array with 24 fields per trial
|
| 469 |
+
|
| 470 |
+
Note over Patient,FS: Connect with a hospital
|
| 471 |
+
Patient->>UI: Click "Connect with Hospital" on a trial card
|
| 472 |
+
UI->>Flask: GET /api/patient/hospitals-for-trial?trial_id=NCT...
|
| 473 |
+
Flask->>ML: hospitals_for_trial(trial_id)
|
| 474 |
+
ML-->>Flask: tiered hospital list (Tier 1..4)
|
| 475 |
+
Flask-->>UI: hospitals array
|
| 476 |
+
UI->>UI: Render modal β green section (Tier1) + grey section (Tier2-4)
|
| 477 |
+
Patient->>UI: Select hospital + enter message + submit
|
| 478 |
+
UI->>Flask: POST /api/patient/connect
|
| 479 |
+
Flask->>DB: connection_exists()? if no β create_connection()
|
| 480 |
+
DB-->>Flask: connection dict (or 409 duplicate)
|
| 481 |
+
Flask-->>UI: success redirect to connections tab
|
| 482 |
+
|
| 483 |
+
Note over Patient,FS: Inbox and messaging
|
| 484 |
+
Patient->>UI: Click "Inbox" tab
|
| 485 |
+
UI->>Flask: GET /api/patient/inbox
|
| 486 |
+
Flask->>DB: get_patient_inbox_threads(patient_id)
|
| 487 |
+
DB-->>Flask: threads with last_message + unread_count
|
| 488 |
+
Flask-->>UI: threads array + update badge
|
| 489 |
+
Patient->>UI: Click "Open" on a thread
|
| 490 |
+
UI->>Flask: GET /api/patient/connections/{cid}/messages
|
| 491 |
+
Flask->>DB: mark_messages_read(role=hospital)
|
| 492 |
+
Flask->>DB: get_connection_messages(cid)
|
| 493 |
+
Flask-->>UI: messages array
|
| 494 |
+
Patient->>UI: Type reply + Enter or Send
|
| 495 |
+
UI->>Flask: POST /api/patient/connections/{cid}/messages
|
| 496 |
+
Flask->>DB: create_connection_message(role=patient)
|
| 497 |
+
|
| 498 |
+
Note over Patient,FS: Document upload
|
| 499 |
+
Patient->>UI: Select file in Documents section
|
| 500 |
+
UI->>Flask: POST /api/patient/documents (multipart/form-data)
|
| 501 |
+
Flask->>Flask: secure_filename, validate extension + 10MB limit
|
| 502 |
+
Flask->>FS: Save to uploads/patient_docs/{pid}/{doc_id}_filename
|
| 503 |
+
Flask->>DB: update_patient_profile(documents=[...append])
|
| 504 |
+
Flask-->>UI: document metadata dict
|
| 505 |
+
</div>
|
| 506 |
+
</div>
|
| 507 |
+
</section>
|
| 508 |
+
|
| 509 |
+
<!-- =========================================================
|
| 510 |
+
5. HOSPITAL JOURNEY
|
| 511 |
+
========================================================= -->
|
| 512 |
+
<section id="s5">
|
| 513 |
+
<h2>5. Hospital User Journey</h2>
|
| 514 |
+
<p class="subtitle">
|
| 515 |
+
Key request-response flows through the hospital portal, from login to connection management and messaging.
|
| 516 |
+
</p>
|
| 517 |
+
<div class="diagram-wrap">
|
| 518 |
+
<div class="mermaid">
|
| 519 |
+
sequenceDiagram
|
| 520 |
+
actor Hospital
|
| 521 |
+
participant UI as hospital.html
|
| 522 |
+
participant Flask as Flask (app.py)
|
| 523 |
+
participant DB as database.py
|
| 524 |
+
participant ML as pipeline.py
|
| 525 |
+
|
| 526 |
+
Note over Hospital,ML: Login
|
| 527 |
+
Hospital->>UI: Enter credentials
|
| 528 |
+
UI->>Flask: POST /auth/hospital/login
|
| 529 |
+
Flask->>DB: authenticate_hospital()
|
| 530 |
+
DB-->>Flask: hospital dict
|
| 531 |
+
Flask-->>UI: session cookie + hospital JSON
|
| 532 |
+
|
| 533 |
+
Note over Hospital,ML: Browse available patients
|
| 534 |
+
Hospital->>UI: Click "Available Patients" tab (default)
|
| 535 |
+
UI->>Flask: GET /api/hospital/patients
|
| 536 |
+
Flask->>DB: get_open_patients_for_hospital(include_connected=False)
|
| 537 |
+
DB-->>Flask: patients with open_to_trials=1 excluding already-connected
|
| 538 |
+
Flask-->>UI: patient cards (25 opt-in patients total)
|
| 539 |
+
|
| 540 |
+
Note over Hospital,ML: Search by condition
|
| 541 |
+
Hospital->>UI: Type condition + toggle "include connected"
|
| 542 |
+
UI->>Flask: GET /api/hospital/patients?condition=diabetes&include_connected=true
|
| 543 |
+
Flask->>DB: get_open_patients_for_hospital(condition_filter, include_connected=True)
|
| 544 |
+
Flask-->>UI: filtered patient list
|
| 545 |
+
|
| 546 |
+
Note over Hospital,ML: View matched trials
|
| 547 |
+
Hospital->>UI: Click "My Trials" tab
|
| 548 |
+
UI->>Flask: GET /api/hospital/trials
|
| 549 |
+
Flask->>ML: trials_for_hospital(hospital_name, location, research_conditions)
|
| 550 |
+
ML->>ML: Tier1 β facility name Jaccard >= 0.25 + is_active
|
| 551 |
+
ML->>ML: Tier2 β same US state + is_active
|
| 552 |
+
ML->>ML: Tier3 β condition overlap + is_active
|
| 553 |
+
ML-->>Flask: active trial list with match_tier and match_reason
|
| 554 |
+
Flask-->>UI: trial cards with status, phase, facility, ClinicalTrials.gov link
|
| 555 |
+
|
| 556 |
+
Note over Hospital,ML: Connect with a patient
|
| 557 |
+
Hospital->>UI: Click "Connect" on a patient card
|
| 558 |
+
UI->>Flask: POST /api/hospital/connect
|
| 559 |
+
Flask->>DB: create_connection(initiated_by=hospital)
|
| 560 |
+
DB-->>Flask: connection dict (or 409 duplicate)
|
| 561 |
+
|
| 562 |
+
Note over Hospital,ML: Manage connections
|
| 563 |
+
Hospital->>UI: Click "My Connections" tab
|
| 564 |
+
UI->>Flask: GET /api/hospital/connections
|
| 565 |
+
Flask->>DB: get_hospital_connections(hospital_id)
|
| 566 |
+
Flask-->>UI: connection table with patient details
|
| 567 |
+
Hospital->>UI: Click "Accept" / "Reject" / "Complete"
|
| 568 |
+
UI->>Flask: PUT /api/hospital/connections/{cid}/status
|
| 569 |
+
Flask->>DB: update_connection_status(cid, status)
|
| 570 |
+
|
| 571 |
+
Note over Hospital,ML: Inbox and messaging
|
| 572 |
+
Hospital->>UI: Click "Inbox" tab
|
| 573 |
+
UI->>Flask: GET /api/hospital/inbox
|
| 574 |
+
Flask->>DB: get_hospital_inbox_threads(hospital_id)
|
| 575 |
+
DB-->>Flask: threads sorted by last_message_at DESC
|
| 576 |
+
Flask-->>UI: thread list with unread_count, update nav badge
|
| 577 |
+
Hospital->>UI: Click "Open" on a thread
|
| 578 |
+
UI->>Flask: GET /api/hospital/connections/{cid}/messages
|
| 579 |
+
Flask->>DB: mark_messages_read(role=hospital)
|
| 580 |
+
Flask->>DB: get_connection_messages(cid)
|
| 581 |
+
Flask-->>UI: messages in chronological order
|
| 582 |
+
Hospital->>UI: Type reply + Send
|
| 583 |
+
UI->>Flask: POST /api/hospital/connections/{cid}/messages
|
| 584 |
+
Flask->>DB: create_connection_message(role=hospital)
|
| 585 |
+
|
| 586 |
+
Note over Hospital,ML: Update profile
|
| 587 |
+
Hospital->>UI: Edit name / location / research conditions
|
| 588 |
+
UI->>Flask: POST /api/hospital/profile
|
| 589 |
+
Flask->>DB: update_hospital_profile()
|
| 590 |
+
Flask-->>UI: updated hospital dict
|
| 591 |
+
UI->>UI: Update navbar name live (no page reload)
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
</section>
|
| 595 |
+
|
| 596 |
+
<!-- =========================================================
|
| 597 |
+
6. TIERED MATCHING
|
| 598 |
+
========================================================= -->
|
| 599 |
+
<section id="s6">
|
| 600 |
+
<h2>6. Tiered Matching Logic</h2>
|
| 601 |
+
<p class="subtitle">
|
| 602 |
+
Two reverse-matching functions sharing the same 4-tier cascade. Both use
|
| 603 |
+
<code>_name_tokens()</code> + Jaccard similarity for Tier 1, US state extraction for Tier 2,
|
| 604 |
+
and condition set intersection for Tier 3.
|
| 605 |
+
</p>
|
| 606 |
+
<div class="grid-2">
|
| 607 |
+
<div>
|
| 608 |
+
<h3 style="color:#3fb950;margin-bottom:.75rem;font-size:1rem;">
|
| 609 |
+
hospitals_for_trial(trial_id) β Patient selects a trial
|
| 610 |
+
</h3>
|
| 611 |
+
<div class="diagram-wrap">
|
| 612 |
+
<div class="mermaid">
|
| 613 |
+
flowchart TD
|
| 614 |
+
START_A["Patient clicks<br/>'Connect with Hospital'<br/>on trial NCTxxxxxx"]
|
| 615 |
+
|
| 616 |
+
subgraph T1A["Tier 1 β Verified Site"]
|
| 617 |
+
C1A["For each hospital in DB:<br/>Jaccard(hospital name tokens,<br/>ANY trial facility name tokens) >= 0.25"]
|
| 618 |
+
R1A["π’ match_reason:<br/>'verified site on this trial'"]
|
| 619 |
+
end
|
| 620 |
+
|
| 621 |
+
subgraph T2A["Tier 2 β Same State"]
|
| 622 |
+
C2A["Hospital state extracted from<br/>'City, ST' location field<br/>== any trial US facility state"]
|
| 623 |
+
R2A["β¬ match_reason:<br/>'in same state as a trial site'"]
|
| 624 |
+
end
|
| 625 |
+
|
| 626 |
+
subgraph T3A["Tier 3 β Condition Overlap"]
|
| 627 |
+
C3A["hospital.research_conditions<br/>β© trial.conditions β empty"]
|
| 628 |
+
R3A["β¬ match_reason:<br/>'researches related conditions'"]
|
| 629 |
+
end
|
| 630 |
+
|
| 631 |
+
subgraph T4A["Tier 4 β Fallback"]
|
| 632 |
+
C4A["Trial has no US facility data<br/>all remaining hospitals included"]
|
| 633 |
+
R4A["β¬ match_reason: ''"]
|
| 634 |
+
end
|
| 635 |
+
|
| 636 |
+
MODAL["Render connect modal<br/>π’ VERIFIED TRIAL SITES header<br/>β¬ RELATED HOSPITALS header<br/>(Tiers 2β4)"]
|
| 637 |
+
|
| 638 |
+
START_A --> T1A --> T2A --> T3A --> T4A --> MODAL
|
| 639 |
+
</div>
|
| 640 |
+
</div>
|
| 641 |
+
</div>
|
| 642 |
+
|
| 643 |
+
<div>
|
| 644 |
+
<h3 style="color:#79c0ff;margin-bottom:.75rem;font-size:1rem;">
|
| 645 |
+
trials_for_hospital(name, loc, conditions) β Hospital views My Trials
|
| 646 |
+
</h3>
|
| 647 |
+
<div class="diagram-wrap">
|
| 648 |
+
<div class="mermaid">
|
| 649 |
+
flowchart TD
|
| 650 |
+
START_B["Hospital opens<br/>'My Trials' tab"]
|
| 651 |
+
|
| 652 |
+
subgraph T1B["Tier 1 β Name Matched"]
|
| 653 |
+
C1B["For each active trial:<br/>Jaccard(hospital name tokens,<br/>trial facility name tokens) >= 0.25<br/>+ is_active = True"]
|
| 654 |
+
R1B["match_tier: 1<br/>match_reason:<br/>'name matched to trial site'"]
|
| 655 |
+
end
|
| 656 |
+
|
| 657 |
+
subgraph T2B["Tier 2 β Same State"]
|
| 658 |
+
C2B["Hospital state<br/>== trial US facility state<br/>+ is_active = True"]
|
| 659 |
+
R2B["match_tier: 2<br/>match_reason:<br/>'in same state as trial site'"]
|
| 660 |
+
end
|
| 661 |
+
|
| 662 |
+
subgraph T3B["Tier 3 β Condition Overlap"]
|
| 663 |
+
C3B["hospital.research_conditions<br/>β© trial.conditions β empty<br/>+ is_active = True"]
|
| 664 |
+
R3B["match_tier: 3<br/>match_reason:<br/>'researches related conditions'"]
|
| 665 |
+
end
|
| 666 |
+
|
| 667 |
+
SORT["Sort by match_tier ASC<br/>cap at top_k=20<br/>Return trial cards with<br/>status Β· phase Β· facility Β· summary"]
|
| 668 |
+
|
| 669 |
+
START_B --> T1B --> T2B --> T3B --> SORT
|
| 670 |
+
</div>
|
| 671 |
+
</div>
|
| 672 |
+
</div>
|
| 673 |
+
</div>
|
| 674 |
+
|
| 675 |
+
<div style="margin-top:1.5rem">
|
| 676 |
+
<h3 style="color:#c9d1d9;margin-bottom:.75rem;font-size:1rem;">Shared Helpers</h3>
|
| 677 |
+
<div class="diagram-wrap">
|
| 678 |
+
<div class="mermaid">
|
| 679 |
+
flowchart LR
|
| 680 |
+
subgraph TOK["_name_tokens(name)"]
|
| 681 |
+
TK1["Lowercase all words"]
|
| 682 |
+
TK2["Remove stopwords:<br/>hospital, medical, center, clinic, university,<br/>health, care, system, institute, general,<br/>regional, national, community, the, of, and, atβ¦"]
|
| 683 |
+
TK3["Remove words shorter than 3 chars"]
|
| 684 |
+
TK4["Return frozenset of tokens"]
|
| 685 |
+
TK1 --> TK2 --> TK3 --> TK4
|
| 686 |
+
end
|
| 687 |
+
|
| 688 |
+
subgraph JAC["Jaccard Similarity"]
|
| 689 |
+
J1["score = |A β© B| / |A βͺ B|"]
|
| 690 |
+
J2["Best score across ALL<br/>facility names for the trial"]
|
| 691 |
+
J1 --> J2
|
| 692 |
+
end
|
| 693 |
+
|
| 694 |
+
subgraph STATE["State Extraction"]
|
| 695 |
+
S1["Parse 'City, ST' location string"]
|
| 696 |
+
S2["Lookup ST abbreviation<br/>in US_STATE_MAP dict<br/>β full name e.g. 'Massachusetts'"]
|
| 697 |
+
S3["Compare against<br/>trial_us_states set<br/>(from trail_facilities.csv)"]
|
| 698 |
+
S1 --> S2 --> S3
|
| 699 |
+
end
|
| 700 |
+
|
| 701 |
+
TOK --> JAC
|
| 702 |
+
TOK --> STATE
|
| 703 |
+
</div>
|
| 704 |
+
</div>
|
| 705 |
+
</div>
|
| 706 |
+
</section>
|
| 707 |
+
|
| 708 |
+
<!-- =========================================================
|
| 709 |
+
7. FEATURE ENGINEERING
|
| 710 |
+
========================================================= -->
|
| 711 |
+
<section id="s7">
|
| 712 |
+
<h2>7. Feature Engineering β 17 Features</h2>
|
| 713 |
+
<p class="subtitle">
|
| 714 |
+
All features are normalised to 0β1. Computed by <code>_compute_features(patient_row, trial_id)</code>
|
| 715 |
+
in pipeline.py. Used for both training pseudo-labels and inference scoring.
|
| 716 |
+
</p>
|
| 717 |
+
<div class="diagram-wrap">
|
| 718 |
+
<div class="mermaid">
|
| 719 |
+
flowchart LR
|
| 720 |
+
INPUT["Patient Profile<br/>+ Trial Profile"]
|
| 721 |
+
|
| 722 |
+
subgraph COND_GRP["Condition Overlap (4)"]
|
| 723 |
+
direction TB
|
| 724 |
+
CF1["condition_overlap<br/><small>raw count: |patient_conds β© trial_conds|</small>"]
|
| 725 |
+
CF2["jaccard_similarity<br/><small>overlap / union</small>"]
|
| 726 |
+
CF3["overlap_ratio_trial<br/><small>overlap / |trial_conds|</small>"]
|
| 727 |
+
CF4["overlap_ratio_patient<br/><small>overlap / |patient_conds|</small>"]
|
| 728 |
+
end
|
| 729 |
+
|
| 730 |
+
subgraph PROF_GRP["Condition Profile (5)"]
|
| 731 |
+
direction TB
|
| 732 |
+
PF1["condition_rarity_score<br/><small>mean(1/log2(n_trials_per_cond + 2)), norm 0-1</small>"]
|
| 733 |
+
PF2["trial_specificity<br/><small>1 / |trial_conds|</small>"]
|
| 734 |
+
PF3["condition_burden<br/><small>total_patient_conds / 10</small>"]
|
| 735 |
+
PF4["active_ratio<br/><small>active_conds / total_conds</small>"]
|
| 736 |
+
PF5["resolved_ratio<br/><small>resolved_conds / total_conds</small>"]
|
| 737 |
+
end
|
| 738 |
+
|
| 739 |
+
subgraph ELIG_GRP["Eligibility (4)"]
|
| 740 |
+
direction TB
|
| 741 |
+
EF1["age_distance<br/><small>norm distance outside [min_age, max_age]</small>"]
|
| 742 |
+
EF2["age_centered<br/><small>position within range, -1 to +1</small>"]
|
| 743 |
+
EF3["age_compatibility<br/><small>1.0 in range; decays over 30yr gap</small>"]
|
| 744 |
+
EF4["gender_compatibility<br/><small>1.0 match/ALL; 0.1 mismatch</small>"]
|
| 745 |
+
end
|
| 746 |
+
|
| 747 |
+
subgraph EXT_GRP["External Signals (3)"]
|
| 748 |
+
direction TB
|
| 749 |
+
XF1["geo_feasibility<br/><small>1.0 same state Β· 0.75 other US state Β· 0.5 no data</small>"]
|
| 750 |
+
XF2["med_compatibility<br/><small>patient med keywords β© trial drug intervention keywords</small>"]
|
| 751 |
+
XF3["lab_availability<br/><small>patient observation types covered by trial / 20</small>"]
|
| 752 |
+
end
|
| 753 |
+
|
| 754 |
+
subgraph META_GRP["Metadata (1)"]
|
| 755 |
+
MF1["data_completeness<br/><small>fraction of key fields non-empty<br/>importance: ~0.0 in trained RF</small>"]
|
| 756 |
+
end
|
| 757 |
+
|
| 758 |
+
RF["RandomForest<br/>predict_proba()<br/>β eligibility_prob"]
|
| 759 |
+
|
| 760 |
+
INPUT --> COND_GRP
|
| 761 |
+
INPUT --> PROF_GRP
|
| 762 |
+
INPUT --> ELIG_GRP
|
| 763 |
+
INPUT --> EXT_GRP
|
| 764 |
+
INPUT --> META_GRP
|
| 765 |
+
|
| 766 |
+
COND_GRP --> RF
|
| 767 |
+
PROF_GRP --> RF
|
| 768 |
+
ELIG_GRP --> RF
|
| 769 |
+
EXT_GRP --> RF
|
| 770 |
+
META_GRP --> RF
|
| 771 |
+
</div>
|
| 772 |
+
|
| 773 |
+
<div class="note" style="margin-top:1rem">
|
| 774 |
+
<strong>Top features by importance (Random Forest):</strong>
|
| 775 |
+
age_distance (20.4%) Β· age_compatibility (16.9%) Β· gender_compatibility (12.9%) Β· age_centered (7.9%) Β·
|
| 776 |
+
jaccard_similarity (7.5%) Β· condition_rarity_score (6.2%) Β· overlap_ratio_trial (5.2%) Β· overlap_ratio_patient (5.1%) Β·
|
| 777 |
+
condition_overlap (4.0%) Β· remaining 8 features (<3% each).
|
| 778 |
+
<br/><br/>
|
| 779 |
+
<strong>Note on AUC (0.60):</strong> The 82.1% positive pseudo-label rate makes the classification trivially high-accuracy
|
| 780 |
+
but limits discriminative power. Raising the label threshold from 0.5 β 0.6 or enforcing equal pos/neg sampling would improve AUC.
|
| 781 |
+
</div>
|
| 782 |
+
</div>
|
| 783 |
+
</section>
|
| 784 |
+
|
| 785 |
+
<footer style="border-top:1px solid #30363d;margin-top:3rem;padding-top:1.5rem;color:#8b949e;font-size:0.8rem;text-align:center">
|
| 786 |
+
Second Life Β· DSCI 5260 Β· Group 7 Β· Generated 2026-04-25
|
| 787 |
+
</footer>
|
| 788 |
+
|
| 789 |
+
<script>
|
| 790 |
+
mermaid.initialize({
|
| 791 |
+
startOnLoad: true,
|
| 792 |
+
theme: 'dark',
|
| 793 |
+
themeVariables: {
|
| 794 |
+
primaryColor: '#1f6feb',
|
| 795 |
+
primaryTextColor: '#c9d1d9',
|
| 796 |
+
primaryBorderColor: '#388bfd',
|
| 797 |
+
lineColor: '#58a6ff',
|
| 798 |
+
secondaryColor: '#161b22',
|
| 799 |
+
tertiaryColor: '#1c2128',
|
| 800 |
+
background: '#0d1117',
|
| 801 |
+
mainBkg: '#161b22',
|
| 802 |
+
nodeBorder: '#30363d',
|
| 803 |
+
clusterBkg: '#13161b',
|
| 804 |
+
clusterBorder: '#30363d',
|
| 805 |
+
titleColor: '#79c0ff',
|
| 806 |
+
edgeLabelBackground: '#161b22',
|
| 807 |
+
fontFamily: 'system-ui, sans-serif',
|
| 808 |
+
},
|
| 809 |
+
flowchart: {
|
| 810 |
+
useMaxWidth: false,
|
| 811 |
+
htmlLabels: true,
|
| 812 |
+
curve: 'basis',
|
| 813 |
+
padding: 20,
|
| 814 |
+
},
|
| 815 |
+
sequence: {
|
| 816 |
+
useMaxWidth: false,
|
| 817 |
+
mirrorActors: false,
|
| 818 |
+
messageAlign: 'center',
|
| 819 |
+
actorFontFamily:'system-ui, sans-serif',
|
| 820 |
+
noteFontFamily: 'system-ui, sans-serif',
|
| 821 |
+
},
|
| 822 |
+
er: {
|
| 823 |
+
useMaxWidth: false,
|
| 824 |
+
}
|
| 825 |
+
});
|
| 826 |
+
</script>
|
| 827 |
+
</body>
|
| 828 |
+
</html>
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PORT=7860
|
| 5 |
+
ENV HF_DATASET_REPO=MrNoOne07/second-life-data
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
CMD ["python", "hf_space_bootstrap.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title: Second Life
|
| 3 |
-
emoji: π₯
|
| 4 |
-
colorFrom: pink
|
| 5 |
-
colorTo: red
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Second Life Clinical Trial Matching
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
pinned: false
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# Second Life Clinical Trial Matching
|
| 9 |
+
|
| 10 |
+
Second Life is a two-portal Flask application for matching synthetic patient
|
| 11 |
+
profiles to clinical trials and helping hospitals identify patients who are open
|
| 12 |
+
to trial participation.
|
| 13 |
+
|
| 14 |
+
## Space Startup
|
| 15 |
+
|
| 16 |
+
This Hugging Face Space uses Docker. On startup, `hf_space_bootstrap.py`
|
| 17 |
+
downloads the required dataset zip files from the companion Dataset repository:
|
| 18 |
+
|
| 19 |
+
`MrNoOne07/second-life-data`
|
| 20 |
+
|
| 21 |
+
The app then starts `app.py` on port `7860`.
|
| 22 |
+
|
| 23 |
+
## Demo Logins
|
| 24 |
+
|
| 25 |
+
Demo credentials are documented in `login.md`.
|
| 26 |
+
|
| 27 |
+
## Data Notice
|
| 28 |
+
|
| 29 |
+
The uploaded demo data includes generated Synthea patient data and processed
|
| 30 |
+
ClinicalTrials.gov data for class demonstration. MIMIC-IV validation data is not
|
| 31 |
+
bundled in this Space by default because it has separate access and licensing
|
| 32 |
+
requirements.
|
Second_Life_Project_Documentation.docx
ADDED
|
Binary file (45.4 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Second Life β Flask Web Application
|
| 3 |
+
DSCI 5260 | Group 7
|
| 4 |
+
|
| 5 |
+
Two-portal system: Patient Portal + Hospital Portal
|
| 6 |
+
Run: python app.py β http://localhost:5000
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import uuid
|
| 12 |
+
import mimetypes
|
| 13 |
+
import secrets
|
| 14 |
+
import threading
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from flask import (Flask, jsonify, redirect, render_template,
|
| 19 |
+
request, send_file, session, url_for)
|
| 20 |
+
from werkzeug.utils import secure_filename
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 23 |
+
from pipeline import SecondLifePipeline
|
| 24 |
+
import database as db
|
| 25 |
+
|
| 26 |
+
app = Flask(__name__)
|
| 27 |
+
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
| 28 |
+
app.config["JSON_SORT_KEYS"] = False
|
| 29 |
+
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
|
| 30 |
+
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
| 31 |
+
|
| 32 |
+
UPLOAD_DIR = Path(__file__).parent / "uploads" / "patient_docs"
|
| 33 |
+
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".doc", ".txt", ".png", ".jpg", ".jpeg"}
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Pipeline boot (background thread β server comes up immediately)
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
pipeline: SecondLifePipeline | None = None
|
| 39 |
+
_boot_error: str = ""
|
| 40 |
+
_boot_ready = threading.Event()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _boot():
|
| 44 |
+
global pipeline, _boot_error
|
| 45 |
+
try:
|
| 46 |
+
db.init_db()
|
| 47 |
+
p = SecondLifePipeline()
|
| 48 |
+
p.load()
|
| 49 |
+
p.train()
|
| 50 |
+
pipeline = p
|
| 51 |
+
print("[app] Pipeline ready.")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
import traceback
|
| 54 |
+
_boot_error = traceback.format_exc()
|
| 55 |
+
print(f"[app] BOOT ERROR:\n{_boot_error}", file=sys.stderr)
|
| 56 |
+
finally:
|
| 57 |
+
_boot_ready.set()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
_t = threading.Thread(target=_boot, daemon=True)
|
| 61 |
+
_t.start()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _ready():
|
| 65 |
+
return pipeline is not None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Auth guards (return (response, code) or None)
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def _patient_required():
|
| 73 |
+
if "patient_id" not in session:
|
| 74 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _hospital_required():
|
| 79 |
+
if "hospital_id" not in session:
|
| 80 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Sanitisers (strip password hash before sending to client)
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
def _pub_patient(p: dict) -> dict:
|
| 89 |
+
d = dict(p)
|
| 90 |
+
d.pop("password_hash", None)
|
| 91 |
+
return d
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _pub_hospital(h: dict) -> dict:
|
| 95 |
+
d = dict(h)
|
| 96 |
+
d.pop("password_hash", None)
|
| 97 |
+
return d
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Page routes
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
@app.route("/")
|
| 105 |
+
def landing():
|
| 106 |
+
if "patient_id" in session:
|
| 107 |
+
return redirect(url_for("patient_portal"))
|
| 108 |
+
if "hospital_id" in session:
|
| 109 |
+
return redirect(url_for("hospital_portal"))
|
| 110 |
+
return render_template("landing.html")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@app.route("/patient")
|
| 114 |
+
def patient_portal():
|
| 115 |
+
if "patient_id" not in session:
|
| 116 |
+
return redirect(url_for("landing"))
|
| 117 |
+
patient = db.get_patient_by_id(session["patient_id"])
|
| 118 |
+
if not patient:
|
| 119 |
+
session.clear()
|
| 120 |
+
return redirect(url_for("landing"))
|
| 121 |
+
return render_template("patient.html", patient=_pub_patient(patient))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.route("/hospital")
|
| 125 |
+
def hospital_portal():
|
| 126 |
+
if "hospital_id" not in session:
|
| 127 |
+
return redirect(url_for("landing"))
|
| 128 |
+
hospital = db.get_hospital_by_id(session["hospital_id"])
|
| 129 |
+
if not hospital:
|
| 130 |
+
session.clear()
|
| 131 |
+
return redirect(url_for("landing"))
|
| 132 |
+
return render_template("hospital.html", hospital=_pub_hospital(hospital))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Auth routes
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
@app.route("/auth/patient/register", methods=["POST"])
|
| 140 |
+
def patient_register():
|
| 141 |
+
data = request.get_json(force=True) or {}
|
| 142 |
+
username = data.get("username", "").strip()
|
| 143 |
+
password = data.get("password", "").strip()
|
| 144 |
+
first_name = data.get("first_name", "").strip()
|
| 145 |
+
last_name = data.get("last_name", "").strip()
|
| 146 |
+
dob = data.get("dob", "").strip()
|
| 147 |
+
gender = data.get("gender", "").strip().upper()
|
| 148 |
+
address = data.get("address", "").strip()
|
| 149 |
+
|
| 150 |
+
if not username or not password:
|
| 151 |
+
return jsonify({"error": "Username and password are required"}), 400
|
| 152 |
+
|
| 153 |
+
patient = db.create_patient(username, password, first_name, last_name,
|
| 154 |
+
dob, gender, address)
|
| 155 |
+
if patient is None:
|
| 156 |
+
return jsonify({"error": "Username already taken"}), 409
|
| 157 |
+
|
| 158 |
+
session["patient_id"] = patient["id"]
|
| 159 |
+
session["role"] = "patient"
|
| 160 |
+
return jsonify({"success": True, "patient": _pub_patient(patient)})
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.route("/auth/patient/login", methods=["POST"])
|
| 164 |
+
def patient_login():
|
| 165 |
+
data = request.get_json(force=True) or {}
|
| 166 |
+
patient = db.authenticate_patient(data.get("username", ""),
|
| 167 |
+
data.get("password", ""))
|
| 168 |
+
if patient is None:
|
| 169 |
+
return jsonify({"error": "Invalid username or password"}), 401
|
| 170 |
+
|
| 171 |
+
session["patient_id"] = patient["id"]
|
| 172 |
+
session["role"] = "patient"
|
| 173 |
+
return jsonify({"success": True, "patient": _pub_patient(patient)})
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@app.route("/auth/hospital/register", methods=["POST"])
|
| 177 |
+
def hospital_register():
|
| 178 |
+
data = request.get_json(force=True) or {}
|
| 179 |
+
username = data.get("username", "").strip()
|
| 180 |
+
password = data.get("password", "").strip()
|
| 181 |
+
hospital_name = data.get("hospital_name", "").strip()
|
| 182 |
+
location = data.get("location", "").strip()
|
| 183 |
+
research_conditions = data.get("research_conditions", [])
|
| 184 |
+
|
| 185 |
+
if not username or not password or not hospital_name:
|
| 186 |
+
return jsonify({"error": "Username, password and hospital name are required"}), 400
|
| 187 |
+
|
| 188 |
+
hospital = db.create_hospital(username, password, hospital_name,
|
| 189 |
+
location, research_conditions)
|
| 190 |
+
if hospital is None:
|
| 191 |
+
return jsonify({"error": "Username already taken"}), 409
|
| 192 |
+
|
| 193 |
+
session["hospital_id"] = hospital["id"]
|
| 194 |
+
session["role"] = "hospital"
|
| 195 |
+
return jsonify({"success": True, "hospital": _pub_hospital(hospital)})
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.route("/auth/hospital/login", methods=["POST"])
|
| 199 |
+
def hospital_login():
|
| 200 |
+
data = request.get_json(force=True) or {}
|
| 201 |
+
hospital = db.authenticate_hospital(data.get("username", ""),
|
| 202 |
+
data.get("password", ""))
|
| 203 |
+
if hospital is None:
|
| 204 |
+
return jsonify({"error": "Invalid username or password"}), 401
|
| 205 |
+
|
| 206 |
+
session["hospital_id"] = hospital["id"]
|
| 207 |
+
session["role"] = "hospital"
|
| 208 |
+
return jsonify({"success": True, "hospital": _pub_hospital(hospital)})
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@app.route("/auth/logout", methods=["POST"])
|
| 212 |
+
def logout():
|
| 213 |
+
session.clear()
|
| 214 |
+
return jsonify({"success": True})
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# Patient API
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
@app.route("/api/patient/profile", methods=["GET"])
|
| 222 |
+
def get_patient_profile():
|
| 223 |
+
err = _patient_required()
|
| 224 |
+
if err:
|
| 225 |
+
return err
|
| 226 |
+
patient = db.get_patient_by_id(session["patient_id"])
|
| 227 |
+
if not patient:
|
| 228 |
+
return jsonify({"error": "Not found"}), 404
|
| 229 |
+
return jsonify(_pub_patient(patient))
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@app.route("/api/patient/profile", methods=["POST"])
|
| 233 |
+
def update_patient_profile():
|
| 234 |
+
err = _patient_required()
|
| 235 |
+
if err:
|
| 236 |
+
return err
|
| 237 |
+
data = request.get_json(force=True) or {}
|
| 238 |
+
allowed = {"first_name", "last_name", "dob", "gender", "address",
|
| 239 |
+
"conditions", "medications", "open_to_trials"}
|
| 240 |
+
kwargs = {k: v for k, v in data.items() if k in allowed}
|
| 241 |
+
db.update_patient_profile(session["patient_id"], **kwargs)
|
| 242 |
+
return jsonify(_pub_patient(db.get_patient_by_id(session["patient_id"])))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@app.route("/api/patient/matches", methods=["GET"])
|
| 246 |
+
def patient_matches():
|
| 247 |
+
err = _patient_required()
|
| 248 |
+
if err:
|
| 249 |
+
return err
|
| 250 |
+
if not _ready():
|
| 251 |
+
return jsonify({"error": "System still loading. Please wait."}), 503
|
| 252 |
+
|
| 253 |
+
patient = db.get_patient_by_id(session["patient_id"])
|
| 254 |
+
conditions = patient.get("conditions", [])
|
| 255 |
+
address = patient.get("address", "")
|
| 256 |
+
|
| 257 |
+
# Calculate age from DOB
|
| 258 |
+
age = 50
|
| 259 |
+
dob = patient.get("dob", "")
|
| 260 |
+
if dob:
|
| 261 |
+
try:
|
| 262 |
+
birth = datetime.strptime(dob, "%Y-%m-%d")
|
| 263 |
+
age = int((datetime.now() - birth).days / 365.25)
|
| 264 |
+
except Exception:
|
| 265 |
+
pass
|
| 266 |
+
|
| 267 |
+
gender = patient.get("gender", "M") or "M"
|
| 268 |
+
|
| 269 |
+
if not conditions:
|
| 270 |
+
return jsonify({"results": [],
|
| 271 |
+
"message": "No conditions on your profile. "
|
| 272 |
+
"Update your profile first."})
|
| 273 |
+
|
| 274 |
+
matches = pipeline.match_patient(
|
| 275 |
+
conditions, age, gender, top_k=20,
|
| 276 |
+
patient_id=session["patient_id"], address=address,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# Annotate with saved interest status
|
| 280 |
+
interests = {i["trial_id"]: i["status"]
|
| 281 |
+
for i in db.get_patient_interests(session["patient_id"])}
|
| 282 |
+
for m in matches:
|
| 283 |
+
m["interest_status"] = interests.get(m["trial_id"])
|
| 284 |
+
|
| 285 |
+
return jsonify({"results": matches, "total": len(matches)})
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@app.route("/api/patient/interests", methods=["GET"])
|
| 289 |
+
def get_patient_interests():
|
| 290 |
+
err = _patient_required()
|
| 291 |
+
if err:
|
| 292 |
+
return err
|
| 293 |
+
return jsonify({"interests": db.get_patient_interests(session["patient_id"])})
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.route("/api/patient/interest", methods=["POST"])
|
| 297 |
+
def save_patient_interest():
|
| 298 |
+
err = _patient_required()
|
| 299 |
+
if err:
|
| 300 |
+
return err
|
| 301 |
+
data = request.get_json(force=True) or {}
|
| 302 |
+
trial_id = data.get("trial_id", "")
|
| 303 |
+
trial_title = data.get("trial_title", "")
|
| 304 |
+
match_score = float(data.get("match_score", 0))
|
| 305 |
+
if not trial_id:
|
| 306 |
+
return jsonify({"error": "trial_id required"}), 400
|
| 307 |
+
db.save_trial_interest(session["patient_id"], trial_id,
|
| 308 |
+
trial_title, match_score)
|
| 309 |
+
return jsonify({"success": True})
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@app.route("/api/patient/interest/<trial_id>", methods=["DELETE"])
|
| 313 |
+
def remove_patient_interest(trial_id):
|
| 314 |
+
err = _patient_required()
|
| 315 |
+
if err:
|
| 316 |
+
return err
|
| 317 |
+
db.withdraw_interest(session["patient_id"], trial_id)
|
| 318 |
+
return jsonify({"success": True})
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@app.route("/api/patient/connections", methods=["GET"])
|
| 322 |
+
def get_patient_connections():
|
| 323 |
+
err = _patient_required()
|
| 324 |
+
if err:
|
| 325 |
+
return err
|
| 326 |
+
return jsonify({"connections": db.get_patient_connections(session["patient_id"])})
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@app.route("/api/patient/connect", methods=["POST"])
|
| 330 |
+
def patient_connect():
|
| 331 |
+
err = _patient_required()
|
| 332 |
+
if err:
|
| 333 |
+
return err
|
| 334 |
+
data = request.get_json(force=True) or {}
|
| 335 |
+
hospital_id = data.get("hospital_id", "")
|
| 336 |
+
trial_id = data.get("trial_id", "")
|
| 337 |
+
trial_title = data.get("trial_title", "")
|
| 338 |
+
message = data.get("message", "")
|
| 339 |
+
if not hospital_id:
|
| 340 |
+
return jsonify({"error": "hospital_id required"}), 400
|
| 341 |
+
conn = db.create_connection(
|
| 342 |
+
session["patient_id"], hospital_id, trial_id, trial_title,
|
| 343 |
+
initiated_by="patient", message=message,
|
| 344 |
+
)
|
| 345 |
+
if conn is None:
|
| 346 |
+
return jsonify({"error": "A connection with this hospital for this trial already exists"}), 409
|
| 347 |
+
return jsonify({"success": True, "connection": conn})
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _hospital_name_tokens(name: str) -> frozenset:
|
| 351 |
+
"""Tokenise a hospital name for facility-name matching (same stopword set as pipeline)."""
|
| 352 |
+
import re as _re
|
| 353 |
+
_STOP = {"the","of","and","at","for","in","a","an","is","by",
|
| 354 |
+
"hospital","medical","center","centre","clinic","university",
|
| 355 |
+
"health","care","healthcare","system","institute","foundation",
|
| 356 |
+
"research","general","regional","national","community",
|
| 357 |
+
"services","department","division","college","school"}
|
| 358 |
+
words = _re.findall(r"[a-z]+", str(name).lower())
|
| 359 |
+
return frozenset(w for w in words if w not in _STOP and len(w) >= 3)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _facility_match_score(h_tokens: frozenset, facility_token_list: list) -> float:
|
| 363 |
+
"""
|
| 364 |
+
Return the best Jaccard score between hospital name tokens and any facility
|
| 365 |
+
name tokens for a given trial. Returns 0.0 if facility_token_list is empty.
|
| 366 |
+
"""
|
| 367 |
+
if not h_tokens or not facility_token_list:
|
| 368 |
+
return 0.0
|
| 369 |
+
best = 0.0
|
| 370 |
+
for fac_tokens in facility_token_list:
|
| 371 |
+
if not fac_tokens:
|
| 372 |
+
continue
|
| 373 |
+
inter = len(h_tokens & fac_tokens)
|
| 374 |
+
union = len(h_tokens | fac_tokens)
|
| 375 |
+
score = inter / union if union else 0.0
|
| 376 |
+
if score > best:
|
| 377 |
+
best = score
|
| 378 |
+
return best
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@app.route("/api/patient/hospitals-for-trial", methods=["GET"])
|
| 382 |
+
def hospitals_for_trial():
|
| 383 |
+
err = _patient_required()
|
| 384 |
+
if err:
|
| 385 |
+
return err
|
| 386 |
+
trial_id = request.args.get("trial_id", "")
|
| 387 |
+
if not trial_id:
|
| 388 |
+
return jsonify({"hospitals": []})
|
| 389 |
+
|
| 390 |
+
from pipeline import STATE_ABBREV
|
| 391 |
+
import re as _re
|
| 392 |
+
|
| 393 |
+
trial_fac_tokens = []
|
| 394 |
+
trial_states = set()
|
| 395 |
+
trial_conds = set()
|
| 396 |
+
if _ready():
|
| 397 |
+
trial_fac_tokens = pipeline.trial_facility_tokens.get(trial_id, [])
|
| 398 |
+
trial_states = pipeline.trial_us_states.get(trial_id, set())
|
| 399 |
+
if trial_id in pipeline.trial_profiles:
|
| 400 |
+
trial_conds = pipeline.trial_profiles[trial_id]["conditions"]
|
| 401 |
+
|
| 402 |
+
all_hospitals = db.get_all_hospitals()
|
| 403 |
+
result = []
|
| 404 |
+
for h in all_hospitals:
|
| 405 |
+
h_name = (h.get("hospital_name") or "").strip()
|
| 406 |
+
h_loc = (h.get("location") or "").strip()
|
| 407 |
+
|
| 408 |
+
# Extract hospital state from "City, ST" location string
|
| 409 |
+
m = _re.search(r",\s*([A-Z]{2})\s*$", h_loc)
|
| 410 |
+
h_state_full = STATE_ABBREV.get(m.group(1), "") if m else ""
|
| 411 |
+
|
| 412 |
+
# Tier 1: real facility-name match (Jaccard β₯ 0.25 is a generous but meaningful threshold)
|
| 413 |
+
h_tokens = _hospital_name_tokens(h_name)
|
| 414 |
+
name_score = _facility_match_score(h_tokens, trial_fac_tokens)
|
| 415 |
+
if name_score >= 0.25:
|
| 416 |
+
result.append({
|
| 417 |
+
"id": h["id"],
|
| 418 |
+
"hospital_name": h_name,
|
| 419 |
+
"location": h_loc,
|
| 420 |
+
"match_reason": "verified site on this trial",
|
| 421 |
+
"match_tier": 1,
|
| 422 |
+
})
|
| 423 |
+
continue
|
| 424 |
+
|
| 425 |
+
# Tier 2: hospital is in the same US state as a trial facility
|
| 426 |
+
if trial_states and h_state_full and h_state_full in trial_states:
|
| 427 |
+
result.append({
|
| 428 |
+
"id": h["id"],
|
| 429 |
+
"hospital_name": h_name,
|
| 430 |
+
"location": h_loc,
|
| 431 |
+
"match_reason": "in same state as a trial site",
|
| 432 |
+
"match_tier": 2,
|
| 433 |
+
})
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
# Tier 3: research_conditions overlap with trial conditions
|
| 437 |
+
rc_set = {r.lower() for r in (h.get("research_conditions") or [])}
|
| 438 |
+
if rc_set & trial_conds:
|
| 439 |
+
result.append({
|
| 440 |
+
"id": h["id"],
|
| 441 |
+
"hospital_name": h_name,
|
| 442 |
+
"location": h_loc,
|
| 443 |
+
"match_reason": "researches related conditions",
|
| 444 |
+
"match_tier": 3,
|
| 445 |
+
})
|
| 446 |
+
continue
|
| 447 |
+
|
| 448 |
+
# Tier 4: no facility data exists at all β show all registered hospitals
|
| 449 |
+
if not trial_fac_tokens and not trial_states:
|
| 450 |
+
result.append({
|
| 451 |
+
"id": h["id"],
|
| 452 |
+
"hospital_name": h_name,
|
| 453 |
+
"location": h_loc,
|
| 454 |
+
"match_reason": "",
|
| 455 |
+
"match_tier": 4,
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
result.sort(key=lambda x: x["match_tier"])
|
| 459 |
+
return jsonify({"hospitals": result})
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# ---------------------------------------------------------------------------
|
| 463 |
+
# Hospital API
|
| 464 |
+
# ---------------------------------------------------------------------------
|
| 465 |
+
|
| 466 |
+
@app.route("/api/hospital/profile", methods=["GET"])
|
| 467 |
+
def get_hospital_profile():
|
| 468 |
+
err = _hospital_required()
|
| 469 |
+
if err:
|
| 470 |
+
return err
|
| 471 |
+
hospital = db.get_hospital_by_id(session["hospital_id"])
|
| 472 |
+
if not hospital:
|
| 473 |
+
return jsonify({"error": "Not found"}), 404
|
| 474 |
+
return jsonify(_pub_hospital(hospital))
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
@app.route("/api/hospital/profile", methods=["POST"])
|
| 478 |
+
def update_hospital_profile():
|
| 479 |
+
err = _hospital_required()
|
| 480 |
+
if err:
|
| 481 |
+
return err
|
| 482 |
+
data = request.get_json(force=True) or {}
|
| 483 |
+
allowed = {"hospital_name", "location", "research_conditions"}
|
| 484 |
+
kwargs = {k: v for k, v in data.items() if k in allowed}
|
| 485 |
+
db.update_hospital_profile(session["hospital_id"], **kwargs)
|
| 486 |
+
return jsonify(_pub_hospital(db.get_hospital_by_id(session["hospital_id"])))
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@app.route("/api/hospital/trials", methods=["GET"])
|
| 490 |
+
def get_hospital_trials():
|
| 491 |
+
err = _hospital_required()
|
| 492 |
+
if err:
|
| 493 |
+
return err
|
| 494 |
+
if not _ready():
|
| 495 |
+
return jsonify({"trials": [], "message": "System still loading"}), 202
|
| 496 |
+
hospital = db.get_hospital_by_id(session["hospital_id"])
|
| 497 |
+
if not hospital:
|
| 498 |
+
return jsonify({"trials": []}), 404
|
| 499 |
+
trials = pipeline.trials_for_hospital(
|
| 500 |
+
hospital.get("hospital_name", ""),
|
| 501 |
+
hospital.get("location", ""),
|
| 502 |
+
hospital.get("research_conditions", []),
|
| 503 |
+
top_k=20,
|
| 504 |
+
)
|
| 505 |
+
return jsonify({"trials": trials, "total": len(trials)})
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
@app.route("/api/hospital/patients", methods=["GET"])
|
| 509 |
+
def get_hospital_patients():
|
| 510 |
+
err = _hospital_required()
|
| 511 |
+
if err:
|
| 512 |
+
return err
|
| 513 |
+
condition = request.args.get("condition", "").strip()
|
| 514 |
+
include_connected = request.args.get("include_connected", "0") == "1"
|
| 515 |
+
patients = db.get_open_patients_for_hospital(
|
| 516 |
+
session["hospital_id"], condition, include_connected=include_connected
|
| 517 |
+
)
|
| 518 |
+
return jsonify({"patients": patients})
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
@app.route("/api/hospital/connect", methods=["POST"])
|
| 522 |
+
def hospital_connect():
|
| 523 |
+
err = _hospital_required()
|
| 524 |
+
if err:
|
| 525 |
+
return err
|
| 526 |
+
data = request.get_json(force=True) or {}
|
| 527 |
+
patient_id = data.get("patient_id", "")
|
| 528 |
+
trial_id = data.get("trial_id", "")
|
| 529 |
+
trial_title = data.get("trial_title", "")
|
| 530 |
+
message = data.get("message", "")
|
| 531 |
+
if not patient_id:
|
| 532 |
+
return jsonify({"error": "patient_id required"}), 400
|
| 533 |
+
conn = db.create_connection(
|
| 534 |
+
patient_id, session["hospital_id"], trial_id, trial_title,
|
| 535 |
+
initiated_by="hospital", message=message,
|
| 536 |
+
)
|
| 537 |
+
if conn is None:
|
| 538 |
+
return jsonify({"error": "A connection with this patient already exists"}), 409
|
| 539 |
+
return jsonify({"success": True, "connection": conn})
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
@app.route("/api/hospital/connections", methods=["GET"])
|
| 543 |
+
def get_hospital_connections():
|
| 544 |
+
err = _hospital_required()
|
| 545 |
+
if err:
|
| 546 |
+
return err
|
| 547 |
+
return jsonify({"connections": db.get_hospital_connections(session["hospital_id"])})
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
@app.route("/api/hospital/connections/<cid>/status", methods=["PUT"])
|
| 551 |
+
def update_hospital_connection_status(cid):
|
| 552 |
+
err = _hospital_required()
|
| 553 |
+
if err:
|
| 554 |
+
return err
|
| 555 |
+
data = request.get_json(force=True) or {}
|
| 556 |
+
status = data.get("status", "")
|
| 557 |
+
if status not in ("pending", "accepted", "rejected", "completed"):
|
| 558 |
+
return jsonify({"error": "Invalid status"}), 400
|
| 559 |
+
db.update_connection_status(cid, status)
|
| 560 |
+
return jsonify({"success": True})
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# ---------------------------------------------------------------------------
|
| 564 |
+
# Connection Messages
|
| 565 |
+
# ---------------------------------------------------------------------------
|
| 566 |
+
|
| 567 |
+
@app.route("/api/patient/connections/<cid>/messages", methods=["GET"])
|
| 568 |
+
def get_patient_connection_messages(cid):
|
| 569 |
+
err = _patient_required()
|
| 570 |
+
if err:
|
| 571 |
+
return err
|
| 572 |
+
conn = db.get_connection(cid)
|
| 573 |
+
if not conn or conn["patient_id"] != session["patient_id"]:
|
| 574 |
+
return jsonify({"error": "Not found"}), 404
|
| 575 |
+
db.mark_messages_read(cid, "patient")
|
| 576 |
+
return jsonify({"messages": db.get_connection_messages(cid)})
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
@app.route("/api/patient/connections/<cid>/messages", methods=["POST"])
|
| 580 |
+
def post_patient_connection_message(cid):
|
| 581 |
+
err = _patient_required()
|
| 582 |
+
if err:
|
| 583 |
+
return err
|
| 584 |
+
conn = db.get_connection(cid)
|
| 585 |
+
if not conn or conn["patient_id"] != session["patient_id"]:
|
| 586 |
+
return jsonify({"error": "Not found"}), 404
|
| 587 |
+
body = (request.get_json(force=True) or {}).get("body", "").strip()
|
| 588 |
+
if not body:
|
| 589 |
+
return jsonify({"error": "Message body required"}), 400
|
| 590 |
+
msg = db.create_connection_message(cid, "patient", session["patient_id"], body)
|
| 591 |
+
return jsonify({"success": True, "message": msg})
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
@app.route("/api/hospital/connections/<cid>/messages", methods=["GET"])
|
| 595 |
+
def get_hospital_connection_messages(cid):
|
| 596 |
+
err = _hospital_required()
|
| 597 |
+
if err:
|
| 598 |
+
return err
|
| 599 |
+
conn = db.get_connection(cid)
|
| 600 |
+
if not conn or conn["hospital_id"] != session["hospital_id"]:
|
| 601 |
+
return jsonify({"error": "Not found"}), 404
|
| 602 |
+
db.mark_messages_read(cid, "hospital")
|
| 603 |
+
return jsonify({"messages": db.get_connection_messages(cid)})
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
@app.route("/api/hospital/connections/<cid>/messages", methods=["POST"])
|
| 607 |
+
def post_hospital_connection_message(cid):
|
| 608 |
+
err = _hospital_required()
|
| 609 |
+
if err:
|
| 610 |
+
return err
|
| 611 |
+
conn = db.get_connection(cid)
|
| 612 |
+
if not conn or conn["hospital_id"] != session["hospital_id"]:
|
| 613 |
+
return jsonify({"error": "Not found"}), 404
|
| 614 |
+
body = (request.get_json(force=True) or {}).get("body", "").strip()
|
| 615 |
+
if not body:
|
| 616 |
+
return jsonify({"error": "Message body required"}), 400
|
| 617 |
+
msg = db.create_connection_message(cid, "hospital", session["hospital_id"], body)
|
| 618 |
+
return jsonify({"success": True, "message": msg})
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
# ---------------------------------------------------------------------------
|
| 622 |
+
# Patient Documents
|
| 623 |
+
# ---------------------------------------------------------------------------
|
| 624 |
+
|
| 625 |
+
@app.route("/api/patient/documents", methods=["GET"])
|
| 626 |
+
def get_patient_documents():
|
| 627 |
+
err = _patient_required()
|
| 628 |
+
if err:
|
| 629 |
+
return err
|
| 630 |
+
patient = db.get_patient_by_id(session["patient_id"])
|
| 631 |
+
return jsonify({"documents": patient.get("documents", []) or []})
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
@app.route("/api/patient/documents", methods=["POST"])
|
| 635 |
+
def upload_patient_document():
|
| 636 |
+
err = _patient_required()
|
| 637 |
+
if err:
|
| 638 |
+
return err
|
| 639 |
+
if "file" not in request.files or not request.files["file"].filename:
|
| 640 |
+
return jsonify({"error": "No file selected"}), 400
|
| 641 |
+
f = request.files["file"]
|
| 642 |
+
ext = Path(f.filename).suffix.lower()
|
| 643 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 644 |
+
return jsonify({"error": f"Allowed types: {', '.join(sorted(ALLOWED_EXTENSIONS))}"}), 400
|
| 645 |
+
|
| 646 |
+
pid = session["patient_id"]
|
| 647 |
+
doc_dir = UPLOAD_DIR / pid
|
| 648 |
+
doc_dir.mkdir(parents=True, exist_ok=True)
|
| 649 |
+
|
| 650 |
+
doc_id = str(uuid.uuid4())
|
| 651 |
+
safe_name = secure_filename(f.filename)
|
| 652 |
+
save_path = doc_dir / f"{doc_id}_{safe_name}"
|
| 653 |
+
f.save(str(save_path))
|
| 654 |
+
|
| 655 |
+
doc_meta = {
|
| 656 |
+
"id": doc_id,
|
| 657 |
+
"filename": safe_name,
|
| 658 |
+
"path": str(save_path.relative_to(Path(__file__).parent)),
|
| 659 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 660 |
+
"mime_type": mimetypes.guess_type(f.filename)[0] or "application/octet-stream",
|
| 661 |
+
"size_bytes": save_path.stat().st_size,
|
| 662 |
+
}
|
| 663 |
+
patient = db.get_patient_by_id(pid)
|
| 664 |
+
docs = list(patient.get("documents", []) or [])
|
| 665 |
+
docs.append(doc_meta)
|
| 666 |
+
db.update_patient_profile(pid, documents=docs)
|
| 667 |
+
return jsonify({"success": True, "document": doc_meta})
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
@app.route("/api/patient/documents/<doc_id>", methods=["DELETE"])
|
| 671 |
+
def delete_patient_document(doc_id):
|
| 672 |
+
err = _patient_required()
|
| 673 |
+
if err:
|
| 674 |
+
return err
|
| 675 |
+
pid = session["patient_id"]
|
| 676 |
+
patient = db.get_patient_by_id(pid)
|
| 677 |
+
docs = list(patient.get("documents", []) or [])
|
| 678 |
+
target = next((d for d in docs if d["id"] == doc_id), None)
|
| 679 |
+
if not target:
|
| 680 |
+
return jsonify({"error": "Not found"}), 404
|
| 681 |
+
try:
|
| 682 |
+
fpath = Path(__file__).parent / target["path"]
|
| 683 |
+
if fpath.exists():
|
| 684 |
+
fpath.unlink()
|
| 685 |
+
except Exception:
|
| 686 |
+
pass
|
| 687 |
+
db.update_patient_profile(pid, documents=[d for d in docs if d["id"] != doc_id])
|
| 688 |
+
return jsonify({"success": True})
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
@app.route("/api/patient/documents/<doc_id>/download")
|
| 692 |
+
def download_patient_document(doc_id):
|
| 693 |
+
err = _patient_required()
|
| 694 |
+
if err:
|
| 695 |
+
return err
|
| 696 |
+
patient = db.get_patient_by_id(session["patient_id"])
|
| 697 |
+
docs = list(patient.get("documents", []) or [])
|
| 698 |
+
target = next((d for d in docs if d["id"] == doc_id), None)
|
| 699 |
+
if not target:
|
| 700 |
+
return jsonify({"error": "Not found"}), 404
|
| 701 |
+
fpath = Path(__file__).parent / target["path"]
|
| 702 |
+
if not fpath.exists():
|
| 703 |
+
return jsonify({"error": "File not found on disk"}), 404
|
| 704 |
+
return send_file(str(fpath), download_name=target["filename"], as_attachment=True)
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
# ---------------------------------------------------------------------------
|
| 708 |
+
# Inbox
|
| 709 |
+
# ---------------------------------------------------------------------------
|
| 710 |
+
|
| 711 |
+
@app.route("/api/hospital/inbox")
|
| 712 |
+
def get_hospital_inbox():
|
| 713 |
+
err = _hospital_required()
|
| 714 |
+
if err:
|
| 715 |
+
return err
|
| 716 |
+
return jsonify({"threads": db.get_hospital_inbox_threads(session["hospital_id"])})
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
@app.route("/api/patient/inbox")
|
| 720 |
+
def get_patient_inbox():
|
| 721 |
+
err = _patient_required()
|
| 722 |
+
if err:
|
| 723 |
+
return err
|
| 724 |
+
return jsonify({"threads": db.get_patient_inbox_threads(session["patient_id"])})
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
# ---------------------------------------------------------------------------
|
| 728 |
+
# Shared pipeline API
|
| 729 |
+
# ---------------------------------------------------------------------------
|
| 730 |
+
|
| 731 |
+
@app.route("/api/status")
|
| 732 |
+
def api_status():
|
| 733 |
+
if _boot_error:
|
| 734 |
+
return jsonify({"ready": False, "error": _boot_error}), 500
|
| 735 |
+
if not _ready():
|
| 736 |
+
return jsonify({"ready": False,
|
| 737 |
+
"message": "Loading data and training modelβ¦"}), 202
|
| 738 |
+
return jsonify({"ready": True, "stats": pipeline.stats})
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
@app.route("/api/conditions/autocomplete")
|
| 742 |
+
def api_conditions_autocomplete():
|
| 743 |
+
if not _ready():
|
| 744 |
+
return jsonify({"results": []})
|
| 745 |
+
q = request.args.get("q", "").strip()
|
| 746 |
+
if len(q) < 2:
|
| 747 |
+
return jsonify({"results": []})
|
| 748 |
+
return jsonify({"results": pipeline.condition_autocomplete(q, limit=15)})
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
# ---------------------------------------------------------------------------
|
| 752 |
+
# Main
|
| 753 |
+
# ---------------------------------------------------------------------------
|
| 754 |
+
if __name__ == "__main__":
|
| 755 |
+
port = int(os.environ.get("PORT", 5000))
|
| 756 |
+
print("=" * 60)
|
| 757 |
+
print(" Second Life β Clinical Trial Matching")
|
| 758 |
+
print(f" http://localhost:{port}")
|
| 759 |
+
print("=" * 60)
|
| 760 |
+
app.run(host="0.0.0.0", debug=False, port=port, use_reloader=False)
|
claude_handoff.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Claude Handoff
|
| 2 |
+
|
| 3 |
+
## Current App Shape
|
| 4 |
+
|
| 5 |
+
Multi-page Flask app. All files committed + local session changes applied.
|
| 6 |
+
|
| 7 |
+
Active runtime files:
|
| 8 |
+
|
| 9 |
+
- `app.py`: Flask routes and portal/API logic
|
| 10 |
+
- `pipeline.py`: data loading, feature engineering, model training, trial matching
|
| 11 |
+
- `database.py`: SQLite accounts, interests, connections
|
| 12 |
+
- `templates/landing.html`: login / registration
|
| 13 |
+
- `templates/patient.html`: patient portal
|
| 14 |
+
- `templates/hospital.html`: hospital portal
|
| 15 |
+
|
| 16 |
+
`templates/index.html` deleted.
|
| 17 |
+
|
| 18 |
+
## Status: All Fixes Complete
|
| 19 |
+
|
| 20 |
+
The following work is done and local (not yet committed to Git):
|
| 21 |
+
|
| 22 |
+
### From Previous Session (before rate limit)
|
| 23 |
+
|
| 24 |
+
1. Hospital suggestion tiered logic (Tier 1 facility-name Jaccard, Tier 2 same-state, Tier 3 condition overlap, Tier 4 fallback)
|
| 25 |
+
2. Duplicate connection prevention (schema UNIQUE + COALESCE index)
|
| 26 |
+
3. Hospital patient feed excludes already-connected patients
|
| 27 |
+
4. Demo patient seeded with `open_to_trials = 1`
|
| 28 |
+
5. Hospital profile editing tab (name, location, research conditions)
|
| 29 |
+
6. Patient-facing model disclaimer on trial results
|
| 30 |
+
7. Trial site info (lead site + location) surfaced in patient UI
|
| 31 |
+
8. Hospital registration collects `research_conditions`
|
| 32 |
+
|
| 33 |
+
### From Current Session
|
| 34 |
+
|
| 35 |
+
9. **Patient connect modal**: Tier 1 hospitals shown under "Verified Trial Sites" (green header); Tiers 2/3/4 shown under "Related Hospitals β not confirmed trial sites" (grey header). Two visually separated sections.
|
| 36 |
+
10. **hospital.html bug fix**: `btn-close-white` β `btn-close` on `bg-info` profile condition tags (white X was invisible on light-blue background).
|
| 37 |
+
11. **landing.html**: Enter key now submits login/register forms (all username + password inputs).
|
| 38 |
+
12. **patient.html**: System status banner auto-clears β polls `/api/status` every 5s until pipeline is ready, then stops.
|
| 39 |
+
|
| 40 |
+
## Decision Log
|
| 41 |
+
|
| 42 |
+
**Hospital matching mode: BROAD**
|
| 43 |
+
- Tier 1 = verified site (Jaccard name match β₯ 0.25)
|
| 44 |
+
- Tiers 2+3 = related hospitals (same state / condition overlap)
|
| 45 |
+
- Visual separation in modal so user sees which is which
|
| 46 |
+
- Reason: safer for demo β modal won't be empty; stronger matches still shown first
|
| 47 |
+
|
| 48 |
+
## Known Limitation (not a bug, by design)
|
| 49 |
+
|
| 50 |
+
Model trains on synthetic/rule-based labels. Disclaimer shown in UI. Real fix requires clinician-reviewed labels or historical screening decisions β out of scope for DSCI 5260.
|
| 51 |
+
|
| 52 |
+
## Demo Credentials
|
| 53 |
+
|
| 54 |
+
| Role | Username | Password |
|
| 55 |
+
|------|----------|----------|
|
| 56 |
+
| Patient | john_doe | pass123 |
|
| 57 |
+
| Hospital | mgh | mgh123 |
|
| 58 |
+
|
| 59 |
+
## Recommended Test Flow
|
| 60 |
+
|
| 61 |
+
See "What to Test" section returned at end of last Claude session.
|
| 62 |
+
|
| 63 |
+
## Git Note
|
| 64 |
+
|
| 65 |
+
Last GitHub push: `ff67a12` β all local work above is uncommitted.
|
database.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Second Life β SQLite database layer
|
| 3 |
+
Handles patient accounts, hospital accounts, trial interests, and connections.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import csv
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import sqlite3
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
BASE_DIR = Path(__file__).parent
|
| 15 |
+
DB_PATH = BASE_DIR / "secondlife.db"
|
| 16 |
+
PATIENT_DETAILS_PATH = BASE_DIR / "Final Patients Synthea Data" / "patients_details.csv"
|
| 17 |
+
PATIENT_CONDITIONS_PATH = BASE_DIR / "Final Patients Synthea Data" / "final_patients_conditions.csv"
|
| 18 |
+
PATIENT_MEDICATIONS_PATH = BASE_DIR / "Final Patients Synthea Data" / "patients_medications.csv"
|
| 19 |
+
DATASET_PATIENT_SEED_COUNT = 20
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Schema
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
_SCHEMA = """
|
| 25 |
+
CREATE TABLE IF NOT EXISTS patient_accounts (
|
| 26 |
+
id TEXT PRIMARY KEY,
|
| 27 |
+
username TEXT UNIQUE NOT NULL,
|
| 28 |
+
password_hash TEXT NOT NULL,
|
| 29 |
+
synthea_id TEXT,
|
| 30 |
+
first_name TEXT,
|
| 31 |
+
last_name TEXT,
|
| 32 |
+
dob TEXT,
|
| 33 |
+
gender TEXT,
|
| 34 |
+
address TEXT,
|
| 35 |
+
conditions TEXT DEFAULT '[]',
|
| 36 |
+
medications TEXT DEFAULT '[]',
|
| 37 |
+
documents TEXT DEFAULT '[]',
|
| 38 |
+
open_to_trials INTEGER DEFAULT 0,
|
| 39 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
CREATE TABLE IF NOT EXISTS hospital_accounts (
|
| 43 |
+
id TEXT PRIMARY KEY,
|
| 44 |
+
username TEXT UNIQUE NOT NULL,
|
| 45 |
+
password_hash TEXT NOT NULL,
|
| 46 |
+
hospital_name TEXT NOT NULL,
|
| 47 |
+
location TEXT,
|
| 48 |
+
research_conditions TEXT DEFAULT '[]',
|
| 49 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
CREATE TABLE IF NOT EXISTS patient_trial_interests (
|
| 53 |
+
id TEXT PRIMARY KEY,
|
| 54 |
+
patient_id TEXT NOT NULL,
|
| 55 |
+
trial_id TEXT NOT NULL,
|
| 56 |
+
trial_title TEXT,
|
| 57 |
+
match_score REAL,
|
| 58 |
+
status TEXT DEFAULT 'interested',
|
| 59 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
| 60 |
+
UNIQUE(patient_id, trial_id)
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
CREATE TABLE IF NOT EXISTS connections (
|
| 64 |
+
id TEXT PRIMARY KEY,
|
| 65 |
+
patient_id TEXT NOT NULL,
|
| 66 |
+
hospital_id TEXT NOT NULL,
|
| 67 |
+
trial_id TEXT,
|
| 68 |
+
trial_title TEXT,
|
| 69 |
+
initiated_by TEXT DEFAULT 'patient',
|
| 70 |
+
status TEXT DEFAULT 'pending',
|
| 71 |
+
message TEXT,
|
| 72 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
| 73 |
+
UNIQUE(patient_id, hospital_id, trial_id)
|
| 74 |
+
);
|
| 75 |
+
|
| 76 |
+
CREATE TABLE IF NOT EXISTS connection_messages (
|
| 77 |
+
id TEXT PRIMARY KEY,
|
| 78 |
+
connection_id TEXT NOT NULL,
|
| 79 |
+
sender_role TEXT NOT NULL,
|
| 80 |
+
sender_id TEXT NOT NULL,
|
| 81 |
+
body TEXT NOT NULL,
|
| 82 |
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
| 83 |
+
is_read INTEGER DEFAULT 0
|
| 84 |
+
);
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
# Run after schema to add constraint to pre-existing DBs that lack it
|
| 88 |
+
_POST_SCHEMA = """
|
| 89 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_conn_unique
|
| 90 |
+
ON connections(patient_id, hospital_id, COALESCE(trial_id, ''));
|
| 91 |
+
CREATE INDEX IF NOT EXISTS idx_msgs_conn
|
| 92 |
+
ON connection_messages(connection_id, created_at);
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Helpers
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
def _conn():
|
| 100 |
+
c = sqlite3.connect(DB_PATH)
|
| 101 |
+
c.row_factory = sqlite3.Row
|
| 102 |
+
return c
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _hash(password: str) -> str:
|
| 106 |
+
return hashlib.sha256(password.encode()).hexdigest()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _row_to_dict(row) -> dict:
|
| 110 |
+
if row is None:
|
| 111 |
+
return None
|
| 112 |
+
d = dict(row)
|
| 113 |
+
for k in ("conditions", "medications", "documents", "research_conditions"):
|
| 114 |
+
if k in d and isinstance(d[k], str):
|
| 115 |
+
try:
|
| 116 |
+
d[k] = json.loads(d[k])
|
| 117 |
+
except Exception:
|
| 118 |
+
d[k] = []
|
| 119 |
+
return d
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Init
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def init_db():
|
| 127 |
+
with _conn() as c:
|
| 128 |
+
c.executescript(_SCHEMA)
|
| 129 |
+
try:
|
| 130 |
+
c.executescript(_POST_SCHEMA)
|
| 131 |
+
except Exception:
|
| 132 |
+
pass # index may already exist with different definition
|
| 133 |
+
_seed_demo_data()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _seed_demo_data():
|
| 137 |
+
"""Seed demo hospital and patient accounts if not already present."""
|
| 138 |
+
with _conn() as c:
|
| 139 |
+
# Demo hospitals
|
| 140 |
+
hospitals = [
|
| 141 |
+
("mgh", "mgh123", "Massachusetts General Hospital", "Boston, MA",
|
| 142 |
+
["hypertension", "diabetes", "cardiac arrest", "stroke"]),
|
| 143 |
+
("cleveland", "clinic123", "Cleveland Clinic", "Cleveland, OH",
|
| 144 |
+
["coronary artery disease", "myocardial infarction", "heart failure"]),
|
| 145 |
+
("jhopkins", "johns123", "Johns Hopkins Hospital", "Baltimore, MD",
|
| 146 |
+
["cancer", "non-small cell lung cancer", "malignant tumor of colon"]),
|
| 147 |
+
]
|
| 148 |
+
for uname, pwd, name, loc, conds in hospitals:
|
| 149 |
+
existing = c.execute(
|
| 150 |
+
"SELECT id FROM hospital_accounts WHERE username=?", (uname,)
|
| 151 |
+
).fetchone()
|
| 152 |
+
if not existing:
|
| 153 |
+
c.execute(
|
| 154 |
+
"INSERT INTO hospital_accounts VALUES (?,?,?,?,?,?,?)",
|
| 155 |
+
(str(uuid.uuid4()), uname, _hash(pwd), name, loc,
|
| 156 |
+
json.dumps(conds), datetime.now().isoformat())
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Demo patient accounts
|
| 160 |
+
patients = [
|
| 161 |
+
("john_doe", "pass123", None, "John", "Doe", "1965-03-12", "M",
|
| 162 |
+
"123 Main St Boston MA 02101 US",
|
| 163 |
+
["hypertension", "diabetes", "myocardial infarction"],
|
| 164 |
+
["metformin", "lisinopril"], 1),
|
| 165 |
+
("jane_smith", "pass123", None, "Jane", "Smith", "1978-07-22", "F",
|
| 166 |
+
"456 Oak Ave Cambridge MA 02139 US",
|
| 167 |
+
["asthma", "atopic dermatitis", "seasonal allergic rhinitis"],
|
| 168 |
+
["albuterol", "fluticasone"], 1),
|
| 169 |
+
("bob_jones", "pass123", None, "Robert", "Jones", "1955-11-05", "M",
|
| 170 |
+
"789 Pine Rd Cleveland OH 44106 US",
|
| 171 |
+
["coronary artery disease", "hypertension", "chronic pain"],
|
| 172 |
+
["atorvastatin", "aspirin"], 1),
|
| 173 |
+
("alice_brown","pass123", None, "Alice", "Brown", "1972-06-14", "F",
|
| 174 |
+
"101 Elm St Baltimore MD 21201 US",
|
| 175 |
+
["non-small cell lung cancer", "stroke"],
|
| 176 |
+
["erlotinib"], 1),
|
| 177 |
+
("david_chen", "pass123", None, "David", "Chen", "1948-09-30", "M",
|
| 178 |
+
"202 Oak Blvd Chicago IL 60601 US",
|
| 179 |
+
["diabetes", "osteoporosis", "coronary artery disease"],
|
| 180 |
+
["insulin", "alendronate"], 1),
|
| 181 |
+
]
|
| 182 |
+
for uname, pwd, syn_id, fn, ln, dob, gend, addr, conds, meds, open_trials in patients:
|
| 183 |
+
existing = c.execute(
|
| 184 |
+
"SELECT id FROM patient_accounts WHERE username=?", (uname,)
|
| 185 |
+
).fetchone()
|
| 186 |
+
if not existing:
|
| 187 |
+
c.execute(
|
| 188 |
+
"""INSERT INTO patient_accounts
|
| 189 |
+
(id,username,password_hash,synthea_id,first_name,last_name,
|
| 190 |
+
dob,gender,address,conditions,medications,open_to_trials,created_at)
|
| 191 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 192 |
+
(str(uuid.uuid4()), uname, _hash(pwd), syn_id, fn, ln,
|
| 193 |
+
dob, gend, addr,
|
| 194 |
+
json.dumps(conds), json.dumps(meds), open_trials,
|
| 195 |
+
datetime.now().isoformat())
|
| 196 |
+
)
|
| 197 |
+
else:
|
| 198 |
+
# Ensure existing demo patients are open_to_trials=1
|
| 199 |
+
c.execute(
|
| 200 |
+
"UPDATE patient_accounts SET open_to_trials=1 WHERE username=?", (uname,)
|
| 201 |
+
)
|
| 202 |
+
_seed_dataset_patients(c, max_patients=DATASET_PATIENT_SEED_COUNT)
|
| 203 |
+
c.commit()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _seed_dataset_patients(c, max_patients: int = 20):
|
| 207 |
+
"""
|
| 208 |
+
Seed a small set of real Synthea patients into the portal so the hospital
|
| 209 |
+
dashboards are not limited to hand-made demo accounts.
|
| 210 |
+
"""
|
| 211 |
+
if not (PATIENT_DETAILS_PATH.exists() and PATIENT_CONDITIONS_PATH.exists()
|
| 212 |
+
and PATIENT_MEDICATIONS_PATH.exists()):
|
| 213 |
+
return
|
| 214 |
+
|
| 215 |
+
existing_dataset_count = c.execute(
|
| 216 |
+
"SELECT COUNT(*) FROM patient_accounts WHERE synthea_id IS NOT NULL"
|
| 217 |
+
).fetchone()[0]
|
| 218 |
+
if existing_dataset_count >= max_patients:
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
existing_synthea_ids = {
|
| 222 |
+
row[0] for row in c.execute(
|
| 223 |
+
"SELECT synthea_id FROM patient_accounts WHERE synthea_id IS NOT NULL"
|
| 224 |
+
).fetchall()
|
| 225 |
+
}
|
| 226 |
+
needed = max_patients - existing_dataset_count
|
| 227 |
+
selected = {}
|
| 228 |
+
|
| 229 |
+
with PATIENT_DETAILS_PATH.open("r", encoding="utf-8", newline="") as fh:
|
| 230 |
+
reader = csv.DictReader(fh)
|
| 231 |
+
for row in reader:
|
| 232 |
+
if len(selected) >= needed:
|
| 233 |
+
break
|
| 234 |
+
sid = (row.get("Patient_ID") or "").strip()
|
| 235 |
+
if not sid or sid in existing_synthea_ids:
|
| 236 |
+
continue
|
| 237 |
+
if (row.get("Death_Date") or "").strip():
|
| 238 |
+
continue
|
| 239 |
+
|
| 240 |
+
first_name = (row.get("First_Name") or "").strip() or "Patient"
|
| 241 |
+
last_name = (row.get("Last_Name") or "").strip() or sid[:6]
|
| 242 |
+
dob = _normalize_dataset_date(row.get("Birth_Date", ""))
|
| 243 |
+
gender = (row.get("Gender") or "").strip()
|
| 244 |
+
address = (row.get("Address") or "").strip()
|
| 245 |
+
username = f"synthea_{sid[:8].lower()}"
|
| 246 |
+
selected[sid] = {
|
| 247 |
+
"username": username,
|
| 248 |
+
"first_name": first_name,
|
| 249 |
+
"last_name": last_name,
|
| 250 |
+
"dob": dob,
|
| 251 |
+
"gender": gender,
|
| 252 |
+
"address": address,
|
| 253 |
+
"conditions": [],
|
| 254 |
+
"medications": [],
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
if not selected:
|
| 258 |
+
return
|
| 259 |
+
|
| 260 |
+
with PATIENT_CONDITIONS_PATH.open("r", encoding="utf-8", newline="") as fh:
|
| 261 |
+
reader = csv.DictReader(fh)
|
| 262 |
+
for row in reader:
|
| 263 |
+
sid = (row.get("Patient_ID") or "").strip()
|
| 264 |
+
if sid not in selected:
|
| 265 |
+
continue
|
| 266 |
+
cond = (row.get("Condition_Name") or "").strip().lower()
|
| 267 |
+
if cond and cond not in selected[sid]["conditions"]:
|
| 268 |
+
selected[sid]["conditions"].append(cond)
|
| 269 |
+
if len(selected[sid]["conditions"]) >= 8:
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
with PATIENT_MEDICATIONS_PATH.open("r", encoding="utf-8", newline="") as fh:
|
| 273 |
+
reader = csv.DictReader(fh)
|
| 274 |
+
for row in reader:
|
| 275 |
+
sid = (row.get("Patient_ID") or "").strip()
|
| 276 |
+
if sid not in selected:
|
| 277 |
+
continue
|
| 278 |
+
med = (row.get("Medication_Name") or "").strip()
|
| 279 |
+
if med and med not in selected[sid]["medications"]:
|
| 280 |
+
selected[sid]["medications"].append(med)
|
| 281 |
+
if len(selected[sid]["medications"]) >= 6:
|
| 282 |
+
continue
|
| 283 |
+
|
| 284 |
+
for sid, patient in selected.items():
|
| 285 |
+
c.execute(
|
| 286 |
+
"""INSERT OR IGNORE INTO patient_accounts
|
| 287 |
+
(id,username,password_hash,synthea_id,first_name,last_name,
|
| 288 |
+
dob,gender,address,conditions,medications,open_to_trials,created_at)
|
| 289 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 290 |
+
(
|
| 291 |
+
str(uuid.uuid4()),
|
| 292 |
+
patient["username"],
|
| 293 |
+
_hash("pass123"),
|
| 294 |
+
sid,
|
| 295 |
+
patient["first_name"],
|
| 296 |
+
patient["last_name"],
|
| 297 |
+
patient["dob"],
|
| 298 |
+
patient["gender"],
|
| 299 |
+
patient["address"],
|
| 300 |
+
json.dumps(patient["conditions"]),
|
| 301 |
+
json.dumps(patient["medications"]),
|
| 302 |
+
1,
|
| 303 |
+
datetime.now().isoformat(),
|
| 304 |
+
),
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _normalize_dataset_date(raw: str) -> str:
|
| 309 |
+
raw = (raw or "").strip()
|
| 310 |
+
if not raw:
|
| 311 |
+
return ""
|
| 312 |
+
try:
|
| 313 |
+
return datetime.strptime(raw, "%d-%m-%Y").strftime("%Y-%m-%d")
|
| 314 |
+
except ValueError:
|
| 315 |
+
return raw
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
# Patient CRUD
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
|
| 322 |
+
def create_patient(username, password, first_name="", last_name="",
|
| 323 |
+
dob="", gender="", address="", synthea_id=None) -> dict | None:
|
| 324 |
+
pid = str(uuid.uuid4())
|
| 325 |
+
try:
|
| 326 |
+
with _conn() as c:
|
| 327 |
+
c.execute(
|
| 328 |
+
"""INSERT INTO patient_accounts
|
| 329 |
+
(id,username,password_hash,synthea_id,first_name,last_name,
|
| 330 |
+
dob,gender,address,created_at)
|
| 331 |
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
| 332 |
+
(pid, username, _hash(password), synthea_id,
|
| 333 |
+
first_name, last_name, dob, gender, address,
|
| 334 |
+
datetime.now().isoformat())
|
| 335 |
+
)
|
| 336 |
+
c.commit()
|
| 337 |
+
return get_patient_by_id(pid)
|
| 338 |
+
except sqlite3.IntegrityError:
|
| 339 |
+
return None # username taken
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def get_patient_by_id(pid: str) -> dict | None:
|
| 343 |
+
with _conn() as c:
|
| 344 |
+
return _row_to_dict(c.execute(
|
| 345 |
+
"SELECT * FROM patient_accounts WHERE id=?", (pid,)
|
| 346 |
+
).fetchone())
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def get_patient_by_username(username: str) -> dict | None:
|
| 350 |
+
with _conn() as c:
|
| 351 |
+
return _row_to_dict(c.execute(
|
| 352 |
+
"SELECT * FROM patient_accounts WHERE username=?", (username,)
|
| 353 |
+
).fetchone())
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def authenticate_patient(username: str, password: str) -> dict | None:
|
| 357 |
+
p = get_patient_by_username(username)
|
| 358 |
+
if p and p["password_hash"] == _hash(password):
|
| 359 |
+
return p
|
| 360 |
+
return None
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def update_patient_profile(pid: str, **kwargs):
|
| 364 |
+
allowed = {"first_name", "last_name", "dob", "gender", "address",
|
| 365 |
+
"conditions", "medications", "documents", "open_to_trials", "synthea_id"}
|
| 366 |
+
fields, vals = [], []
|
| 367 |
+
for k, v in kwargs.items():
|
| 368 |
+
if k in allowed:
|
| 369 |
+
fields.append(f"{k}=?")
|
| 370 |
+
vals.append(json.dumps(v) if isinstance(v, (list, dict)) else v)
|
| 371 |
+
if not fields:
|
| 372 |
+
return
|
| 373 |
+
vals.append(pid)
|
| 374 |
+
with _conn() as c:
|
| 375 |
+
c.execute(f"UPDATE patient_accounts SET {', '.join(fields)} WHERE id=?", vals)
|
| 376 |
+
c.commit()
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ---------------------------------------------------------------------------
|
| 380 |
+
# Hospital CRUD
|
| 381 |
+
# ---------------------------------------------------------------------------
|
| 382 |
+
|
| 383 |
+
def create_hospital(username, password, hospital_name, location="",
|
| 384 |
+
research_conditions=None) -> dict | None:
|
| 385 |
+
hid = str(uuid.uuid4())
|
| 386 |
+
try:
|
| 387 |
+
with _conn() as c:
|
| 388 |
+
c.execute(
|
| 389 |
+
"""INSERT INTO hospital_accounts
|
| 390 |
+
(id,username,password_hash,hospital_name,location,research_conditions,created_at)
|
| 391 |
+
VALUES (?,?,?,?,?,?,?)""",
|
| 392 |
+
(hid, username, _hash(password), hospital_name, location,
|
| 393 |
+
json.dumps(research_conditions or []),
|
| 394 |
+
datetime.now().isoformat())
|
| 395 |
+
)
|
| 396 |
+
c.commit()
|
| 397 |
+
return get_hospital_by_id(hid)
|
| 398 |
+
except sqlite3.IntegrityError:
|
| 399 |
+
return None
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def get_hospital_by_id(hid: str) -> dict | None:
|
| 403 |
+
with _conn() as c:
|
| 404 |
+
return _row_to_dict(c.execute(
|
| 405 |
+
"SELECT * FROM hospital_accounts WHERE id=?", (hid,)
|
| 406 |
+
).fetchone())
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def get_hospital_by_username(username: str) -> dict | None:
|
| 410 |
+
with _conn() as c:
|
| 411 |
+
return _row_to_dict(c.execute(
|
| 412 |
+
"SELECT * FROM hospital_accounts WHERE username=?", (username,)
|
| 413 |
+
).fetchone())
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def authenticate_hospital(username: str, password: str) -> dict | None:
|
| 417 |
+
h = get_hospital_by_username(username)
|
| 418 |
+
if h and h["password_hash"] == _hash(password):
|
| 419 |
+
return h
|
| 420 |
+
return None
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def get_all_hospitals() -> list:
|
| 424 |
+
with _conn() as c:
|
| 425 |
+
rows = c.execute(
|
| 426 |
+
"SELECT id, hospital_name, location, research_conditions FROM hospital_accounts"
|
| 427 |
+
).fetchall()
|
| 428 |
+
return [_row_to_dict(r) for r in rows]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def update_hospital_profile(hid: str, **kwargs):
|
| 432 |
+
allowed = {"hospital_name", "location", "research_conditions"}
|
| 433 |
+
fields, vals = [], []
|
| 434 |
+
for k, v in kwargs.items():
|
| 435 |
+
if k in allowed:
|
| 436 |
+
fields.append(f"{k}=?")
|
| 437 |
+
vals.append(json.dumps(v) if isinstance(v, (list, dict)) else v)
|
| 438 |
+
if not fields:
|
| 439 |
+
return
|
| 440 |
+
vals.append(hid)
|
| 441 |
+
with _conn() as c:
|
| 442 |
+
c.execute(f"UPDATE hospital_accounts SET {', '.join(fields)} WHERE id=?", vals)
|
| 443 |
+
c.commit()
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
# Trial Interests
|
| 448 |
+
# ---------------------------------------------------------------------------
|
| 449 |
+
|
| 450 |
+
def save_trial_interest(patient_id, trial_id, trial_title, match_score):
|
| 451 |
+
iid = str(uuid.uuid4())
|
| 452 |
+
with _conn() as c:
|
| 453 |
+
c.execute(
|
| 454 |
+
"""INSERT OR REPLACE INTO patient_trial_interests
|
| 455 |
+
(id,patient_id,trial_id,trial_title,match_score,status,created_at)
|
| 456 |
+
VALUES (?,?,?,?,?,'interested',?)""",
|
| 457 |
+
(iid, patient_id, trial_id, trial_title[:200],
|
| 458 |
+
match_score, datetime.now().isoformat())
|
| 459 |
+
)
|
| 460 |
+
c.commit()
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def get_patient_interests(patient_id: str) -> list:
|
| 464 |
+
with _conn() as c:
|
| 465 |
+
rows = c.execute(
|
| 466 |
+
"""SELECT * FROM patient_trial_interests
|
| 467 |
+
WHERE patient_id=? ORDER BY match_score DESC""",
|
| 468 |
+
(patient_id,)
|
| 469 |
+
).fetchall()
|
| 470 |
+
return [dict(r) for r in rows]
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def withdraw_interest(patient_id, trial_id):
|
| 474 |
+
with _conn() as c:
|
| 475 |
+
c.execute(
|
| 476 |
+
"""UPDATE patient_trial_interests SET status='withdrawn'
|
| 477 |
+
WHERE patient_id=? AND trial_id=?""",
|
| 478 |
+
(patient_id, trial_id)
|
| 479 |
+
)
|
| 480 |
+
c.commit()
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ---------------------------------------------------------------------------
|
| 484 |
+
# Connections
|
| 485 |
+
# ---------------------------------------------------------------------------
|
| 486 |
+
|
| 487 |
+
def connection_exists(patient_id: str, hospital_id: str, trial_id: str) -> bool:
|
| 488 |
+
with _conn() as c:
|
| 489 |
+
row = c.execute(
|
| 490 |
+
"""SELECT id FROM connections
|
| 491 |
+
WHERE patient_id=? AND hospital_id=? AND COALESCE(trial_id,'')=COALESCE(?,'')""",
|
| 492 |
+
(patient_id, hospital_id, trial_id)
|
| 493 |
+
).fetchone()
|
| 494 |
+
return row is not None
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def create_connection(patient_id, hospital_id, trial_id, trial_title,
|
| 498 |
+
initiated_by="patient", message="") -> dict | None:
|
| 499 |
+
"""Returns None if a connection for this (patient, hospital, trial) already exists."""
|
| 500 |
+
if connection_exists(patient_id, hospital_id, trial_id):
|
| 501 |
+
return None
|
| 502 |
+
cid = str(uuid.uuid4())
|
| 503 |
+
try:
|
| 504 |
+
with _conn() as c:
|
| 505 |
+
c.execute(
|
| 506 |
+
"""INSERT INTO connections
|
| 507 |
+
(id,patient_id,hospital_id,trial_id,trial_title,
|
| 508 |
+
initiated_by,status,message,created_at)
|
| 509 |
+
VALUES (?,?,?,?,?,?, 'pending',?,?)""",
|
| 510 |
+
(cid, patient_id, hospital_id, trial_id, trial_title[:200] if trial_title else "",
|
| 511 |
+
initiated_by, message, datetime.now().isoformat())
|
| 512 |
+
)
|
| 513 |
+
c.commit()
|
| 514 |
+
except sqlite3.IntegrityError:
|
| 515 |
+
return None # race condition β already inserted
|
| 516 |
+
return get_connection(cid)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def get_connection(cid: str) -> dict | None:
|
| 520 |
+
with _conn() as c:
|
| 521 |
+
return _row_to_dict(c.execute(
|
| 522 |
+
"SELECT * FROM connections WHERE id=?", (cid,)
|
| 523 |
+
).fetchone())
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def get_patient_connections(patient_id: str) -> list:
|
| 527 |
+
with _conn() as c:
|
| 528 |
+
rows = c.execute(
|
| 529 |
+
"""SELECT c.*, h.hospital_name, h.location as hospital_location
|
| 530 |
+
FROM connections c
|
| 531 |
+
JOIN hospital_accounts h ON c.hospital_id=h.id
|
| 532 |
+
WHERE c.patient_id=? ORDER BY c.created_at DESC""",
|
| 533 |
+
(patient_id,)
|
| 534 |
+
).fetchall()
|
| 535 |
+
return [dict(r) for r in rows]
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def get_hospital_connections(hospital_id: str) -> list:
|
| 539 |
+
with _conn() as c:
|
| 540 |
+
rows = c.execute(
|
| 541 |
+
"""SELECT c.*,
|
| 542 |
+
p.first_name, p.last_name, p.gender, p.dob,
|
| 543 |
+
p.conditions, p.address
|
| 544 |
+
FROM connections c
|
| 545 |
+
JOIN patient_accounts p ON c.patient_id=p.id
|
| 546 |
+
WHERE c.hospital_id=? ORDER BY c.created_at DESC""",
|
| 547 |
+
(hospital_id,)
|
| 548 |
+
).fetchall()
|
| 549 |
+
result = []
|
| 550 |
+
for r in rows:
|
| 551 |
+
d = dict(r)
|
| 552 |
+
for k in ("conditions",):
|
| 553 |
+
if isinstance(d.get(k), str):
|
| 554 |
+
try:
|
| 555 |
+
d[k] = json.loads(d[k])
|
| 556 |
+
except Exception:
|
| 557 |
+
d[k] = []
|
| 558 |
+
result.append(d)
|
| 559 |
+
return result
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def update_connection_status(cid: str, status: str):
|
| 563 |
+
with _conn() as c:
|
| 564 |
+
c.execute("UPDATE connections SET status=? WHERE id=?", (status, cid))
|
| 565 |
+
c.commit()
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def get_open_patients_for_hospital(hospital_id: str, condition_filter: str = "",
|
| 569 |
+
include_connected: bool = False) -> list:
|
| 570 |
+
"""
|
| 571 |
+
Returns patients who are open_to_trials=1, optionally filtered by condition.
|
| 572 |
+
When include_connected=False (default), excludes patients already connected to this hospital.
|
| 573 |
+
"""
|
| 574 |
+
with _conn() as c:
|
| 575 |
+
if include_connected:
|
| 576 |
+
rows = c.execute(
|
| 577 |
+
"""SELECT id, first_name, last_name, gender, dob, address, conditions
|
| 578 |
+
FROM patient_accounts WHERE open_to_trials=1"""
|
| 579 |
+
).fetchall()
|
| 580 |
+
else:
|
| 581 |
+
rows = c.execute(
|
| 582 |
+
"""SELECT id, first_name, last_name, gender, dob, address, conditions
|
| 583 |
+
FROM patient_accounts
|
| 584 |
+
WHERE open_to_trials=1
|
| 585 |
+
AND id NOT IN (
|
| 586 |
+
SELECT DISTINCT patient_id FROM connections
|
| 587 |
+
WHERE hospital_id=?
|
| 588 |
+
)""",
|
| 589 |
+
(hospital_id,)
|
| 590 |
+
).fetchall()
|
| 591 |
+
|
| 592 |
+
result = []
|
| 593 |
+
cf = condition_filter.lower().strip()
|
| 594 |
+
for r in rows:
|
| 595 |
+
d = dict(r)
|
| 596 |
+
try:
|
| 597 |
+
d["conditions"] = json.loads(d["conditions"]) if isinstance(d["conditions"], str) else d["conditions"]
|
| 598 |
+
except Exception:
|
| 599 |
+
d["conditions"] = []
|
| 600 |
+
if cf and not any(cf in cond.lower() for cond in d["conditions"]):
|
| 601 |
+
continue
|
| 602 |
+
result.append(d)
|
| 603 |
+
return result
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
# ---------------------------------------------------------------------------
|
| 607 |
+
# Connection Messages
|
| 608 |
+
# ---------------------------------------------------------------------------
|
| 609 |
+
|
| 610 |
+
def get_connection_messages(connection_id: str) -> list:
|
| 611 |
+
with _conn() as c:
|
| 612 |
+
rows = c.execute(
|
| 613 |
+
"""SELECT * FROM connection_messages
|
| 614 |
+
WHERE connection_id=? ORDER BY created_at ASC""",
|
| 615 |
+
(connection_id,)
|
| 616 |
+
).fetchall()
|
| 617 |
+
return [dict(r) for r in rows]
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def create_connection_message(connection_id: str, sender_role: str,
|
| 621 |
+
sender_id: str, body: str) -> dict:
|
| 622 |
+
mid = str(uuid.uuid4())
|
| 623 |
+
with _conn() as c:
|
| 624 |
+
c.execute(
|
| 625 |
+
"""INSERT INTO connection_messages
|
| 626 |
+
(id, connection_id, sender_role, sender_id, body, created_at)
|
| 627 |
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
| 628 |
+
(mid, connection_id, sender_role, sender_id,
|
| 629 |
+
body[:2000], datetime.now().isoformat())
|
| 630 |
+
)
|
| 631 |
+
c.commit()
|
| 632 |
+
with _conn() as c:
|
| 633 |
+
row = c.execute(
|
| 634 |
+
"SELECT * FROM connection_messages WHERE id=?", (mid,)
|
| 635 |
+
).fetchone()
|
| 636 |
+
return dict(row) if row else {}
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def mark_messages_read(connection_id: str, reader_role: str):
|
| 640 |
+
with _conn() as c:
|
| 641 |
+
c.execute(
|
| 642 |
+
"""UPDATE connection_messages SET is_read=1
|
| 643 |
+
WHERE connection_id=? AND sender_role != ?""",
|
| 644 |
+
(connection_id, reader_role)
|
| 645 |
+
)
|
| 646 |
+
c.commit()
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def unread_count(connection_id: str, reader_role: str) -> int:
|
| 650 |
+
with _conn() as c:
|
| 651 |
+
row = c.execute(
|
| 652 |
+
"""SELECT COUNT(*) AS cnt FROM connection_messages
|
| 653 |
+
WHERE connection_id=? AND sender_role != ? AND is_read=0""",
|
| 654 |
+
(connection_id, reader_role)
|
| 655 |
+
).fetchone()
|
| 656 |
+
return row["cnt"] if row else 0
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def get_hospital_inbox_threads(hospital_id: str) -> list:
|
| 660 |
+
"""All connection threads for a hospital, ordered by most recent activity."""
|
| 661 |
+
with _conn() as c:
|
| 662 |
+
rows = c.execute(
|
| 663 |
+
"""SELECT
|
| 664 |
+
c.id, c.patient_id, c.trial_title, c.status, c.created_at,
|
| 665 |
+
p.first_name, p.last_name,
|
| 666 |
+
(SELECT body FROM connection_messages
|
| 667 |
+
WHERE connection_id=c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
| 668 |
+
(SELECT created_at FROM connection_messages
|
| 669 |
+
WHERE connection_id=c.id ORDER BY created_at DESC LIMIT 1) AS last_message_at,
|
| 670 |
+
(SELECT COUNT(*) FROM connection_messages
|
| 671 |
+
WHERE connection_id=c.id AND sender_role='patient' AND is_read=0) AS unread_count
|
| 672 |
+
FROM connections c
|
| 673 |
+
JOIN patient_accounts p ON c.patient_id=p.id
|
| 674 |
+
WHERE c.hospital_id=?""",
|
| 675 |
+
(hospital_id,)
|
| 676 |
+
).fetchall()
|
| 677 |
+
threads = [dict(r) for r in rows]
|
| 678 |
+
threads.sort(key=lambda t: t.get("last_message_at") or t["created_at"], reverse=True)
|
| 679 |
+
return threads
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def get_patient_inbox_threads(patient_id: str) -> list:
|
| 683 |
+
"""All connection threads for a patient, ordered by most recent activity."""
|
| 684 |
+
with _conn() as c:
|
| 685 |
+
rows = c.execute(
|
| 686 |
+
"""SELECT
|
| 687 |
+
c.id, c.hospital_id, c.trial_title, c.status, c.created_at,
|
| 688 |
+
h.hospital_name,
|
| 689 |
+
(SELECT body FROM connection_messages
|
| 690 |
+
WHERE connection_id=c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
| 691 |
+
(SELECT created_at FROM connection_messages
|
| 692 |
+
WHERE connection_id=c.id ORDER BY created_at DESC LIMIT 1) AS last_message_at,
|
| 693 |
+
(SELECT COUNT(*) FROM connection_messages
|
| 694 |
+
WHERE connection_id=c.id AND sender_role='hospital' AND is_read=0) AS unread_count
|
| 695 |
+
FROM connections c
|
| 696 |
+
JOIN hospital_accounts h ON c.hospital_id=h.id
|
| 697 |
+
WHERE c.patient_id=?""",
|
| 698 |
+
(patient_id,)
|
| 699 |
+
).fetchall()
|
| 700 |
+
threads = [dict(r) for r in rows]
|
| 701 |
+
threads.sort(key=lambda t: t.get("last_message_at") or t["created_at"], reverse=True)
|
| 702 |
+
return threads
|
hf_space_bootstrap.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space startup helper.
|
| 2 |
+
|
| 3 |
+
The Flask app expects two local dataset folders. In the Space, those folders are
|
| 4 |
+
downloaded from a separate Hugging Face Dataset repo before importing app.py,
|
| 5 |
+
because app.py starts the pipeline boot thread at import time.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import zipfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from huggingface_hub import hf_hub_download
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 18 |
+
DATASET_REPO = os.environ.get("HF_DATASET_REPO", "MrNoOne07/second-life-data")
|
| 19 |
+
|
| 20 |
+
DATASET_ZIPS = [
|
| 21 |
+
("Final Clinical Trails Data.zip", "Final Clinical Trails Data"),
|
| 22 |
+
("Final Patients Synthea Data.zip", "Final Patients Synthea Data"),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _ensure_dataset(zip_name: str, folder_name: str) -> None:
|
| 27 |
+
folder_path = BASE_DIR / folder_name
|
| 28 |
+
if folder_path.exists() and any(folder_path.iterdir()):
|
| 29 |
+
print(f"[bootstrap] Found {folder_name}.")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
print(f"[bootstrap] Downloading {zip_name} from {DATASET_REPO}...")
|
| 33 |
+
zip_path = hf_hub_download(
|
| 34 |
+
repo_id=DATASET_REPO,
|
| 35 |
+
filename=zip_name,
|
| 36 |
+
repo_type="dataset",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
print(f"[bootstrap] Extracting {zip_name}...")
|
| 40 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 41 |
+
zf.extractall(BASE_DIR)
|
| 42 |
+
|
| 43 |
+
if not folder_path.exists():
|
| 44 |
+
raise RuntimeError(f"Expected extracted folder not found: {folder_path}")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main() -> None:
|
| 48 |
+
for zip_name, folder_name in DATASET_ZIPS:
|
| 49 |
+
_ensure_dataset(zip_name, folder_name)
|
| 50 |
+
|
| 51 |
+
from app import app
|
| 52 |
+
|
| 53 |
+
port = int(os.environ.get("PORT", 7860))
|
| 54 |
+
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|
llm.md
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Second Life β Project Reference (LLM Context Document)
|
| 2 |
+
DSCI 5260 | Group 7 | Last updated: 2026-04-25 (post Session 6 β inbox, messaging, trial dashboard, document upload, dataset seeding)
|
| 3 |
+
|
| 4 |
+
## Architecture Overview
|
| 5 |
+
|
| 6 |
+
Flask web app (port 5000) with two authenticated portals:
|
| 7 |
+
- **Patient Portal** (/patient): Register/login, update medical profile, get AI trial matches, connect with hospitals
|
| 8 |
+
- **Hospital Portal** (/hospital): Login, browse opt-in patients, search by condition, manage connections, edit profile
|
| 9 |
+
|
| 10 |
+
### Key Files
|
| 11 |
+
- `pipeline.py` β Core ML pipeline (data loading, feature engineering, model training, matching)
|
| 12 |
+
- `app.py` β Flask server: session auth, patient API, hospital API, pipeline API
|
| 13 |
+
- `database.py` β SQLite layer: 5 tables, auth, connections, trial interests, messages
|
| 14 |
+
- `templates/landing.html` β Login/register landing page
|
| 15 |
+
- `templates/patient.html` β Patient SPA (profile, trials, connections, inbox)
|
| 16 |
+
- `templates/hospital.html` β Hospital SPA (patients, search, my trials, inbox, connections, profile)
|
| 17 |
+
- `llm.md` β This file
|
| 18 |
+
|
| 19 |
+
## Running the System
|
| 20 |
+
|
| 21 |
+
```powershell
|
| 22 |
+
cd "E:\DSCI 5260\Project\PT"
|
| 23 |
+
python app.py
|
| 24 |
+
# open http://localhost:5000
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
On first run (no model_cache.pkl): loads all data files (~2-3 min), trains RF model, saves cache.
|
| 28 |
+
On subsequent runs: loads cached model immediately.
|
| 29 |
+
|
| 30 |
+
Delete `model_cache.pkl` to force retrain (required after pipeline feature changes).
|
| 31 |
+
|
| 32 |
+
## Demo Credentials
|
| 33 |
+
|
| 34 |
+
All hand-made patients have `open_to_trials=1` and password `pass123`.
|
| 35 |
+
|
| 36 |
+
| Username | Password | Name | Conditions | Location |
|
| 37 |
+
|----------|----------|------|-----------|----------|
|
| 38 |
+
| john_doe | pass123 | John Doe | hypertension, diabetes, MI | Boston, MA |
|
| 39 |
+
| jane_smith | pass123 | Jane Smith | asthma, atopic dermatitis, allergic rhinitis | Cambridge, MA |
|
| 40 |
+
| bob_jones | pass123 | Robert Jones | CAD, hypertension, chronic pain | Cleveland, OH |
|
| 41 |
+
| alice_brown | pass123 | Alice Brown | non-small cell lung cancer, stroke | Baltimore, MD |
|
| 42 |
+
| david_chen | pass123 | David Chen | diabetes, osteoporosis, CAD | Chicago, IL |
|
| 43 |
+
|
| 44 |
+
| Username | Password | Hospital | Location |
|
| 45 |
+
|----------|----------|---------|----------|
|
| 46 |
+
| mgh | mgh123 | Massachusetts General Hospital | Boston, MA |
|
| 47 |
+
| cleveland | clinic123 | Cleveland Clinic | Cleveland, OH |
|
| 48 |
+
| jhopkins | johns123 | Johns Hopkins Hospital | Baltimore, MD |
|
| 49 |
+
|
| 50 |
+
### Dataset-Backed Patients (Synthea)
|
| 51 |
+
20 real Synthea patients are auto-seeded on first run from `Final Patients Synthea Data/`. Username format: `synthea_<first 8 chars of Patient_ID>`, password `pass123`, all `open_to_trials=1`. Total DB patients: 25 (5 hand-made + 20 Synthea).
|
| 52 |
+
|
| 53 |
+
List all accounts: `SELECT username, first_name, last_name FROM patient_accounts ORDER BY username;`
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Data Sources
|
| 58 |
+
|
| 59 |
+
### Patient Side (Synthea synthetic data)
|
| 60 |
+
- `Final Patients Synthea Data/final_patients_conditions.csv` β ~967K rows, **265,893 patients**, 106 conditions overlapping with trials. Columns: Patient_ID, Condition_Name, Condition_End_Date
|
| 61 |
+
- `Final Patients Synthea Data/patients_details.csv` β Demographics. Columns: Patient_ID, First_Name, Last_Name, Birth_Date (DD-MM-YYYY), Gender, Race, Ethnicity, Address
|
| 62 |
+
- `Final Patients Synthea Data/patients_medications.csv` β **213,182 patients with med data**. Columns: Patient_ID, Medication_Name, Medication_End_Date
|
| 63 |
+
- `Final Patients Synthea Data/patients_observations.csv` β **23,231 patients with lab data**. Columns: Patient_ID, Observation_Name
|
| 64 |
+
|
| 65 |
+
### Trial Side (ClinicalTrials.gov / AACT)
|
| 66 |
+
- `Final Clinical Trails Data/trail_conditions.csv` β ~1M rows, **571,379 total trials**, **34,074 with matched condition profiles**. Columns: Trial_ID, Condition_Name_Lower
|
| 67 |
+
- `Final Clinical Trails Data/trail_eligibilities.csv` β Columns: Trial_ID, Gender (leading space β stripped), Minimum_Age, Maximum_Age, Eligibility_Criteria
|
| 68 |
+
- `Final Clinical Trails Data/trail_studies.csv` β **65,292 recruiting trials**. Columns: Trial_ID, Brief_Title, Overall_Status, Phase, Start_Date, Enrollment
|
| 69 |
+
- `Final Clinical Trails Data/trail_facilities.csv` β **189,274 trials with US state geo data**. Columns: Trial_ID, Facility_Name, Facility_City, Facility_State (full names), Facility_Country
|
| 70 |
+
- `Final Clinical Trails Data/trail_brief_summaries.csv` β Columns: Trial_ID, Brief_Summary
|
| 71 |
+
- `Final Clinical Trails Data/trail_interventions.csv` β **196,865 trials with drug intervention data**. Columns: Trial_ID, Intervention_Type, Intervention_Name
|
| 72 |
+
- `Final Clinical Trails Data/trail_countries.csv` β Not used for geo scoring (superseded by facility-level state data)
|
| 73 |
+
- `Final Clinical Trails Data/trail_keywords.csv` β Columns: Trial_ID, Keyword_Name_Lower
|
| 74 |
+
|
| 75 |
+
### MIMIC-IV Demo (code-level validation only, not in UI)
|
| 76 |
+
- `mimic-iv-clinical-database-demo-2.2/hosp/patients.csv.gz`
|
| 77 |
+
- `mimic-iv-clinical-database-demo-2.2/hosp/diagnoses_icd.csv.gz`
|
| 78 |
+
- `mimic-iv-clinical-database-demo-2.2/hosp/d_icd_diagnoses.csv.gz`
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## SQLite Database (secondlife.db)
|
| 83 |
+
|
| 84 |
+
### Tables
|
| 85 |
+
```sql
|
| 86 |
+
patient_accounts(id, username, password_hash, synthea_id, first_name, last_name,
|
| 87 |
+
dob, gender, address, conditions TEXT DEFAULT '[]',
|
| 88 |
+
medications TEXT DEFAULT '[]', documents TEXT DEFAULT '[]',
|
| 89 |
+
open_to_trials INTEGER DEFAULT 0, created_at)
|
| 90 |
+
|
| 91 |
+
hospital_accounts(id, username, password_hash, hospital_name, location,
|
| 92 |
+
research_conditions TEXT DEFAULT '[]', created_at)
|
| 93 |
+
|
| 94 |
+
patient_trial_interests(id, patient_id, trial_id, trial_title, match_score,
|
| 95 |
+
status DEFAULT 'interested', created_at,
|
| 96 |
+
UNIQUE(patient_id, trial_id))
|
| 97 |
+
|
| 98 |
+
connections(id, patient_id, hospital_id, trial_id, trial_title,
|
| 99 |
+
initiated_by DEFAULT 'patient', status DEFAULT 'pending',
|
| 100 |
+
message, created_at,
|
| 101 |
+
UNIQUE(patient_id, hospital_id, trial_id))
|
| 102 |
+
-- Post-schema index handles NULL trial_id:
|
| 103 |
+
-- CREATE UNIQUE INDEX idx_conn_unique ON connections(patient_id, hospital_id, COALESCE(trial_id, ''))
|
| 104 |
+
|
| 105 |
+
connection_messages(id, connection_id, sender_role TEXT, -- 'patient' or 'hospital'
|
| 106 |
+
sender_id TEXT, body TEXT,
|
| 107 |
+
created_at, is_read INTEGER DEFAULT 0)
|
| 108 |
+
-- Index: idx_msgs_conn ON connection_messages(connection_id, created_at)
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
JSON fields (conditions, medications, documents, research_conditions) are stored as TEXT and parsed via `_row_to_dict()`.
|
| 112 |
+
|
| 113 |
+
### Key database.py Functions
|
| 114 |
+
|
| 115 |
+
| Function | Purpose |
|
| 116 |
+
|---------|---------|
|
| 117 |
+
| `get_open_patients_for_hospital(hid, condition_filter, include_connected)` | Returns open_to_trials=1 patients; when `include_connected=False` excludes already-connected patients |
|
| 118 |
+
| `get_connection_messages(connection_id)` | All messages for a connection, ASC order |
|
| 119 |
+
| `create_connection_message(connection_id, sender_role, sender_id, body)` | Insert message, returns dict |
|
| 120 |
+
| `mark_messages_read(connection_id, reader_role)` | Mark all messages from the other role as read |
|
| 121 |
+
| `unread_count(connection_id, reader_role)` | Count of unread messages from the other role |
|
| 122 |
+
| `get_hospital_inbox_threads(hospital_id)` | All threads with last_message, last_message_at, unread_count; sorted by activity |
|
| 123 |
+
| `get_patient_inbox_threads(patient_id)` | Same for patient side |
|
| 124 |
+
| `_seed_dataset_patients(c, max_patients=20)` | Seeds Synthea patients from CSV on first run |
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Feature Engineering (17 features in FEATURE_COLS)
|
| 129 |
+
|
| 130 |
+
| Feature | Description |
|
| 131 |
+
|---------|-------------|
|
| 132 |
+
| condition_overlap | Raw count of shared conditions |
|
| 133 |
+
| jaccard_similarity | overlap / union |
|
| 134 |
+
| overlap_ratio_trial | overlap / len(trial_conditions) |
|
| 135 |
+
| overlap_ratio_patient | overlap / len(patient_conditions) |
|
| 136 |
+
| condition_rarity_score | mean(1/log2(n_trials_per_cond+2)), normalised 0-1 |
|
| 137 |
+
| trial_specificity | 1 / trial_condition_count |
|
| 138 |
+
| condition_burden | total_patient_conds / 10 |
|
| 139 |
+
| active_ratio | active_conds / total_conds |
|
| 140 |
+
| resolved_ratio | resolved_conds / total_conds |
|
| 141 |
+
| age_distance | normalised distance outside age range (0 if within) |
|
| 142 |
+
| age_centered | position within age range (-1 to +1) |
|
| 143 |
+
| age_compatibility | 1.0 in range, decays over 30-year gap |
|
| 144 |
+
| gender_compatibility | 1.0 match/all, 0.1 mismatch |
|
| 145 |
+
| **geo_feasibility** | **State-level: 1.0 same state, 0.75 other US state, 0.5 no data** |
|
| 146 |
+
| **med_compatibility** | **Keyword overlap: patient meds vs trial drug interventions** |
|
| 147 |
+
| **lab_availability** | **Patient observation/lab type coverage (0-1, normalised by 20)** |
|
| 148 |
+
| data_completeness | fraction of key fields present |
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Flask API Routes
|
| 153 |
+
|
| 154 |
+
### Auth (no login required)
|
| 155 |
+
- `POST /auth/patient/register` β {success, patient} or {error}
|
| 156 |
+
- `POST /auth/patient/login` β {success, patient} or {error}
|
| 157 |
+
- `POST /auth/hospital/register` β {success, hospital} or {error}
|
| 158 |
+
- `POST /auth/hospital/login` β {success, hospital} or {error}
|
| 159 |
+
- `POST /auth/logout` β {success}
|
| 160 |
+
|
| 161 |
+
### Patient API (requires patient session)
|
| 162 |
+
- `GET /api/patient/profile` β patient dict (no password_hash)
|
| 163 |
+
- `POST /api/patient/profile` β updated patient dict; allowed fields: first_name, last_name, dob, gender, address, conditions, medications, open_to_trials
|
| 164 |
+
- `GET /api/patient/matches` β {results: [...trials], total}
|
| 165 |
+
- `GET /api/patient/interests` β {interests: [...]}
|
| 166 |
+
- `POST /api/patient/interest` β {success}; body: {trial_id, trial_title, match_score}
|
| 167 |
+
- `DELETE /api/patient/interest/<trial_id>` β {success}
|
| 168 |
+
- `GET /api/patient/connections` β {connections: [...]} joined with hospital_name
|
| 169 |
+
- `POST /api/patient/connect` β {success, connection} or 409 if duplicate; body: {hospital_id, trial_id, trial_title, message}
|
| 170 |
+
- `GET /api/patient/hospitals-for-trial?trial_id=NCT...` β {hospitals: [...]} tiered matching (see below)
|
| 171 |
+
- `GET /api/patient/connections/<cid>/messages` β {messages: [...]}; marks hospital messages read
|
| 172 |
+
- `POST /api/patient/connections/<cid>/messages` β {success, message}; body: {body}
|
| 173 |
+
- `GET /api/patient/inbox` β {threads: [...]} each with last_message, last_message_at, unread_count, hospital_name
|
| 174 |
+
- `GET /api/patient/documents` β {documents: [...]}
|
| 175 |
+
- `POST /api/patient/documents` β {success, document}; multipart/form-data file upload (max 10 MB, .pdf/.docx/.doc/.txt/.png/.jpg/.jpeg)
|
| 176 |
+
- `DELETE /api/patient/documents/<doc_id>` β {success}; removes file from disk and DB
|
| 177 |
+
- `GET /api/patient/documents/<doc_id>/download` β file download (as_attachment)
|
| 178 |
+
|
| 179 |
+
### Hospital API (requires hospital session)
|
| 180 |
+
- `GET /api/hospital/profile` β hospital dict (no password_hash)
|
| 181 |
+
- `POST /api/hospital/profile` β updated hospital dict; allowed fields: hospital_name, location, research_conditions
|
| 182 |
+
- `GET /api/hospital/patients?condition=&include_connected=` β {patients: [...]} open_to_trials=1; `include_connected=true` to include already-connected patients (used by Search tab)
|
| 183 |
+
- `POST /api/hospital/connect` β {success, connection} or 409 if duplicate; body: {patient_id, trial_id, trial_title, message}
|
| 184 |
+
- `GET /api/hospital/connections` β {connections: [...]} joined with patient fields
|
| 185 |
+
- `PUT /api/hospital/connections/<cid>/status` β {success}; body: {status: pending|accepted|rejected|completed}
|
| 186 |
+
- `GET /api/hospital/connections/<cid>/messages` β {messages: [...]}; marks patient messages read
|
| 187 |
+
- `POST /api/hospital/connections/<cid>/messages` β {success, message}; body: {body}
|
| 188 |
+
- `GET /api/hospital/inbox` β {threads: [...]} each with last_message, last_message_at, unread_count, first_name, last_name
|
| 189 |
+
- `GET /api/hospital/trials` β {trials: [...]} active trials matched to hospital profile (see below)
|
| 190 |
+
|
| 191 |
+
### Shared
|
| 192 |
+
- `GET /api/status` β {ready, stats} or {ready: false, message}
|
| 193 |
+
- `GET /api/conditions/autocomplete?q=...` β {results: [...]}
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## Hospital Trial Dashboard (`/api/hospital/trials`)
|
| 198 |
+
|
| 199 |
+
`pipeline.trials_for_hospital(hospital_name, location, research_conditions, top_k=20)` β reverse of patient matching: given a hospital's profile, find active clinical trials it is most relevant to.
|
| 200 |
+
|
| 201 |
+
| Tier | Match condition | `match_reason` field |
|
| 202 |
+
|------|----------------|----------------------|
|
| 203 |
+
| 1 | Jaccard(hospital name tokens, trial facility name tokens) β₯ 0.25 | "name matched to trial site" |
|
| 204 |
+
| 2 | Hospital state matches a US trial facility state | "in same state as trial site" |
|
| 205 |
+
| 3 | Hospital `research_conditions` overlaps trial conditions | "researches related conditions" |
|
| 206 |
+
|
| 207 |
+
Active-only filter (`is_active` check) applied at every tier. Returns list of dicts:
|
| 208 |
+
`trial_id, title, phase, status, summary, location, facility_name, n_sites, match_tier, match_reason`
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Tiered Hospital Matching (`/api/patient/hospitals-for-trial`)
|
| 213 |
+
|
| 214 |
+
For each trial, hospitals in the DB are scored and returned in tier order (Tier 1 first):
|
| 215 |
+
|
| 216 |
+
| Tier | Match condition | `match_reason` field | UI label |
|
| 217 |
+
|------|----------------|----------------------|----------|
|
| 218 |
+
| 1 | Jaccard(hospital name tokens, any trial facility name tokens) β₯ 0.25 | "verified site on this trial" | Green β Verified Trial Sites |
|
| 219 |
+
| 2 | Hospital state (from "City, ST" location) matches a trial US facility state | "in same state as a trial site" | Grey β Related Hospitals |
|
| 220 |
+
| 3 | Hospital `research_conditions` overlaps trial conditions | "researches related conditions" | Grey β Related Hospitals |
|
| 221 |
+
| 4 | Fallback β trial has no facility/state data at all | "" | Grey β Related Hospitals |
|
| 222 |
+
|
| 223 |
+
The patient connect modal groups Tier 1 hospitals under a green "VERIFIED TRIAL SITES" header and Tiers 2-4 under a grey "RELATED HOSPITALS β not confirmed trial sites" header.
|
| 224 |
+
|
| 225 |
+
**Pipeline lookups used:**
|
| 226 |
+
- `pipeline.trial_facility_tokens[trial_id]` β list of frozensets of significant words from facility names
|
| 227 |
+
- `pipeline.trial_us_states[trial_id]` β set of US state full names (e.g. {"Massachusetts"})
|
| 228 |
+
- `pipeline.trial_profiles[trial_id]["conditions"]` β set of condition strings
|
| 229 |
+
|
| 230 |
+
**Stopword set for facility name tokenisation** (same in pipeline.py and app.py):
|
| 231 |
+
hospital, medical, center, centre, clinic, university, health, care, healthcare, system, institute, foundation, research, general, regional, national, community, services, department, division, college, school, the, of, and, at, for, in, a, an, is, by
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
## Trial Match Result Fields
|
| 236 |
+
|
| 237 |
+
Each item in `/api/patient/matches` results:
|
| 238 |
+
- trial_id, title, phase, status, min_age, max_age, sex, enrollment, start_date
|
| 239 |
+
- eligibility_probability (0-100, calibrated RF probability Γ 100)
|
| 240 |
+
- match_score (0-100, rule-based: overlap_ratio weighted)
|
| 241 |
+
- combined_score (0-100, 0.6 Γ eligibility + 0.4 Γ match_score)
|
| 242 |
+
- age_compatibility, gender_compatibility, geo_feasibility, med_compatibility (all 0-100)
|
| 243 |
+
- condition_rarity_score (0-1)
|
| 244 |
+
- overlap_conditions (list of conditions shared with patient)
|
| 245 |
+
- trial_conditions (all trial conditions)
|
| 246 |
+
- criteria (eligibility criteria text, truncated 500 chars)
|
| 247 |
+
- summary (brief summary, truncated 400 chars)
|
| 248 |
+
- **facility_name** (lead US facility name, or "" if not available)
|
| 249 |
+
- location (lead US facility city/state/country string)
|
| 250 |
+
- n_sites (total facility count for this trial)
|
| 251 |
+
- interest_status (null | 'interested' | 'withdrawn', from patient_trial_interests)
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
## Data Privacy Model
|
| 256 |
+
|
| 257 |
+
1. Hospitals see only patients with open_to_trials=1 (name, age, gender, conditions)
|
| 258 |
+
2. Full details accessible only after patient-initiated connection
|
| 259 |
+
3. Hospital cannot contact a patient unless patient is open to trials
|
| 260 |
+
4. Connection record: patient_id, hospital_id, trial_id, initiated_by, status, message
|
| 261 |
+
5. Hospital can also initiate connections with opt-in patients from the hospital portal
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## XSS Prevention
|
| 266 |
+
|
| 267 |
+
All user-controlled strings use DOM API (never innerHTML for user data):
|
| 268 |
+
```javascript
|
| 269 |
+
function escH(s) { // text content in innerHTML contexts
|
| 270 |
+
const d = document.createElement('div');
|
| 271 |
+
d.appendChild(document.createTextNode(String(s||'')));
|
| 272 |
+
return d.innerHTML;
|
| 273 |
+
}
|
| 274 |
+
function escA(s) { // HTML attribute values
|
| 275 |
+
return String(s||'').replace(/&/g,'&').replace(/"/g,'"')
|
| 276 |
+
.replace(/</g,'<').replace(/>/g,'>');
|
| 277 |
+
}
|
| 278 |
+
```
|
| 279 |
+
Event listeners use addEventListener only. Tags and cards built via createElement + textContent.
|
| 280 |
+
|
| 281 |
+
---
|
| 282 |
+
|
| 283 |
+
## ML Model
|
| 284 |
+
|
| 285 |
+
- Random Forest (n_estimators=200, max_depth=12, class_weight="balanced")
|
| 286 |
+
- CalibratedClassifierCV (isotonic, cv=3) for probability calibration
|
| 287 |
+
- GroupShuffleSplit (patient-level, 80/20, no leakage) for train/test split
|
| 288 |
+
- GroupKFold (5-fold, patient-level) for cross-validation
|
| 289 |
+
- Training sample: 3000 patients Γ 30 trials each + random negatives
|
| 290 |
+
- Cache: `model_cache.pkl` (delete to force retrain)
|
| 291 |
+
|
| 292 |
+
### Actual Metrics (from verified live run, 2026-04-25)
|
| 293 |
+
|
| 294 |
+
| Metric | Value |
|
| 295 |
+
|--------|-------|
|
| 296 |
+
| Accuracy | 85.15% |
|
| 297 |
+
| AUC-ROC | 0.5976 |
|
| 298 |
+
| CV AUC (5-fold) | 0.5992 Β± 0.0052 |
|
| 299 |
+
| F1 | 0.9168 |
|
| 300 |
+
| Precision | 0.8518 |
|
| 301 |
+
| Recall | 0.9925 |
|
| 302 |
+
| Brier score | 0.1263 |
|
| 303 |
+
| Avg precision | 0.8540 |
|
| 304 |
+
| Train size | 90,994 pairs |
|
| 305 |
+
| Test size | 22,681 pairs |
|
| 306 |
+
| Positive label rate | 82.1% |
|
| 307 |
+
|
| 308 |
+
> **Note on AUC:** The 82.1% positive rate in pseudo-labels (weighted 6-feature labelling threshold at 0.5) makes the classification task easy to solve trivially β high accuracy/recall but lower AUC. To improve AUC, the pseudo-label threshold should be raised (e.g. 0.6) or positive/negative sampling balanced more aggressively.
|
| 309 |
+
|
| 310 |
+
### Feature Importance (Random Forest, ranked)
|
| 311 |
+
|
| 312 |
+
| Rank | Feature | Importance |
|
| 313 |
+
|------|---------|-----------|
|
| 314 |
+
| 1 | age_distance | 0.2040 |
|
| 315 |
+
| 2 | age_compatibility | 0.1691 |
|
| 316 |
+
| 3 | gender_compatibility | 0.1288 |
|
| 317 |
+
| 4 | age_centered | 0.0791 |
|
| 318 |
+
| 5 | jaccard_similarity | 0.0751 |
|
| 319 |
+
| 6 | condition_rarity_score | 0.0621 |
|
| 320 |
+
| 7 | overlap_ratio_trial | 0.0519 |
|
| 321 |
+
| 8 | overlap_ratio_patient | 0.0512 |
|
| 322 |
+
| 9 | condition_overlap | 0.0396 |
|
| 323 |
+
| 10 | condition_burden | 0.0274 |
|
| 324 |
+
| 11 | resolved_ratio | 0.0263 |
|
| 325 |
+
| 12 | active_ratio | 0.0258 |
|
| 326 |
+
| 13 | lab_availability | 0.0208 |
|
| 327 |
+
| 14 | geo_feasibility | 0.0158 |
|
| 328 |
+
| 15 | med_compatibility | 0.0144 |
|
| 329 |
+
| 16 | trial_specificity | 0.0087 |
|
| 330 |
+
| 17 | data_completeness | 0.0000 |
|
| 331 |
+
|
| 332 |
+
---
|
| 333 |
+
|
| 334 |
+
## MIMIC-IV Validation (code-level only, not in UI)
|
| 335 |
+
|
| 336 |
+
- 100 demo patients, ~90% match rate after 3-tier ICD β condition mapping
|
| 337 |
+
- Call: `pipeline.validate_mimic()` β list of {subject_id, mapped_conditions, n_matches, top_match}
|
| 338 |
+
- 3-tier mapping: exact β substring containment β word-overlap β₯ 75%
|
| 339 |
+
- Not exposed via any Flask route
|
| 340 |
+
|
| 341 |
+
---
|
| 342 |
+
|
| 343 |
+
## All Bug Fixes by Session
|
| 344 |
+
|
| 345 |
+
### Session 3 Fixes (2026-04-25) β Two-portal foundation
|
| 346 |
+
|
| 347 |
+
#### Fix 1 β patient_id not passed to match_patient() (CRITICAL)
|
| 348 |
+
**Before:** `pipeline.match_patient(conditions, age, gender, top_k=20)`
|
| 349 |
+
**After:** `pipeline.match_patient(conditions, age, gender, top_k=20, patient_id=session["patient_id"], address=address)`
|
| 350 |
+
|
| 351 |
+
Without this, patient-specific medication keywords and lab scores defaulted to empty / 0.3 for all users β med_compatibility and lab_availability were effectively constants.
|
| 352 |
+
|
| 353 |
+
#### Fix 2 β Pseudo-label used only 3 features (HIGH)
|
| 354 |
+
**Before:** AND gate on age/gender/condition β geo/med/lab had near-zero training influence.
|
| 355 |
+
**After:** 6-feature weighted score with 15% random noise:
|
| 356 |
+
```python
|
| 357 |
+
score = (
|
| 358 |
+
0.30 * float(row["age_compatibility"] > 0.6) +
|
| 359 |
+
0.15 * float(row["gender_compatibility"] > 0.5) +
|
| 360 |
+
0.25 * float(row["jaccard_similarity"] > 0.05) +
|
| 361 |
+
0.10 * float(row["geo_feasibility"]) +
|
| 362 |
+
0.10 * float(row["med_compatibility"]) +
|
| 363 |
+
0.10 * float(row["lab_availability"])
|
| 364 |
+
)
|
| 365 |
+
base = int(score >= 0.5)
|
| 366 |
+
# 15% hash-deterministic noise for realism
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
#### Fix 3 β geo_feasibility was country-level heuristic (MEDIUM)
|
| 370 |
+
**Before:** Float from `trail_countries.csv` (1.0 US, 0.7 multi-national, 0.35 non-US). No patient location.
|
| 371 |
+
**After:** State-level matching using `trail_facilities.csv` + patient address regex:
|
| 372 |
+
```python
|
| 373 |
+
def _geo_score(patient_state_full, trial_states):
|
| 374 |
+
if not trial_states: return 0.5 # no US facility data β neutral
|
| 375 |
+
if patient_state_full in trial_states: return 1.0
|
| 376 |
+
return 0.75 # other US state
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
#### Fix 4 β NameError `trial_geo` in _compute_features return dict
|
| 380 |
+
`"geo_feasibility": float(trial_geo)` β `"geo_feasibility": geo_feasibility`
|
| 381 |
+
|
| 382 |
+
#### Fix 5 β XSS in condition tag onclick handlers (MEDIUM)
|
| 383 |
+
`addConditionTag('${c}')` broke for conditions with apostrophes (e.g. "alzheimer's disease").
|
| 384 |
+
Fixed with DOM-based `makeTag()` using textContent + addEventListener. No inline onclick anywhere.
|
| 385 |
+
|
| 386 |
+
#### Fix 6 β Duplicate connection prevention (was: no guard)
|
| 387 |
+
- `connections` table: added `UNIQUE(patient_id, hospital_id, trial_id)` schema constraint
|
| 388 |
+
- `init_db()`: runs `CREATE UNIQUE INDEX IF NOT EXISTS idx_conn_unique ON connections(patient_id, hospital_id, COALESCE(trial_id, ''))` to handle NULL trial_id and backfill existing DBs
|
| 389 |
+
- `create_connection()`: pre-checks `connection_exists()` before insert; returns `None` on duplicate
|
| 390 |
+
- `/api/patient/connect` and `/api/hospital/connect`: return 409 when `create_connection()` returns None
|
| 391 |
+
|
| 392 |
+
#### Fix 7 β Hospital patient feed showed already-contacted patients (was: no exclusion)
|
| 393 |
+
`get_open_patients_for_hospital()` now uses:
|
| 394 |
+
```sql
|
| 395 |
+
WHERE open_to_trials=1
|
| 396 |
+
AND id NOT IN (SELECT DISTINCT patient_id FROM connections WHERE hospital_id=?)
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
#### Fix 8 β Demo seed: john_doe starts with open_to_trials=1
|
| 400 |
+
Hospital portal was empty on a fresh database. `_seed_demo_data()` now seeds john_doe with `open_to_trials=1`.
|
| 401 |
+
|
| 402 |
+
#### Fix 9 β Hospital registration silently ignored research_conditions
|
| 403 |
+
`templates/landing.html` hospital register form now collects comma-separated research conditions and sends them as a parsed lowercase array to the backend.
|
| 404 |
+
|
| 405 |
+
#### Fix 10 β Trial cards only showed site count, not facility name or location
|
| 406 |
+
`pipeline.py match_patient()` now extracts `facility_name` from `Facility_Name` column; prefers US facilities. Patient portal detail grid shows "Lead Site" and "Location" when available.
|
| 407 |
+
|
| 408 |
+
---
|
| 409 |
+
|
| 410 |
+
### Session 4 Fixes (2026-04-25) β Hospital matching overhaul + profile editing
|
| 411 |
+
|
| 412 |
+
#### Fix 11 β Hospital suggestion logic replaced (was: research_conditions overlap only)
|
| 413 |
+
Complete replacement of `/api/patient/hospitals-for-trial`:
|
| 414 |
+
|
| 415 |
+
**Before:** looped all hospitals, included any whose `research_conditions` overlapped trial conditions. No tier concept, no facility data used.
|
| 416 |
+
|
| 417 |
+
**After:** 4-tier system using two new pipeline lookups:
|
| 418 |
+
- `pipeline.trial_facility_tokens[trial_id]` β built from `Facility_Name` column in trail_facilities.csv, US rows only. Each facility name tokenised by stripping stopwords + words < 3 chars.
|
| 419 |
+
- `pipeline.trial_us_states[trial_id]` β set of full US state names for the trial
|
| 420 |
+
|
| 421 |
+
Helper functions in `app.py`:
|
| 422 |
+
```python
|
| 423 |
+
def _hospital_name_tokens(name: str) -> frozenset:
|
| 424 |
+
# strips stopwords, keeps words β₯ 3 chars
|
| 425 |
+
...
|
| 426 |
+
|
| 427 |
+
def _facility_match_score(h_tokens: frozenset, facility_token_list: list) -> float:
|
| 428 |
+
# best Jaccard score against any facility in the trial
|
| 429 |
+
...
|
| 430 |
+
```
|
| 431 |
+
|
| 432 |
+
Each hospital gets one tier assigned and a `match_reason` + `match_tier` in the response.
|
| 433 |
+
Result list sorted by `match_tier` ascending (best first).
|
| 434 |
+
|
| 435 |
+
#### Fix 12 β Hospital portal had no profile editing
|
| 436 |
+
`POST /api/hospital/profile` added (was GET-only). `database.py update_hospital_profile()` added. `templates/hospital.html` now has a **My Profile** tab with editable hospital name, location, and research condition tags. On save, the navbar hospital name updates live without a page reload.
|
| 437 |
+
|
| 438 |
+
#### Fix 13 β Model disclaimer missing from patient trial results
|
| 439 |
+
`templates/patient.html` trial results section now shows an alert above results:
|
| 440 |
+
> "Match percentages are predictions from a model trained on synthetic patient data and rule-based labels β not validated clinical eligibility determinations. Always consult a healthcare provider before enrolling in any trial."
|
| 441 |
+
|
| 442 |
+
---
|
| 443 |
+
|
| 444 |
+
### Session 5 Fixes (2026-04-25) β Modal UX + bug fixes
|
| 445 |
+
|
| 446 |
+
#### Fix 14 β Patient connect modal showed all hospitals in one flat list
|
| 447 |
+
**Before:** All hospitals (all tiers) in a single flat list, sorted by tier, with coloured badges as the only visual distinction.
|
| 448 |
+
|
| 449 |
+
**After:** Modal renders two visually separated sections:
|
| 450 |
+
- **"VERIFIED TRIAL SITES"** (green `sec-head`) β Tier 1 hospitals only
|
| 451 |
+
- **"RELATED HOSPITALS β not confirmed trial sites"** (grey `sec-head` with inline subtitle) β Tiers 2, 3, 4
|
| 452 |
+
|
| 453 |
+
Both sections only render if they have entries. Click delegation on the outer `#hospitalList` wrapper still works for both sections.
|
| 454 |
+
|
| 455 |
+
#### Fix 15 β Close button invisible on hospital profile condition tags
|
| 456 |
+
`hospital.html renderProfTags()`: `btn-close-white` (white X) on `bg-info text-dark` badge (light blue background) β `btn-close` (dark X). The X was invisible before.
|
| 457 |
+
|
| 458 |
+
#### Fix 16 β Login/register forms required mouse click, no Enter key support
|
| 459 |
+
`templates/landing.html`: Added `_onEnter(inputId, fn)` helper and wired Enter key on all login and register inputs (both patient and hospital portals). Works on username field too (not just password).
|
| 460 |
+
|
| 461 |
+
#### Fix 17 β System ready banner never auto-cleared on slow boot
|
| 462 |
+
`templates/patient.html`: `checkStatus()` was called once at DOMContentLoaded and never again. If the pipeline was still training when the user opened the page, the yellow banner persisted even after the pipeline finished.
|
| 463 |
+
|
| 464 |
+
**After:** `startStatusPoll()` starts a `setInterval` (5s) when the initial check finds `ready: false`. The interval clears itself once `ready: true` is received.
|
| 465 |
+
```javascript
|
| 466 |
+
checkStatus().then(() => { if (!sysReady) startStatusPoll(); });
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
---
|
| 470 |
+
|
| 471 |
+
## Live Run Verification (2026-04-25)
|
| 472 |
+
|
| 473 |
+
End-to-end test results after full retrain with no model_cache.pkl:
|
| 474 |
+
|
| 475 |
+
| Test | Result |
|
| 476 |
+
|------|--------|
|
| 477 |
+
| Landing page GET / | 200 OK |
|
| 478 |
+
| Patient login john_doe/pass123 | OK β returns patient JSON |
|
| 479 |
+
| Hospital login mgh/mgh123 | OK β returns hospital JSON |
|
| 480 |
+
| Pipeline ready (api/status) | ready: true |
|
| 481 |
+
| /api/patient/matches for john_doe | 20 results, all score fields populated |
|
| 482 |
+
| Top match geo score for Greece trial | 50% (no US facility β correct) |
|
| 483 |
+
| Hospital browses open_to_trials patients | 25 patients visible (5 demo + 20 Synthea) |
|
| 484 |
+
| Hospital condition search ?condition=hypertension | results including Synthea patients |
|
| 485 |
+
| Hospital β patient connect (POST) | OK, status=pending |
|
| 486 |
+
| Patient sees hospital connection (GET) | 1 connection, hospital_name present |
|
| 487 |
+
| /api/conditions/autocomplete?q=hyper | ["hypertension"] |
|
| 488 |
+
| Duplicate connect attempt | 409 error |
|
| 489 |
+
| Hospital profile save | navbar name updates live |
|
| 490 |
+
| Patient connect modal | Two sections render correctly |
|
| 491 |
+
| /api/hospital/trials for mgh | Active trials with match tiers |
|
| 492 |
+
| /api/hospital/inbox | Threads with unread counts |
|
| 493 |
+
| /api/patient/inbox | Threads with hospital names |
|
| 494 |
+
| Patient document upload | File saved, metadata in DB |
|
| 495 |
+
| Inbox Synthea seeding | 25 patients total confirmed |
|
| 496 |
+
|
| 497 |
+
---
|
| 498 |
+
|
| 499 |
+
### Session 6 Fixes (2026-04-25) β Messaging, inbox, trial dashboard, document upload, dataset seeding
|
| 500 |
+
|
| 501 |
+
#### Fix 18 β Hospital trial dashboard (My Trials tab)
|
| 502 |
+
**Before:** Hospital portal had no way to see which clinical trials were relevant to it.
|
| 503 |
+
|
| 504 |
+
**After:** New "My Trials" nav tab in `hospital.html`. Calls `GET /api/hospital/trials` β `pipeline.trials_for_hospital()`. Active-only filter at all 3 tiers. Cards show status badge, phase, match tier, facility name, summary excerpt, and a "View on ClinicalTrials.gov β" link.
|
| 505 |
+
|
| 506 |
+
Active-only filter: `if not self.trial_profiles[trial_id].get("is_active", False): continue` at each tier loop in `pipeline.py`.
|
| 507 |
+
|
| 508 |
+
#### Fix 19 β Messaging (chat in connections)
|
| 509 |
+
**Before:** Connections table had no messaging. Patients and hospitals could only see connection status.
|
| 510 |
+
|
| 511 |
+
**After:**
|
| 512 |
+
- New `connection_messages` table with `sender_role`, `sender_id`, `body`, `is_read`.
|
| 513 |
+
- `GET/POST /api/patient/connections/<cid>/messages` and `GET/POST /api/hospital/connections/<cid>/messages`.
|
| 514 |
+
- Both portals have a messages modal (`#msgModal`) opened by a Chat button in the Connections table.
|
| 515 |
+
- `mark_messages_read()` called on GET to auto-mark messages as read when the recipient opens the thread.
|
| 516 |
+
|
| 517 |
+
#### Fix 20 β Dedicated Inbox tab (both portals)
|
| 518 |
+
**Before:** Chat only accessible from the My Connections table row β no inbox overview.
|
| 519 |
+
|
| 520 |
+
**After:** New "Inbox" nav tab in both `hospital.html` and `patient.html`.
|
| 521 |
+
- Calls `GET /api/hospital/inbox` or `GET /api/patient/inbox`.
|
| 522 |
+
- Backed by `get_hospital_inbox_threads()` / `get_patient_inbox_threads()` β SQL subqueries aggregate last_message, last_message_at, unread_count per thread.
|
| 523 |
+
- Threads sorted by most recent activity (Python-side sort on `last_message_at or created_at`).
|
| 524 |
+
- Unread count badge on nav tab button updates when inbox loads.
|
| 525 |
+
- "Open" button reuses the existing `openMsgModal()` and messages modal.
|
| 526 |
+
|
| 527 |
+
#### Fix 21 β Document upload (patient portal)
|
| 528 |
+
**Before:** Patient profile had no file upload section.
|
| 529 |
+
|
| 530 |
+
**After:** "My Documents" card added to patient profile tab. 4 routes:
|
| 531 |
+
- `POST /api/patient/documents` β werkzeug `secure_filename`, 10 MB limit, allowed extensions: `.pdf/.docx/.doc/.txt/.png/.jpg/.jpeg`. Saves to `uploads/patient_docs/<patient_id>/`. Metadata stored as JSON array in `patient_accounts.documents`.
|
| 532 |
+
- `DELETE /api/patient/documents/<doc_id>` β removes file from disk and metadata from DB.
|
| 533 |
+
- `GET /api/patient/documents/<doc_id>/download` β serves file as attachment.
|
| 534 |
+
- `app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024` enforced Flask-side.
|
| 535 |
+
|
| 536 |
+
#### Fix 22 β Dataset-backed patient seeding
|
| 537 |
+
**Before:** Only hand-made demo patients in DB (john_doe only had open_to_trials=1 initially). Hospital search returned 0 results on fresh DB.
|
| 538 |
+
|
| 539 |
+
**After:** `_seed_dataset_patients(c, max_patients=20)` in `database.py` seeds 20 real Synthea patients from CSV files on first run. Skips deceased patients (Death_Date not empty). Reads up to 8 conditions + 6 medications per patient. Username: `synthea_<first8chars_of_Patient_ID>`, password `pass123`, `open_to_trials=1`.
|
| 540 |
+
|
| 541 |
+
All 5 hand-made demo patients also set to `open_to_trials=1`. Total: 25 patients in DB.
|
| 542 |
+
|
| 543 |
+
#### Fix 23 β Hospital search include_connected toggle
|
| 544 |
+
**Before:** Hospital Search tab also excluded already-connected patients, same as Available Patients tab β making it useless for re-searching.
|
| 545 |
+
|
| 546 |
+
**After:** Search tab adds `include_connected=true` query param. `GET /api/hospital/patients?include_connected=true` bypasses the exclusion subquery. Available Patients tab retains strict exclusion. Toggle checkbox in Search tab UI.
|
| 547 |
+
|
| 548 |
+
#### Fix 24 β Template auto-reload
|
| 549 |
+
**Before:** `app.run(debug=False, use_reloader=False)` β template edits required server restart to take effect.
|
| 550 |
+
|
| 551 |
+
**After:** `app.config["TEMPLATES_AUTO_RELOAD"] = True` added after other config lines. Templates now reload on every request without enabling full debug mode or the reloader.
|
| 552 |
+
|
| 553 |
+
---
|
| 554 |
+
|
| 555 |
+
## Known Issues / Future Improvements
|
| 556 |
+
|
| 557 |
+
1. **High pseudo-label positive rate (82.1%)** β lowers AUC-ROC to ~0.60. Fix: raise label threshold from 0.5 to 0.6, or explicitly sample equal positive/negative pairs.
|
| 558 |
+
|
| 559 |
+
2. **data_completeness feature importance = 0** β nearly constant across training pairs (all synthetic patients have complete data). Consider removing from FEATURE_COLS.
|
| 560 |
+
|
| 561 |
+
3. **Fuzzy condition matching not implemented** β only exact condition name overlaps used (106 conditions). Substring/semantic fuzzy matching would expand coverage significantly.
|
| 562 |
+
|
| 563 |
+
4. **lab_availability coverage is low (8.7%)** β observations file is sparse. Consider normalising denominator to the subset that has any lab data.
|
| 564 |
+
|
| 565 |
+
5. **Hospital portal does not rank patients by match quality** β listed in insertion order. Could rank by condition overlap with the hospital's research_conditions.
|
| 566 |
+
|
| 567 |
+
6. **No email/notification system** β connection requests visible only inside the portal.
|
| 568 |
+
|
| 569 |
+
7. **No automated tests** β syntax checking only (`python -m py_compile`). Key flows to cover: patient registration β profile update β match β connect; hospital registration β patient browse β connect β status update; duplicate connection rejection.
|
| 570 |
+
|
| 571 |
+
8. **Inbox badge not auto-refreshed** β unread count badge only updates when the user clicks the Inbox tab. No real-time push; would require polling or WebSockets.
|
| 572 |
+
|
| 573 |
+
9. **Document access control** β uploaded files are served from disk by doc_id only; no additional hospital-side access to patient documents (by design β privacy model). Hospital sees document count in patient profile only after connection.
|
| 574 |
+
|
| 575 |
+
10. **Synthea patients have Synthea-style names** (e.g. "Geovany567 Reichert456") β cosmetically odd but functionally correct. No fix needed for demo.
|
login.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Demo Login Credentials
|
| 2 |
+
|
| 3 |
+
## Hospital Accounts
|
| 4 |
+
|
| 5 |
+
| Portal | Username | Password | Notes |
|
| 6 |
+
|---|---|---|---|
|
| 7 |
+
| Hospital | `mgh` | `mgh123` | Massachusetts General Hospital |
|
| 8 |
+
| Hospital | `cleveland` | `clinic123` | Cleveland Clinic |
|
| 9 |
+
| Hospital | `jhopkins` | `johns123` | Johns Hopkins Hospital |
|
| 10 |
+
|
| 11 |
+
## Demo Patient Accounts
|
| 12 |
+
|
| 13 |
+
| Portal | Username | Password | Notes |
|
| 14 |
+
|---|---|---|---|
|
| 15 |
+
| Patient | `john_doe` | `pass123` | Demo patient |
|
| 16 |
+
| Patient | `jane_smith` | `pass123` | Demo patient |
|
| 17 |
+
| Patient | `bob_jones` | `pass123` | Demo patient |
|
| 18 |
+
| Patient | `alice_brown` | `pass123` | Demo patient |
|
| 19 |
+
| Patient | `david_chen` | `pass123` | Demo patient |
|
| 20 |
+
|
| 21 |
+
## Dataset-Backed Patient Accounts
|
| 22 |
+
|
| 23 |
+
- The app also auto-seeds a small set of Synthea patients into the portal.
|
| 24 |
+
- Username format: `synthea_<first 8 chars of Patient_ID>`
|
| 25 |
+
- Default password: `pass123`
|
| 26 |
+
- Example: patient ID `660bec03-...` becomes username `synthea_660bec03`
|
| 27 |
+
|
| 28 |
+
To list all current patient usernames in the local DB:
|
| 29 |
+
|
| 30 |
+
```powershell
|
| 31 |
+
@'
|
| 32 |
+
import sqlite3
|
| 33 |
+
con = sqlite3.connect("secondlife.db")
|
| 34 |
+
cur = con.cursor()
|
| 35 |
+
for row in cur.execute("select username, first_name, last_name, synthea_id from patient_accounts order by username"):
|
| 36 |
+
print(row)
|
| 37 |
+
'@ | python -
|
| 38 |
+
```
|
model_cache.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b8e3c50d92c2b71314f765dba7d4cacb1f7a349ed4a9d0153b222bf48cf14e83
|
| 3 |
+
size 79928619
|
pipeline.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Second Life β Clinical Trial Matching Pipeline
|
| 3 |
+
DSCI 5260 | Group 7
|
| 4 |
+
|
| 5 |
+
Full pipeline using ALL available data files:
|
| 6 |
+
Patient side : conditions, demographics, medications, observations
|
| 7 |
+
Trial side : conditions, eligibilities, studies, facilities,
|
| 8 |
+
summaries, interventions, countries, keywords
|
| 9 |
+
Validation : MIMIC-IV (real patients, NDA access)
|
| 10 |
+
|
| 11 |
+
Features align with Second_Life_Final_Notebook_V2.ipynb plus
|
| 12 |
+
real-data replacements for previously simulated feasibility factors.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
import math
|
| 17 |
+
import pickle
|
| 18 |
+
import warnings
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
|
| 22 |
+
# US state abbreviation β full name (patient addresses use abbreviations,
|
| 23 |
+
# facility data uses full names)
|
| 24 |
+
STATE_ABBREV = {
|
| 25 |
+
"AL":"Alabama","AK":"Alaska","AZ":"Arizona","AR":"Arkansas","CA":"California",
|
| 26 |
+
"CO":"Colorado","CT":"Connecticut","DE":"Delaware","FL":"Florida","GA":"Georgia",
|
| 27 |
+
"HI":"Hawaii","ID":"Idaho","IL":"Illinois","IN":"Indiana","IA":"Iowa",
|
| 28 |
+
"KS":"Kansas","KY":"Kentucky","LA":"Louisiana","ME":"Maine","MD":"Maryland",
|
| 29 |
+
"MA":"Massachusetts","MI":"Michigan","MN":"Minnesota","MS":"Mississippi",
|
| 30 |
+
"MO":"Missouri","MT":"Montana","NE":"Nebraska","NV":"Nevada","NH":"New Hampshire",
|
| 31 |
+
"NJ":"New Jersey","NM":"New Mexico","NY":"New York","NC":"North Carolina",
|
| 32 |
+
"ND":"North Dakota","OH":"Ohio","OK":"Oklahoma","OR":"Oregon","PA":"Pennsylvania",
|
| 33 |
+
"RI":"Rhode Island","SC":"South Carolina","SD":"South Dakota","TN":"Tennessee",
|
| 34 |
+
"TX":"Texas","UT":"Utah","VT":"Vermont","VA":"Virginia","WA":"Washington",
|
| 35 |
+
"WV":"West Virginia","WI":"Wisconsin","WY":"Wyoming","DC":"District of Columbia",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
import numpy as np
|
| 39 |
+
import pandas as pd
|
| 40 |
+
from sklearn.calibration import CalibratedClassifierCV
|
| 41 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 42 |
+
from sklearn.metrics import (
|
| 43 |
+
accuracy_score, average_precision_score, brier_score_loss,
|
| 44 |
+
f1_score, precision_score, recall_score, roc_auc_score,
|
| 45 |
+
)
|
| 46 |
+
from sklearn.model_selection import GroupKFold, GroupShuffleSplit, cross_val_score
|
| 47 |
+
from sklearn.preprocessing import StandardScaler
|
| 48 |
+
|
| 49 |
+
warnings.filterwarnings("ignore")
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Paths
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
BASE_DIR = Path(__file__).parent
|
| 55 |
+
|
| 56 |
+
# Patient data (Synthea)
|
| 57 |
+
PATIENT_CONDITIONS_PATH = BASE_DIR / "Final Patients Synthea Data" / "final_patients_conditions.csv"
|
| 58 |
+
PATIENT_DETAILS_PATH = BASE_DIR / "Final Patients Synthea Data" / "patients_details.csv"
|
| 59 |
+
PATIENT_MEDICATIONS_PATH = BASE_DIR / "Final Patients Synthea Data" / "patients_medications.csv"
|
| 60 |
+
PATIENT_OBSERVATIONS_PATH= BASE_DIR / "Final Patients Synthea Data" / "patients_observations.csv"
|
| 61 |
+
|
| 62 |
+
# Trial data (ClinicalTrials.gov / AACT)
|
| 63 |
+
TRIAL_CONDITIONS_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_conditions.csv"
|
| 64 |
+
TRIAL_ELIGIBILITY_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_eligibilities.csv"
|
| 65 |
+
TRIAL_STUDIES_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_studies.csv"
|
| 66 |
+
TRIAL_FACILITIES_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_facilities.csv"
|
| 67 |
+
TRIAL_SUMMARIES_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_brief_summaries.csv"
|
| 68 |
+
TRIAL_INTERVENTIONS_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_interventions.csv"
|
| 69 |
+
TRIAL_COUNTRIES_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_countries.csv"
|
| 70 |
+
TRIAL_KEYWORDS_PATH = BASE_DIR / "Final Clinical Trails Data" / "trail_keywords.csv"
|
| 71 |
+
|
| 72 |
+
# MIMIC-IV (real patients, NDA)
|
| 73 |
+
MIMIC_PATIENTS_PATH = BASE_DIR / "mimic-iv-clinical-database-demo-2.2" / "hosp" / "patients.csv.gz"
|
| 74 |
+
MIMIC_DIAGNOSES_PATH = BASE_DIR / "mimic-iv-clinical-database-demo-2.2" / "hosp" / "diagnoses_icd.csv.gz"
|
| 75 |
+
MIMIC_ICD_DICT_PATH = BASE_DIR / "mimic-iv-clinical-database-demo-2.2" / "hosp" / "d_icd_diagnoses.csv.gz"
|
| 76 |
+
|
| 77 |
+
MODEL_CACHE = BASE_DIR / "model_cache.pkl"
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# Feature columns β matches Second_Life_Final_Notebook_V2.ipynb feature set
|
| 81 |
+
# plus real-data replacements for the Beta-simulated feasibility factors
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
FEATURE_COLS = [
|
| 84 |
+
# --- Condition matching (from notebook) ---
|
| 85 |
+
"condition_overlap", # raw count of shared conditions
|
| 86 |
+
"jaccard_similarity", # overlap / union
|
| 87 |
+
"overlap_ratio_trial", # overlap / len(trial_conditions)
|
| 88 |
+
"overlap_ratio_patient", # overlap / len(patient_conditions)
|
| 89 |
+
"condition_rarity_score", # mean(1/log2(n_trials_per_cond+2)) β rare conditions = higher score
|
| 90 |
+
"trial_specificity", # 1 / trial_condition_count β focused trials score higher
|
| 91 |
+
# --- Patient profile ---
|
| 92 |
+
"condition_burden", # total patient conditions / 10 (normalised)
|
| 93 |
+
"active_ratio", # active (unresolved) conditions / total
|
| 94 |
+
"resolved_ratio", # resolved conditions / total β NEW
|
| 95 |
+
# --- Age (soft continuous, no hard gate) ---
|
| 96 |
+
"age_distance", # normalised distance outside age range (0 if within)
|
| 97 |
+
"age_centered", # position within age range (β1 to +1)
|
| 98 |
+
"age_compatibility", # 1.0 in range, decays over 30-year gap
|
| 99 |
+
# --- Gender (soft) ---
|
| 100 |
+
"gender_compatibility", # 1.0 match/all, 0.1 mismatch
|
| 101 |
+
# --- Real feasibility factors (replace Beta simulation) ---
|
| 102 |
+
"geo_feasibility", # 1.0 same-state facility, 0.8 US other state, 0.4 international
|
| 103 |
+
"med_compatibility", # keyword overlap: patient meds β trial drug interventions
|
| 104 |
+
"lab_availability", # patient observation/lab type coverage (0β1)
|
| 105 |
+
# --- Data quality ---
|
| 106 |
+
"data_completeness", # fraction of key fields present
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Helpers
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
def _parse_age(age_str):
|
| 115 |
+
if pd.isna(age_str):
|
| 116 |
+
return None
|
| 117 |
+
s = str(age_str).lower().strip()
|
| 118 |
+
if s in ("n/a", "na", "", "none"):
|
| 119 |
+
return None
|
| 120 |
+
nums = re.findall(r"(\d+)", s)
|
| 121 |
+
if not nums:
|
| 122 |
+
return None
|
| 123 |
+
age = int(nums[0])
|
| 124 |
+
if "month" in s:
|
| 125 |
+
return age / 12.0
|
| 126 |
+
if "week" in s:
|
| 127 |
+
return age / 52.0
|
| 128 |
+
if "day" in s:
|
| 129 |
+
return age / 365.0
|
| 130 |
+
return float(age)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _drug_keyword(name: str) -> str:
|
| 134 |
+
"""Extract the primary drug name keyword from a medication/intervention string."""
|
| 135 |
+
name = str(name).lower().strip()
|
| 136 |
+
# Remove leading spaces and numeric prefixes like "24 HR "
|
| 137 |
+
name = re.sub(r"^\d[\d\s]*hr\s+", "", name)
|
| 138 |
+
# Remove dosage patterns
|
| 139 |
+
name = re.sub(
|
| 140 |
+
r"[\d./]+\s*(mg|ml|mcg|iu|units?|%|actuat|day|pack|tablet|injection"
|
| 141 |
+
r"|oral|capsule|solution|cream|spray|patch|inhaler|mg/ml|mg/actuat)",
|
| 142 |
+
" ", name
|
| 143 |
+
)
|
| 144 |
+
# Split on non-alpha
|
| 145 |
+
words = [w for w in re.split(r"[\s,\-+/\[\]()]+", name)
|
| 146 |
+
if len(w) >= 4 and not w.replace(".", "").replace("/", "").isdigit()]
|
| 147 |
+
if not words:
|
| 148 |
+
return ""
|
| 149 |
+
# Skip generic prefixes
|
| 150 |
+
skip = {"intra", "oral", "drug", "form", "with", "plus", "anti", "solution",
|
| 151 |
+
"extended", "release", "pack", "spray", "cream", "patch"}
|
| 152 |
+
for w in words:
|
| 153 |
+
if w not in skip:
|
| 154 |
+
return w
|
| 155 |
+
return words[0]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _name_tokens(name: str) -> frozenset:
|
| 159 |
+
"""Tokenise a hospital/facility name: drop stopwords and short words."""
|
| 160 |
+
_STOP = {"the","of","and","at","for","in","a","an","is","by",
|
| 161 |
+
"hospital","medical","center","centre","clinic","university",
|
| 162 |
+
"health","care","healthcare","system","institute","foundation",
|
| 163 |
+
"research","general","regional","national","community",
|
| 164 |
+
"services","department","division","college","school"}
|
| 165 |
+
words = re.findall(r"[a-z]+", str(name).lower())
|
| 166 |
+
return frozenset(w for w in words if w not in _STOP and len(w) >= 3)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _geo_score(patient_state_full: str, trial_states: set) -> float:
|
| 170 |
+
"""
|
| 171 |
+
State-level geo feasibility score.
|
| 172 |
+
patient_state_full: full state name e.g. 'Massachusetts'
|
| 173 |
+
trial_states: set of full US state names where trial has facilities
|
| 174 |
+
"""
|
| 175 |
+
if not trial_states:
|
| 176 |
+
return 0.5 # no facility data β neutral
|
| 177 |
+
if patient_state_full and patient_state_full in trial_states:
|
| 178 |
+
return 1.0 # trial in same state
|
| 179 |
+
if trial_states: # trial has US facility but different state
|
| 180 |
+
return 0.75
|
| 181 |
+
return 0.35 # international only (shouldn't reach here)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _extract_state(address: str) -> str:
|
| 185 |
+
"""Extract full US state name from a patient address string."""
|
| 186 |
+
if not address or address in ("nan", "None", ""):
|
| 187 |
+
return ""
|
| 188 |
+
m = re.search(r"\b([A-Z]{2})\s+\d{5}\b", str(address))
|
| 189 |
+
if m:
|
| 190 |
+
return STATE_ABBREV.get(m.group(1), "")
|
| 191 |
+
return ""
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _compute_features(
|
| 195 |
+
patient_conds: set, patient_age: float, patient_gender: str,
|
| 196 |
+
trial_conds: set, trial_min_age: float, trial_max_age: float,
|
| 197 |
+
trial_gender: str,
|
| 198 |
+
total_patient_conds: int, active_patient_conds: int,
|
| 199 |
+
trial_cond_count: int,
|
| 200 |
+
# pre-computed per-entity lookups
|
| 201 |
+
cond_rarity_map: dict, # condition β rarity_score
|
| 202 |
+
trial_us_states: set, # set of US state full-names for this trial
|
| 203 |
+
pat_med_kws: set, # patient's medication keywords
|
| 204 |
+
trial_drug_kws: set, # trial's drug intervention keywords
|
| 205 |
+
pat_lab_score: float, # patient's lab/observation coverage (0β1)
|
| 206 |
+
patient_state_full: str = "", # patient's US state full name
|
| 207 |
+
) -> dict:
|
| 208 |
+
"""Compute all 17 soft continuous match features (no hard binary gates)."""
|
| 209 |
+
|
| 210 |
+
overlap = len(patient_conds & trial_conds)
|
| 211 |
+
union = len(patient_conds | trial_conds)
|
| 212 |
+
|
| 213 |
+
jaccard = overlap / union if union > 0 else 0.0
|
| 214 |
+
overlap_ratio_pat = overlap / len(patient_conds) if len(patient_conds) > 0 else 0.0
|
| 215 |
+
overlap_ratio_trial = overlap / len(trial_conds) if len(trial_conds) > 0 else 0.0
|
| 216 |
+
|
| 217 |
+
# Condition rarity: rare condition match is stronger signal
|
| 218 |
+
overlap_conds = patient_conds & trial_conds
|
| 219 |
+
if overlap_conds:
|
| 220 |
+
rarity_vals = [cond_rarity_map.get(c, 0.5) for c in overlap_conds]
|
| 221 |
+
condition_rarity_score = float(np.mean(rarity_vals))
|
| 222 |
+
else:
|
| 223 |
+
condition_rarity_score = 0.0
|
| 224 |
+
|
| 225 |
+
trial_specificity = 1.0 / trial_cond_count if trial_cond_count > 0 else 0.0
|
| 226 |
+
condition_burden = min(total_patient_conds / 10.0, 1.0)
|
| 227 |
+
active_ratio = active_patient_conds / total_patient_conds if total_patient_conds > 0 else 0.0
|
| 228 |
+
resolved = max(0, total_patient_conds - active_patient_conds)
|
| 229 |
+
resolved_ratio = resolved / total_patient_conds if total_patient_conds > 0 else 0.0
|
| 230 |
+
|
| 231 |
+
# Age features (continuous)
|
| 232 |
+
age = patient_age if patient_age and not np.isnan(patient_age) else 50.0
|
| 233 |
+
min_a = trial_min_age if trial_min_age else 0.0
|
| 234 |
+
max_a = trial_max_age if trial_max_age else 120.0
|
| 235 |
+
|
| 236 |
+
if age < min_a:
|
| 237 |
+
age_distance = (min_a - age) / 100.0
|
| 238 |
+
elif age > max_a:
|
| 239 |
+
age_distance = (age - max_a) / 100.0
|
| 240 |
+
else:
|
| 241 |
+
age_distance = 0.0
|
| 242 |
+
|
| 243 |
+
mid = (min_a + max_a) / 2.0
|
| 244 |
+
half = (max_a - min_a) / 2.0 if max_a > min_a else 1.0
|
| 245 |
+
age_centered = max(-1.0, min(1.0, (age - mid) / half))
|
| 246 |
+
|
| 247 |
+
if min_a <= age <= max_a:
|
| 248 |
+
age_compat = 1.0
|
| 249 |
+
else:
|
| 250 |
+
dist = min(abs(age - min_a), abs(age - max_a))
|
| 251 |
+
age_compat = max(0.0, 1.0 - dist / 30.0)
|
| 252 |
+
|
| 253 |
+
# Gender (soft)
|
| 254 |
+
pg = str(patient_gender).upper().strip()
|
| 255 |
+
tg = str(trial_gender).upper().strip()
|
| 256 |
+
if tg in ("ALL", ""):
|
| 257 |
+
gender_compat = 1.0
|
| 258 |
+
elif (pg in ("M", "MALE") and tg in ("M", "MALE")) or \
|
| 259 |
+
(pg in ("F", "FEMALE") and tg in ("F", "FEMALE")):
|
| 260 |
+
gender_compat = 1.0
|
| 261 |
+
else:
|
| 262 |
+
gender_compat = 0.1
|
| 263 |
+
|
| 264 |
+
# Geo feasibility: state-level (same state = 1.0, other US = 0.75, no data = 0.5)
|
| 265 |
+
geo_feasibility = _geo_score(patient_state_full, trial_us_states)
|
| 266 |
+
|
| 267 |
+
# Medication compatibility (real: keyword overlap)
|
| 268 |
+
if pat_med_kws and trial_drug_kws:
|
| 269 |
+
m_overlap = len(pat_med_kws & trial_drug_kws)
|
| 270 |
+
m_union = len(pat_med_kws | trial_drug_kws)
|
| 271 |
+
# Presence of any shared keyword = compatible; jaccard for strength
|
| 272 |
+
med_compat = 0.8 + 0.2 * (m_overlap / m_union) if m_overlap > 0 else 0.4
|
| 273 |
+
else:
|
| 274 |
+
med_compat = 0.5 # neutral when data is missing
|
| 275 |
+
|
| 276 |
+
# Data completeness
|
| 277 |
+
data_completeness = sum([
|
| 278 |
+
not np.isnan(age),
|
| 279 |
+
pg != "",
|
| 280 |
+
total_patient_conds > 0,
|
| 281 |
+
min_a is not None,
|
| 282 |
+
tg != "",
|
| 283 |
+
]) / 5.0
|
| 284 |
+
|
| 285 |
+
# Rule-based match score (reference only β NOT a feature, used for labelling)
|
| 286 |
+
match_score = (
|
| 287 |
+
0.35 * overlap_ratio_trial +
|
| 288 |
+
0.25 * overlap_ratio_pat +
|
| 289 |
+
0.20 * age_compat +
|
| 290 |
+
0.20 * gender_compat
|
| 291 |
+
) * 100.0
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"condition_overlap": float(overlap),
|
| 295 |
+
"jaccard_similarity": jaccard,
|
| 296 |
+
"overlap_ratio_trial": overlap_ratio_trial,
|
| 297 |
+
"overlap_ratio_patient": overlap_ratio_pat,
|
| 298 |
+
"condition_rarity_score": condition_rarity_score,
|
| 299 |
+
"trial_specificity": trial_specificity,
|
| 300 |
+
"condition_burden": condition_burden,
|
| 301 |
+
"active_ratio": active_ratio,
|
| 302 |
+
"resolved_ratio": resolved_ratio,
|
| 303 |
+
"age_distance": age_distance,
|
| 304 |
+
"age_centered": age_centered,
|
| 305 |
+
"age_compatibility": age_compat,
|
| 306 |
+
"gender_compatibility": gender_compat,
|
| 307 |
+
"geo_feasibility": geo_feasibility,
|
| 308 |
+
"med_compatibility": med_compat,
|
| 309 |
+
"lab_availability": float(pat_lab_score),
|
| 310 |
+
"data_completeness": data_completeness,
|
| 311 |
+
"match_score": match_score,
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ---------------------------------------------------------------------------
|
| 316 |
+
# Pipeline
|
| 317 |
+
# ---------------------------------------------------------------------------
|
| 318 |
+
|
| 319 |
+
class SecondLifePipeline:
|
| 320 |
+
"""
|
| 321 |
+
Loads all data files, trains the eligibility model, and exposes
|
| 322 |
+
match_patient() for the Flask app.
|
| 323 |
+
"""
|
| 324 |
+
|
| 325 |
+
def __init__(self):
|
| 326 |
+
self.model = None
|
| 327 |
+
self.scaler = None
|
| 328 |
+
self.model_metrics = {}
|
| 329 |
+
|
| 330 |
+
# DataFrames
|
| 331 |
+
self.patient_conditions_df = None
|
| 332 |
+
self.patient_details_df = None
|
| 333 |
+
self.trial_studies_df = None
|
| 334 |
+
self.trial_elig_df = None
|
| 335 |
+
self.trial_summaries_df = None
|
| 336 |
+
self.trial_facilities_df = None
|
| 337 |
+
|
| 338 |
+
# Lookup structures built in load()
|
| 339 |
+
self.trial_profiles = {} # trial_id β profile dict
|
| 340 |
+
self.cond_to_trials = {} # condition β [trial_ids]
|
| 341 |
+
self.overlapping_conds = set()
|
| 342 |
+
self.patient_profiles = {} # patient_id β profile dict
|
| 343 |
+
|
| 344 |
+
# New real-data lookups
|
| 345 |
+
self.cond_rarity_map = {} # condition β rarity score
|
| 346 |
+
self.trial_us_states = {} # trial_id β set of US state full names
|
| 347 |
+
self.trial_facility_tokens = {} # trial_id β list of frozensets of facility name tokens
|
| 348 |
+
self.trial_drug_keywords = {} # trial_id β set of drug keywords
|
| 349 |
+
self.patient_med_keywords = {} # patient_id β set of med keywords
|
| 350 |
+
self.patient_lab_score = {} # patient_id β lab coverage (0β1)
|
| 351 |
+
self.patient_address_state = {} # patient_id β full US state name
|
| 352 |
+
|
| 353 |
+
self.stats = {}
|
| 354 |
+
|
| 355 |
+
# ------------------------------------------------------------------
|
| 356 |
+
# Load
|
| 357 |
+
# ------------------------------------------------------------------
|
| 358 |
+
|
| 359 |
+
def load(self):
|
| 360 |
+
print("[pipeline] Loading patient conditions β¦")
|
| 361 |
+
pc = pd.read_csv(PATIENT_CONDITIONS_PATH)
|
| 362 |
+
pc["condition_lower"] = pc["Condition_Name"].str.lower().str.strip()
|
| 363 |
+
self.patient_conditions_df = pc
|
| 364 |
+
|
| 365 |
+
print("[pipeline] Loading patient demographics β¦")
|
| 366 |
+
pd_df = pd.read_csv(PATIENT_DETAILS_PATH)
|
| 367 |
+
pd_df["Birth_Date"] = pd.to_datetime(pd_df["Birth_Date"], format="%d-%m-%Y", errors="coerce")
|
| 368 |
+
ref = datetime(2024, 1, 1)
|
| 369 |
+
pd_df["Patient_Age"] = ((ref - pd_df["Birth_Date"]).dt.days / 365.25).round()
|
| 370 |
+
pd_df["Gender"] = pd_df["Gender"].str.upper().str.strip()
|
| 371 |
+
self.patient_details_df = pd_df
|
| 372 |
+
|
| 373 |
+
if "Address" in pd_df.columns:
|
| 374 |
+
addr_series = pd_df.set_index("Patient_ID")["Address"].dropna()
|
| 375 |
+
state_series = addr_series.apply(lambda a: _extract_state(str(a)))
|
| 376 |
+
self.patient_address_state = {pid: st for pid, st in state_series.items() if st}
|
| 377 |
+
print(f" Patients with state data: {len(self.patient_address_state):,}")
|
| 378 |
+
|
| 379 |
+
print("[pipeline] Loading patient medications β¦")
|
| 380 |
+
meds = pd.read_csv(PATIENT_MEDICATIONS_PATH,
|
| 381 |
+
usecols=["Patient_ID", "Medication_Name", "Medication_End_Date"])
|
| 382 |
+
# Keep only active medications (no end date)
|
| 383 |
+
active_meds = meds[meds["Medication_End_Date"].isna()].copy()
|
| 384 |
+
active_meds["kw"] = active_meds["Medication_Name"].apply(_drug_keyword)
|
| 385 |
+
active_meds = active_meds[active_meds["kw"] != ""]
|
| 386 |
+
self.patient_med_keywords = (
|
| 387 |
+
active_meds.groupby("Patient_ID")["kw"].apply(set).to_dict()
|
| 388 |
+
)
|
| 389 |
+
# Fall back: use all meds if no active ones
|
| 390 |
+
all_meds_kw = meds.copy()
|
| 391 |
+
all_meds_kw["kw"] = all_meds_kw["Medication_Name"].apply(_drug_keyword)
|
| 392 |
+
all_meds_kw = all_meds_kw[all_meds_kw["kw"] != ""]
|
| 393 |
+
all_med_map = all_meds_kw.groupby("Patient_ID")["kw"].apply(set).to_dict()
|
| 394 |
+
for pid, kws in all_med_map.items():
|
| 395 |
+
if pid not in self.patient_med_keywords:
|
| 396 |
+
self.patient_med_keywords[pid] = kws
|
| 397 |
+
print(f" Patients with medication data: {len(self.patient_med_keywords):,}")
|
| 398 |
+
|
| 399 |
+
print("[pipeline] Loading patient observations/labs β¦")
|
| 400 |
+
obs = pd.read_csv(PATIENT_OBSERVATIONS_PATH,
|
| 401 |
+
usecols=["Patient_ID", "Observation_Name"])
|
| 402 |
+
# Unique lab types per patient, normalised by 20 (typical max)
|
| 403 |
+
lab_counts = obs.groupby("Patient_ID")["Observation_Name"].nunique()
|
| 404 |
+
self.patient_lab_score = (lab_counts / 20.0).clip(0, 1).to_dict()
|
| 405 |
+
print(f" Patients with observation data: {len(self.patient_lab_score):,}")
|
| 406 |
+
|
| 407 |
+
print("[pipeline] Loading trial conditions β¦")
|
| 408 |
+
tc = pd.read_csv(TRIAL_CONDITIONS_PATH)
|
| 409 |
+
tc["condition_lower"] = tc["Condition_Name_Lower"].str.lower().str.strip()
|
| 410 |
+
|
| 411 |
+
print("[pipeline] Loading trial eligibilities β¦")
|
| 412 |
+
elig = pd.read_csv(TRIAL_ELIGIBILITY_PATH)
|
| 413 |
+
elig.columns = elig.columns.str.strip()
|
| 414 |
+
if "Gender" in elig.columns:
|
| 415 |
+
elig.rename(columns={"Gender": "Sex"}, inplace=True)
|
| 416 |
+
elig["Min_Age"] = elig["Minimum_Age"].apply(_parse_age).fillna(0)
|
| 417 |
+
elig["Max_Age"] = elig["Maximum_Age"].apply(_parse_age).fillna(120)
|
| 418 |
+
elig["Sex"] = elig["Sex"].fillna("ALL").str.upper().str.strip()
|
| 419 |
+
self.trial_elig_df = elig
|
| 420 |
+
|
| 421 |
+
print("[pipeline] Loading trial studies β¦")
|
| 422 |
+
studies = pd.read_csv(TRIAL_STUDIES_PATH)
|
| 423 |
+
self.trial_studies_df = studies
|
| 424 |
+
print(f" Total trials: {studies['Trial_ID'].nunique():,}")
|
| 425 |
+
|
| 426 |
+
print("[pipeline] Loading trial facilities β¦")
|
| 427 |
+
try:
|
| 428 |
+
fac = pd.read_csv(TRIAL_FACILITIES_PATH, encoding="utf-8", on_bad_lines="skip")
|
| 429 |
+
self.trial_facilities_df = fac
|
| 430 |
+
except Exception:
|
| 431 |
+
self.trial_facilities_df = pd.DataFrame(
|
| 432 |
+
columns=["Trial_ID", "Facility_City", "Facility_State", "Facility_Country"])
|
| 433 |
+
|
| 434 |
+
print("[pipeline] Loading trial summaries β¦")
|
| 435 |
+
try:
|
| 436 |
+
summaries = pd.read_csv(TRIAL_SUMMARIES_PATH, encoding="utf-8", on_bad_lines="skip")
|
| 437 |
+
self.trial_summaries_df = summaries
|
| 438 |
+
except Exception:
|
| 439 |
+
self.trial_summaries_df = pd.DataFrame(columns=["Trial_ID", "Brief_Summary"])
|
| 440 |
+
|
| 441 |
+
# ---- NEW: Trial interventions (for med_compatibility) ----
|
| 442 |
+
print("[pipeline] Loading trial interventions (drug keywords) β¦")
|
| 443 |
+
interv = pd.read_csv(TRIAL_INTERVENTIONS_PATH,
|
| 444 |
+
usecols=["Trial_ID", "Intervention_Type", "Intervention_Name"])
|
| 445 |
+
drug_interv = interv[interv["Intervention_Type"] == "DRUG"].copy()
|
| 446 |
+
drug_interv["kw"] = drug_interv["Intervention_Name"].apply(_drug_keyword)
|
| 447 |
+
drug_interv = drug_interv[drug_interv["kw"] != ""]
|
| 448 |
+
self.trial_drug_keywords = (
|
| 449 |
+
drug_interv.groupby("Trial_ID")["kw"].apply(set).to_dict()
|
| 450 |
+
)
|
| 451 |
+
print(f" Trials with drug intervention data: {len(self.trial_drug_keywords):,}")
|
| 452 |
+
|
| 453 |
+
# ---- State-level geo + facility-name index ----
|
| 454 |
+
print("[pipeline] Building trial US state index and facility-name index β¦")
|
| 455 |
+
fac = self.trial_facilities_df
|
| 456 |
+
if (fac is not None and not fac.empty
|
| 457 |
+
and "Facility_Country" in fac.columns
|
| 458 |
+
and "Facility_State" in fac.columns):
|
| 459 |
+
fac_us = fac[fac["Facility_Country"].str.strip().str.lower()
|
| 460 |
+
.isin(["united states", "usa", "us"])].copy()
|
| 461 |
+
fac_us["state_clean"] = fac_us["Facility_State"].str.strip()
|
| 462 |
+
fac_us = fac_us[
|
| 463 |
+
fac_us["state_clean"].notna() &
|
| 464 |
+
(fac_us["state_clean"] != "") &
|
| 465 |
+
(fac_us["state_clean"].str.lower() != "nan")
|
| 466 |
+
]
|
| 467 |
+
self.trial_us_states = (
|
| 468 |
+
fac_us.groupby("Trial_ID")["state_clean"].apply(set).to_dict()
|
| 469 |
+
)
|
| 470 |
+
# Facility-name token sets: trial_id β list of frozensets of significant words
|
| 471 |
+
if "Facility_Name" in fac_us.columns:
|
| 472 |
+
def _tok(name):
|
| 473 |
+
_STOP = {"the","of","and","at","for","in","a","an","is","by",
|
| 474 |
+
"hospital","medical","center","centre","clinic","university",
|
| 475 |
+
"health","care","healthcare","system","institute","foundation",
|
| 476 |
+
"research","general","regional","national","community",
|
| 477 |
+
"services","department","division","college","school"}
|
| 478 |
+
words = re.findall(r"[a-z]+", str(name).lower())
|
| 479 |
+
return frozenset(w for w in words if w not in _STOP and len(w) >= 3)
|
| 480 |
+
fac_us["name_tokens"] = fac_us["Facility_Name"].apply(_tok)
|
| 481 |
+
self.trial_facility_tokens = (
|
| 482 |
+
fac_us.groupby("Trial_ID")["name_tokens"].apply(list).to_dict()
|
| 483 |
+
)
|
| 484 |
+
else:
|
| 485 |
+
self.trial_facility_tokens = {}
|
| 486 |
+
else:
|
| 487 |
+
self.trial_us_states = {}
|
| 488 |
+
self.trial_facility_tokens = {}
|
| 489 |
+
print(f" Trials with US facility state data: {len(self.trial_us_states):,}")
|
| 490 |
+
print(f" Trials with facility name tokens: {len(self.trial_facility_tokens):,}")
|
| 491 |
+
|
| 492 |
+
# ---- NEW: Trial keywords (extend condition matching bridge) ----
|
| 493 |
+
print("[pipeline] Loading trial keywords β¦")
|
| 494 |
+
kw_df = pd.read_csv(TRIAL_KEYWORDS_PATH,
|
| 495 |
+
usecols=["Trial_ID", "Keyword_Name_Lower"])
|
| 496 |
+
kw_df["kw_lower"] = kw_df["Keyword_Name_Lower"].str.lower().str.strip()
|
| 497 |
+
self.trial_keyword_map = kw_df.groupby("Trial_ID")["kw_lower"].apply(set).to_dict()
|
| 498 |
+
|
| 499 |
+
# Find overlapping conditions
|
| 500 |
+
patient_cond_set = set(pc["condition_lower"].dropna().unique())
|
| 501 |
+
trial_cond_set = set(tc["condition_lower"].dropna().unique())
|
| 502 |
+
self.overlapping_conds = patient_cond_set & trial_cond_set
|
| 503 |
+
print(f" Overlapping conditions (exact): {len(self.overlapping_conds)}")
|
| 504 |
+
|
| 505 |
+
# ---- NEW: Condition rarity scores ----
|
| 506 |
+
print("[pipeline] Computing condition rarity scores β¦")
|
| 507 |
+
cond_trial_counts = tc[tc["condition_lower"].isin(self.overlapping_conds)] \
|
| 508 |
+
.groupby("condition_lower")["Trial_ID"].nunique().to_dict()
|
| 509 |
+
for cond in self.overlapping_conds:
|
| 510 |
+
n = cond_trial_counts.get(cond, 1)
|
| 511 |
+
self.cond_rarity_map[cond] = 1.0 / math.log2(n + 2)
|
| 512 |
+
# Normalise to 0β1
|
| 513 |
+
max_r = max(self.cond_rarity_map.values()) if self.cond_rarity_map else 1.0
|
| 514 |
+
self.cond_rarity_map = {c: v / max_r for c, v in self.cond_rarity_map.items()}
|
| 515 |
+
|
| 516 |
+
# Build trial profiles
|
| 517 |
+
print("[pipeline] Building trial profiles β¦")
|
| 518 |
+
tc_filtered = tc[tc["condition_lower"].isin(self.overlapping_conds)]
|
| 519 |
+
trial_cond_map = tc_filtered.groupby("Trial_ID")["condition_lower"].apply(set).to_dict()
|
| 520 |
+
|
| 521 |
+
elig_idx = elig.set_index("Trial_ID")
|
| 522 |
+
studies_idx= studies.set_index("Trial_ID")
|
| 523 |
+
|
| 524 |
+
active_statuses = {
|
| 525 |
+
"RECRUITING", "NOT_YET_RECRUITING", "ENROLLING_BY_INVITATION",
|
| 526 |
+
"ACTIVE_NOT_RECRUITING", "UNKNOWN",
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
for trial_id, conds in trial_cond_map.items():
|
| 530 |
+
e = elig_idx.loc[trial_id] if trial_id in elig_idx.index else None
|
| 531 |
+
s = studies_idx.loc[trial_id] if trial_id in studies_idx.index else None
|
| 532 |
+
|
| 533 |
+
# Handle cases where index lookup returns DataFrame (duplicate IDs)
|
| 534 |
+
if isinstance(e, pd.DataFrame): e = e.iloc[0]
|
| 535 |
+
if isinstance(s, pd.DataFrame): s = s.iloc[0]
|
| 536 |
+
|
| 537 |
+
min_age = float(e["Min_Age"]) if e is not None else 0.0
|
| 538 |
+
max_age = float(e["Max_Age"]) if e is not None else 120.0
|
| 539 |
+
sex = str(e["Sex"]) if e is not None else "ALL"
|
| 540 |
+
criteria = str(e.get("Eligibility_Criteria", "")) if e is not None else ""
|
| 541 |
+
|
| 542 |
+
status = str(s["Overall_Status"]) if s is not None else "UNKNOWN"
|
| 543 |
+
title = str(s["Brief_Title"]) if s is not None else trial_id
|
| 544 |
+
phase = str(s["Phase"]) if s is not None else "N/A"
|
| 545 |
+
start = str(s.get("Start_Date", "")) if s is not None else ""
|
| 546 |
+
enroll = s.get("Enrollment", np.nan) if s is not None else np.nan
|
| 547 |
+
|
| 548 |
+
self.trial_profiles[trial_id] = {
|
| 549 |
+
"conditions": conds,
|
| 550 |
+
"min_age": min_age,
|
| 551 |
+
"max_age": max_age,
|
| 552 |
+
"sex": sex,
|
| 553 |
+
"status": status,
|
| 554 |
+
"is_active": status in active_statuses,
|
| 555 |
+
"title": title[:200],
|
| 556 |
+
"phase": phase,
|
| 557 |
+
"start_date": start,
|
| 558 |
+
"enrollment": int(enroll) if pd.notna(enroll) else None,
|
| 559 |
+
"criteria": criteria[:500],
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
# Reverse index: condition β [trial_ids]
|
| 563 |
+
for trial_id, prof in self.trial_profiles.items():
|
| 564 |
+
for cond in prof["conditions"]:
|
| 565 |
+
self.cond_to_trials.setdefault(cond, []).append(trial_id)
|
| 566 |
+
|
| 567 |
+
# Extend cond_to_trials with keyword matches
|
| 568 |
+
for trial_id, kws in self.trial_keyword_map.items():
|
| 569 |
+
for kw in kws:
|
| 570 |
+
if kw in self.overlapping_conds and trial_id in self.trial_profiles:
|
| 571 |
+
self.cond_to_trials.setdefault(kw, [])
|
| 572 |
+
if trial_id not in self.cond_to_trials[kw]:
|
| 573 |
+
self.cond_to_trials[kw].append(trial_id)
|
| 574 |
+
|
| 575 |
+
print(f" Trial profiles built: {len(self.trial_profiles):,}")
|
| 576 |
+
|
| 577 |
+
# Build patient profiles
|
| 578 |
+
print("[pipeline] Building patient profiles β¦")
|
| 579 |
+
pat_filtered = pc[pc["condition_lower"].isin(self.overlapping_conds)]
|
| 580 |
+
pat_agg = pat_filtered.groupby("Patient_ID").agg(
|
| 581 |
+
patient_conds = ("condition_lower", set),
|
| 582 |
+
active_conds = ("Condition_End_Date", lambda x: x.isna().sum()),
|
| 583 |
+
total_conds = ("condition_lower", "count"),
|
| 584 |
+
).reset_index()
|
| 585 |
+
|
| 586 |
+
details_idx = pd_df.set_index("Patient_ID")[["Patient_Age", "Gender"]]
|
| 587 |
+
pat_merged = pat_agg.merge(details_idx, left_on="Patient_ID",
|
| 588 |
+
right_index=True, how="inner")
|
| 589 |
+
self.patient_profiles = pat_merged.set_index("Patient_ID").to_dict("index")
|
| 590 |
+
print(f" Patient profiles built: {len(self.patient_profiles):,}")
|
| 591 |
+
|
| 592 |
+
self.stats = {
|
| 593 |
+
"total_patients": pd_df["Patient_ID"].nunique(),
|
| 594 |
+
"total_trials": studies["Trial_ID"].nunique(),
|
| 595 |
+
"recruiting_trials": int((studies["Overall_Status"] == "RECRUITING").sum()),
|
| 596 |
+
"overlapping_conditions": len(self.overlapping_conds),
|
| 597 |
+
"matched_trials": len(self.trial_profiles),
|
| 598 |
+
"patient_profiles_with_matches": len(self.patient_profiles),
|
| 599 |
+
"patients_with_medication_data": len(self.patient_med_keywords),
|
| 600 |
+
"patients_with_lab_data": len(self.patient_lab_score),
|
| 601 |
+
"trials_with_geo_data": len(self.trial_us_states),
|
| 602 |
+
"trials_with_drug_data": len(self.trial_drug_keywords),
|
| 603 |
+
"_conditions": sorted(self.overlapping_conds),
|
| 604 |
+
}
|
| 605 |
+
return self
|
| 606 |
+
|
| 607 |
+
# ------------------------------------------------------------------
|
| 608 |
+
# Train
|
| 609 |
+
# ------------------------------------------------------------------
|
| 610 |
+
|
| 611 |
+
def train(self, n_patients: int = 3000, n_trials_per_patient: int = 30,
|
| 612 |
+
random_state: int = 42):
|
| 613 |
+
if MODEL_CACHE.exists():
|
| 614 |
+
print("[pipeline] Loading cached model β¦")
|
| 615 |
+
with open(MODEL_CACHE, "rb") as f:
|
| 616 |
+
cached = pickle.load(f)
|
| 617 |
+
self.model = cached["model"]
|
| 618 |
+
self.scaler = cached["scaler"]
|
| 619 |
+
self.model_metrics = cached["metrics"]
|
| 620 |
+
self.overlapping_conds = cached.get("overlapping_conds", self.overlapping_conds)
|
| 621 |
+
print(f" Cached model loaded β AUC-ROC = {self.model_metrics.get('auroc', 'N/A'):.4f}")
|
| 622 |
+
return self
|
| 623 |
+
|
| 624 |
+
print(f"[pipeline] Generating training pairs (n_patients={n_patients}) β¦")
|
| 625 |
+
rng = np.random.default_rng(random_state)
|
| 626 |
+
|
| 627 |
+
all_pids = list(self.patient_profiles.keys())
|
| 628 |
+
sampled_pids = rng.choice(all_pids, size=min(n_patients, len(all_pids)), replace=False)
|
| 629 |
+
|
| 630 |
+
active_tids = [tid for tid, p in self.trial_profiles.items() if p["is_active"]]
|
| 631 |
+
if not active_tids:
|
| 632 |
+
active_tids = list(self.trial_profiles.keys())
|
| 633 |
+
|
| 634 |
+
rows = []
|
| 635 |
+
for pid in sampled_pids:
|
| 636 |
+
prof = self.patient_profiles[pid]
|
| 637 |
+
pat_conds = prof["patient_conds"]
|
| 638 |
+
pat_age = float(prof["Patient_Age"]) if pd.notna(prof["Patient_Age"]) else 50.0
|
| 639 |
+
pat_gender = str(prof["Gender"]) if pd.notna(prof["Gender"]) else "M"
|
| 640 |
+
total_c = int(prof["total_conds"])
|
| 641 |
+
active_c = int(prof["active_conds"])
|
| 642 |
+
|
| 643 |
+
pat_med_kws = self.patient_med_keywords.get(pid, set())
|
| 644 |
+
pat_lab = self.patient_lab_score.get(pid, 0.3)
|
| 645 |
+
pat_state = self.patient_address_state.get(pid, "")
|
| 646 |
+
|
| 647 |
+
# Positive candidates: share at least one condition
|
| 648 |
+
candidate_tids = set()
|
| 649 |
+
for c in pat_conds:
|
| 650 |
+
candidate_tids.update(self.cond_to_trials.get(c, []))
|
| 651 |
+
|
| 652 |
+
if not candidate_tids:
|
| 653 |
+
continue
|
| 654 |
+
|
| 655 |
+
# Negative candidates: random trials with no condition overlap
|
| 656 |
+
neg_tids = set(rng.choice(active_tids,
|
| 657 |
+
size=min(10, len(active_tids)),
|
| 658 |
+
replace=False).tolist())
|
| 659 |
+
|
| 660 |
+
candidate_list = list(candidate_tids)
|
| 661 |
+
rng.shuffle(candidate_list)
|
| 662 |
+
candidate_list = candidate_list[:n_trials_per_patient]
|
| 663 |
+
candidate_list += [t for t in neg_tids if t not in candidate_tids]
|
| 664 |
+
|
| 665 |
+
for tid in candidate_list:
|
| 666 |
+
if tid not in self.trial_profiles:
|
| 667 |
+
continue
|
| 668 |
+
tp = self.trial_profiles[tid]
|
| 669 |
+
|
| 670 |
+
feats = _compute_features(
|
| 671 |
+
pat_conds, pat_age, pat_gender,
|
| 672 |
+
tp["conditions"], tp["min_age"], tp["max_age"], tp["sex"],
|
| 673 |
+
total_c, active_c, len(tp["conditions"]),
|
| 674 |
+
self.cond_rarity_map,
|
| 675 |
+
self.trial_us_states.get(tid, set()),
|
| 676 |
+
pat_med_kws,
|
| 677 |
+
self.trial_drug_keywords.get(tid, set()),
|
| 678 |
+
pat_lab,
|
| 679 |
+
patient_state_full=pat_state,
|
| 680 |
+
)
|
| 681 |
+
feats["Patient_ID"] = pid
|
| 682 |
+
feats["Trial_ID"] = tid
|
| 683 |
+
rows.append(feats)
|
| 684 |
+
|
| 685 |
+
df = pd.DataFrame(rows)
|
| 686 |
+
print(f" Training pairs generated: {len(df):,}")
|
| 687 |
+
|
| 688 |
+
# Rule-based label β uses all 6 main signals, 15% noise for realism
|
| 689 |
+
import hashlib
|
| 690 |
+
def _label(row):
|
| 691 |
+
score = (
|
| 692 |
+
0.30 * float(row["age_compatibility"] > 0.6) +
|
| 693 |
+
0.15 * float(row["gender_compatibility"] > 0.5) +
|
| 694 |
+
0.25 * float(row["jaccard_similarity"] > 0.05) +
|
| 695 |
+
0.10 * float(row["geo_feasibility"]) +
|
| 696 |
+
0.10 * float(row["med_compatibility"]) +
|
| 697 |
+
0.10 * float(row["lab_availability"])
|
| 698 |
+
)
|
| 699 |
+
base = int(score >= 0.5)
|
| 700 |
+
h = int(hashlib.md5(
|
| 701 |
+
f"{row['Patient_ID']}_{row['Trial_ID']}".encode()
|
| 702 |
+
).hexdigest()[:8], 16)
|
| 703 |
+
if (h % 1000) / 1000 < 0.15:
|
| 704 |
+
base = 1 - base
|
| 705 |
+
return base
|
| 706 |
+
|
| 707 |
+
df["label"] = df.apply(_label, axis=1)
|
| 708 |
+
|
| 709 |
+
X = df[FEATURE_COLS].fillna(0)
|
| 710 |
+
y = df["label"].values
|
| 711 |
+
groups = df["Patient_ID"].values
|
| 712 |
+
|
| 713 |
+
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=random_state)
|
| 714 |
+
train_idx, test_idx = next(gss.split(X, y, groups))
|
| 715 |
+
|
| 716 |
+
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
|
| 717 |
+
y_train, y_test = y[train_idx], y[test_idx]
|
| 718 |
+
g_train = groups[train_idx]
|
| 719 |
+
|
| 720 |
+
print(f" Train: {len(X_train):,} | Test: {len(X_test):,} | Positive rate: {y_train.mean()*100:.1f}%")
|
| 721 |
+
|
| 722 |
+
scaler = StandardScaler()
|
| 723 |
+
X_train_sc = scaler.fit_transform(X_train)
|
| 724 |
+
|
| 725 |
+
print("[pipeline] Training Random Forest (n_estimators=200, max_depth=12) β¦")
|
| 726 |
+
rf = RandomForestClassifier(
|
| 727 |
+
n_estimators=200, max_depth=12, min_samples_split=10,
|
| 728 |
+
class_weight="balanced", random_state=random_state, n_jobs=-1,
|
| 729 |
+
)
|
| 730 |
+
rf.fit(X_train, y_train)
|
| 731 |
+
|
| 732 |
+
# Calibrate probabilities
|
| 733 |
+
cal = CalibratedClassifierCV(rf, cv=3, method="isotonic")
|
| 734 |
+
cal.fit(X_train, y_train)
|
| 735 |
+
|
| 736 |
+
y_pred = cal.predict(X_test)
|
| 737 |
+
y_prob = cal.predict_proba(X_test)[:, 1]
|
| 738 |
+
|
| 739 |
+
metrics = {
|
| 740 |
+
"accuracy": accuracy_score(y_test, y_pred),
|
| 741 |
+
"precision": precision_score(y_test, y_pred, zero_division=0),
|
| 742 |
+
"recall": recall_score(y_test, y_pred, zero_division=0),
|
| 743 |
+
"f1": f1_score(y_test, y_pred, zero_division=0),
|
| 744 |
+
"auroc": roc_auc_score(y_test, y_prob),
|
| 745 |
+
"brier": brier_score_loss(y_test, y_prob),
|
| 746 |
+
"avg_precision": average_precision_score(y_test, y_prob),
|
| 747 |
+
"train_size": len(X_train),
|
| 748 |
+
"test_size": len(X_test),
|
| 749 |
+
"positive_rate": float(y_train.mean()),
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
cv = GroupKFold(n_splits=5)
|
| 753 |
+
cv_scores = cross_val_score(rf, X_train, y_train, cv=cv,
|
| 754 |
+
groups=g_train, scoring="roc_auc")
|
| 755 |
+
metrics["cv_auroc_mean"] = float(cv_scores.mean())
|
| 756 |
+
metrics["cv_auroc_std"] = float(cv_scores.std())
|
| 757 |
+
metrics["feature_importance"] = dict(zip(FEATURE_COLS, rf.feature_importances_))
|
| 758 |
+
|
| 759 |
+
print(f" Accuracy : {metrics['accuracy']*100:.1f}%")
|
| 760 |
+
print(f" AUC-ROC : {metrics['auroc']:.4f}")
|
| 761 |
+
print(f" CV AUC : {metrics['cv_auroc_mean']:.4f} Β± {metrics['cv_auroc_std']:.4f}")
|
| 762 |
+
|
| 763 |
+
self.model = cal
|
| 764 |
+
self.scaler = scaler
|
| 765 |
+
self.model_metrics = metrics
|
| 766 |
+
|
| 767 |
+
with open(MODEL_CACHE, "wb") as f:
|
| 768 |
+
pickle.dump({
|
| 769 |
+
"model": cal,
|
| 770 |
+
"scaler": scaler,
|
| 771 |
+
"metrics": metrics,
|
| 772 |
+
"overlapping_conds": self.overlapping_conds,
|
| 773 |
+
}, f)
|
| 774 |
+
print("[pipeline] Model saved to cache.")
|
| 775 |
+
return self
|
| 776 |
+
|
| 777 |
+
# ------------------------------------------------------------------
|
| 778 |
+
# Match
|
| 779 |
+
# ------------------------------------------------------------------
|
| 780 |
+
|
| 781 |
+
def match_patient(self, conditions: list, age: float, gender: str,
|
| 782 |
+
top_k: int = 20, active_only: bool = True,
|
| 783 |
+
patient_id: str = None, address: str = "") -> list:
|
| 784 |
+
if self.model is None:
|
| 785 |
+
raise RuntimeError("Model not trained. Call .train() first.")
|
| 786 |
+
|
| 787 |
+
input_conds = {c.lower().strip() for c in conditions}
|
| 788 |
+
matched_conds = input_conds & self.overlapping_conds
|
| 789 |
+
if not matched_conds:
|
| 790 |
+
return []
|
| 791 |
+
|
| 792 |
+
# Candidate trials
|
| 793 |
+
candidate_tids = set()
|
| 794 |
+
for c in matched_conds:
|
| 795 |
+
candidate_tids.update(self.cond_to_trials.get(c, []))
|
| 796 |
+
if not candidate_tids:
|
| 797 |
+
return []
|
| 798 |
+
|
| 799 |
+
total_c = len(input_conds)
|
| 800 |
+
active_c = int(total_c * 0.7)
|
| 801 |
+
gender = str(gender).upper().strip()
|
| 802 |
+
|
| 803 |
+
pat_med_kws = self.patient_med_keywords.get(patient_id, set()) if patient_id else set()
|
| 804 |
+
pat_lab = self.patient_lab_score.get(patient_id, 0.3) if patient_id else 0.3
|
| 805 |
+
|
| 806 |
+
# State-level geo: prefer provided address, fall back to Synthea lookup
|
| 807 |
+
patient_state_full = _extract_state(address) if address else ""
|
| 808 |
+
if not patient_state_full and patient_id:
|
| 809 |
+
patient_state_full = self.patient_address_state.get(patient_id, "")
|
| 810 |
+
|
| 811 |
+
rows = []
|
| 812 |
+
for tid in candidate_tids:
|
| 813 |
+
if tid not in self.trial_profiles:
|
| 814 |
+
continue
|
| 815 |
+
tp = self.trial_profiles[tid]
|
| 816 |
+
if active_only and not tp["is_active"]:
|
| 817 |
+
continue
|
| 818 |
+
|
| 819 |
+
feats = _compute_features(
|
| 820 |
+
matched_conds, age, gender,
|
| 821 |
+
tp["conditions"], tp["min_age"], tp["max_age"], tp["sex"],
|
| 822 |
+
total_c, active_c, len(tp["conditions"]),
|
| 823 |
+
self.cond_rarity_map,
|
| 824 |
+
self.trial_us_states.get(tid, set()),
|
| 825 |
+
pat_med_kws,
|
| 826 |
+
self.trial_drug_keywords.get(tid, set()),
|
| 827 |
+
pat_lab,
|
| 828 |
+
patient_state_full=patient_state_full,
|
| 829 |
+
)
|
| 830 |
+
feats["Trial_ID"] = tid
|
| 831 |
+
rows.append(feats)
|
| 832 |
+
|
| 833 |
+
if not rows:
|
| 834 |
+
return []
|
| 835 |
+
|
| 836 |
+
df = pd.DataFrame(rows)
|
| 837 |
+
X = df[FEATURE_COLS].fillna(0)
|
| 838 |
+
|
| 839 |
+
probs = self.model.predict_proba(X)[:, 1]
|
| 840 |
+
df["eligibility_probability"] = probs
|
| 841 |
+
df["combined_score"] = 0.6 * probs + 0.4 * (df["match_score"] / 100.0)
|
| 842 |
+
|
| 843 |
+
df_sorted = df.sort_values("combined_score", ascending=False).head(top_k)
|
| 844 |
+
|
| 845 |
+
results = []
|
| 846 |
+
for _, row in df_sorted.iterrows():
|
| 847 |
+
tid = row["Trial_ID"]
|
| 848 |
+
tp = self.trial_profiles[tid]
|
| 849 |
+
overlap_conds = sorted(matched_conds & tp["conditions"])
|
| 850 |
+
|
| 851 |
+
summary = ""
|
| 852 |
+
if self.trial_summaries_df is not None and not self.trial_summaries_df.empty:
|
| 853 |
+
s_rows = self.trial_summaries_df[self.trial_summaries_df["Trial_ID"] == tid]
|
| 854 |
+
if not s_rows.empty:
|
| 855 |
+
col = "Brief_Summary" if "Brief_Summary" in s_rows.columns else s_rows.columns[-1]
|
| 856 |
+
summary = str(s_rows.iloc[0][col])[:400]
|
| 857 |
+
|
| 858 |
+
location = ""
|
| 859 |
+
facility_name = ""
|
| 860 |
+
n_sites = 0
|
| 861 |
+
if self.trial_facilities_df is not None and not self.trial_facilities_df.empty:
|
| 862 |
+
f_rows = self.trial_facilities_df[self.trial_facilities_df["Trial_ID"] == tid]
|
| 863 |
+
if not f_rows.empty:
|
| 864 |
+
# Prefer US sites first, fall back to first row
|
| 865 |
+
us_rows = f_rows[f_rows.get("Facility_Country", pd.Series(dtype=str))
|
| 866 |
+
.str.strip().str.lower()
|
| 867 |
+
.isin(["united states", "usa", "us"])] \
|
| 868 |
+
if "Facility_Country" in f_rows.columns else pd.DataFrame()
|
| 869 |
+
r = us_rows.iloc[0] if not us_rows.empty else f_rows.iloc[0]
|
| 870 |
+
parts = [str(r.get(c, "")) for c in
|
| 871 |
+
["Facility_City", "Facility_State", "Facility_Country"]
|
| 872 |
+
if str(r.get(c, "")).strip() not in ("", "nan")]
|
| 873 |
+
location = ", ".join(parts)
|
| 874 |
+
if "Facility_Name" in r.index:
|
| 875 |
+
fn = str(r.get("Facility_Name", "")).strip()
|
| 876 |
+
facility_name = fn if fn not in ("", "nan") else ""
|
| 877 |
+
n_sites = len(f_rows)
|
| 878 |
+
|
| 879 |
+
results.append({
|
| 880 |
+
"trial_id": tid,
|
| 881 |
+
"title": tp["title"],
|
| 882 |
+
"phase": tp["phase"],
|
| 883 |
+
"status": tp["status"],
|
| 884 |
+
"min_age": int(tp["min_age"]),
|
| 885 |
+
"max_age": int(tp["max_age"]),
|
| 886 |
+
"sex": tp["sex"],
|
| 887 |
+
"enrollment": tp["enrollment"],
|
| 888 |
+
"start_date": tp["start_date"],
|
| 889 |
+
"eligibility_probability": round(float(row["eligibility_probability"]) * 100, 1),
|
| 890 |
+
"match_score": round(float(row["match_score"]), 1),
|
| 891 |
+
"combined_score": round(float(row["combined_score"]) * 100, 1),
|
| 892 |
+
"jaccard_similarity": round(float(row["jaccard_similarity"]), 3),
|
| 893 |
+
"age_compatibility": round(float(row["age_compatibility"]) * 100, 1),
|
| 894 |
+
"gender_compatibility": round(float(row["gender_compatibility"]) * 100, 1),
|
| 895 |
+
"geo_feasibility": round(float(row["geo_feasibility"]) * 100, 1),
|
| 896 |
+
"med_compatibility": round(float(row["med_compatibility"]) * 100, 1),
|
| 897 |
+
"condition_rarity_score": round(float(row["condition_rarity_score"]), 3),
|
| 898 |
+
"overlap_conditions": overlap_conds,
|
| 899 |
+
"trial_conditions": sorted(tp["conditions"]),
|
| 900 |
+
"criteria": tp["criteria"],
|
| 901 |
+
"summary": summary,
|
| 902 |
+
"location": location,
|
| 903 |
+
"facility_name": facility_name,
|
| 904 |
+
"n_sites": n_sites,
|
| 905 |
+
})
|
| 906 |
+
|
| 907 |
+
return results
|
| 908 |
+
|
| 909 |
+
# ------------------------------------------------------------------
|
| 910 |
+
# Hospital trial dashboard
|
| 911 |
+
# ------------------------------------------------------------------
|
| 912 |
+
|
| 913 |
+
def trials_for_hospital(self, hospital_name: str, location: str,
|
| 914 |
+
research_conditions: list, top_k: int = 20) -> list:
|
| 915 |
+
"""
|
| 916 |
+
Return trials relevant to this hospital using 3-tier logic (reversed from
|
| 917 |
+
hospitals-for-trial): Tier 1 = Jaccard name match, Tier 2 = same state,
|
| 918 |
+
Tier 3 = research-condition overlap.
|
| 919 |
+
"""
|
| 920 |
+
h_tokens = _name_tokens(hospital_name)
|
| 921 |
+
|
| 922 |
+
m = re.search(r",\s*([A-Z]{2})\s*$", str(location).strip())
|
| 923 |
+
h_state = STATE_ABBREV.get(m.group(1), "") if m else ""
|
| 924 |
+
rc_set = {r.lower().strip() for r in (research_conditions or []) if r}
|
| 925 |
+
|
| 926 |
+
collected: list[tuple[str, int]] = []
|
| 927 |
+
seen: set = set()
|
| 928 |
+
|
| 929 |
+
# Tier 1: facility-name Jaccard β₯ 0.25
|
| 930 |
+
for trial_id, fac_list in self.trial_facility_tokens.items():
|
| 931 |
+
if trial_id not in self.trial_profiles:
|
| 932 |
+
continue
|
| 933 |
+
if not self.trial_profiles[trial_id].get("is_active", False):
|
| 934 |
+
continue
|
| 935 |
+
best = 0.0
|
| 936 |
+
for ft in fac_list:
|
| 937 |
+
if h_tokens and ft:
|
| 938 |
+
inter = len(h_tokens & ft)
|
| 939 |
+
union = len(h_tokens | ft)
|
| 940 |
+
s = inter / union if union else 0.0
|
| 941 |
+
if s > best:
|
| 942 |
+
best = s
|
| 943 |
+
if best >= 0.25:
|
| 944 |
+
collected.append((trial_id, 1))
|
| 945 |
+
seen.add(trial_id)
|
| 946 |
+
|
| 947 |
+
# Tier 2: same state β cap at 2Γtop_k additional to avoid runaway
|
| 948 |
+
if h_state and len(collected) < top_k:
|
| 949 |
+
t2_cap = top_k * 2
|
| 950 |
+
t2_added = 0
|
| 951 |
+
for trial_id, states in self.trial_us_states.items():
|
| 952 |
+
if t2_added >= t2_cap:
|
| 953 |
+
break
|
| 954 |
+
if trial_id in seen or trial_id not in self.trial_profiles:
|
| 955 |
+
continue
|
| 956 |
+
if not self.trial_profiles[trial_id].get("is_active", False):
|
| 957 |
+
continue
|
| 958 |
+
if h_state in states:
|
| 959 |
+
collected.append((trial_id, 2))
|
| 960 |
+
seen.add(trial_id)
|
| 961 |
+
t2_added += 1
|
| 962 |
+
|
| 963 |
+
# Tier 3: research-condition overlap β fill up to top_k
|
| 964 |
+
if rc_set and len(collected) < top_k:
|
| 965 |
+
t3_cap = top_k * 2
|
| 966 |
+
t3_added = 0
|
| 967 |
+
for cond in rc_set:
|
| 968 |
+
for trial_id in self.cond_to_trials.get(cond, []):
|
| 969 |
+
if t3_added >= t3_cap:
|
| 970 |
+
break
|
| 971 |
+
if trial_id in seen or trial_id not in self.trial_profiles:
|
| 972 |
+
continue
|
| 973 |
+
if not self.trial_profiles[trial_id].get("is_active", False):
|
| 974 |
+
continue
|
| 975 |
+
collected.append((trial_id, 3))
|
| 976 |
+
seen.add(trial_id)
|
| 977 |
+
t3_added += 1
|
| 978 |
+
|
| 979 |
+
collected.sort(key=lambda x: x[1])
|
| 980 |
+
collected = collected[:top_k]
|
| 981 |
+
|
| 982 |
+
_REASON = {1: "verified trial site", 2: "trial in your state",
|
| 983 |
+
3: "matches your research conditions"}
|
| 984 |
+
|
| 985 |
+
output = []
|
| 986 |
+
for trial_id, tier in collected:
|
| 987 |
+
tp = self.trial_profiles[trial_id]
|
| 988 |
+
|
| 989 |
+
summary = ""
|
| 990 |
+
if self.trial_summaries_df is not None and not self.trial_summaries_df.empty:
|
| 991 |
+
s_rows = self.trial_summaries_df[self.trial_summaries_df["Trial_ID"] == trial_id]
|
| 992 |
+
if not s_rows.empty:
|
| 993 |
+
col = "Brief_Summary" if "Brief_Summary" in s_rows.columns else s_rows.columns[-1]
|
| 994 |
+
summary = str(s_rows.iloc[0][col])[:300]
|
| 995 |
+
|
| 996 |
+
location_str = facility_name = ""
|
| 997 |
+
n_sites = 0
|
| 998 |
+
if self.trial_facilities_df is not None and not self.trial_facilities_df.empty:
|
| 999 |
+
f_rows = self.trial_facilities_df[self.trial_facilities_df["Trial_ID"] == trial_id]
|
| 1000 |
+
if not f_rows.empty:
|
| 1001 |
+
n_sites = len(f_rows)
|
| 1002 |
+
us_rows = f_rows[f_rows["Facility_Country"].str.strip().str.lower()
|
| 1003 |
+
.isin(["united states", "usa", "us"])] \
|
| 1004 |
+
if "Facility_Country" in f_rows.columns else pd.DataFrame()
|
| 1005 |
+
r = us_rows.iloc[0] if not us_rows.empty else f_rows.iloc[0]
|
| 1006 |
+
parts = [str(r.get(c, "")) for c in
|
| 1007 |
+
["Facility_City", "Facility_State", "Facility_Country"]
|
| 1008 |
+
if str(r.get(c, "")).strip() not in ("", "nan")]
|
| 1009 |
+
location_str = ", ".join(parts)
|
| 1010 |
+
if "Facility_Name" in r.index:
|
| 1011 |
+
fn = str(r.get("Facility_Name", "")).strip()
|
| 1012 |
+
facility_name = fn if fn not in ("", "nan") else ""
|
| 1013 |
+
|
| 1014 |
+
output.append({
|
| 1015 |
+
"trial_id": trial_id,
|
| 1016 |
+
"title": tp["title"],
|
| 1017 |
+
"phase": tp["phase"],
|
| 1018 |
+
"status": tp["status"],
|
| 1019 |
+
"min_age": int(tp["min_age"]),
|
| 1020 |
+
"max_age": int(tp["max_age"]),
|
| 1021 |
+
"sex": tp["sex"],
|
| 1022 |
+
"enrollment": tp["enrollment"],
|
| 1023 |
+
"conditions": sorted(tp["conditions"]),
|
| 1024 |
+
"summary": summary,
|
| 1025 |
+
"location": location_str,
|
| 1026 |
+
"facility_name": facility_name,
|
| 1027 |
+
"n_sites": n_sites,
|
| 1028 |
+
"match_tier": tier,
|
| 1029 |
+
"match_reason": _REASON[tier],
|
| 1030 |
+
})
|
| 1031 |
+
|
| 1032 |
+
return output
|
| 1033 |
+
|
| 1034 |
+
# ------------------------------------------------------------------
|
| 1035 |
+
# Patient lookup
|
| 1036 |
+
# ------------------------------------------------------------------
|
| 1037 |
+
|
| 1038 |
+
def get_patient(self, patient_id: str):
|
| 1039 |
+
if patient_id not in self.patient_profiles:
|
| 1040 |
+
return None
|
| 1041 |
+
prof = self.patient_profiles[patient_id]
|
| 1042 |
+
det = self.patient_details_df[self.patient_details_df["Patient_ID"] == patient_id]
|
| 1043 |
+
if det.empty:
|
| 1044 |
+
return None
|
| 1045 |
+
row = det.iloc[0]
|
| 1046 |
+
|
| 1047 |
+
all_conds = sorted(
|
| 1048 |
+
self.patient_conditions_df[
|
| 1049 |
+
self.patient_conditions_df["Patient_ID"] == patient_id
|
| 1050 |
+
]["Condition_Name"].dropna().unique().tolist()
|
| 1051 |
+
)
|
| 1052 |
+
|
| 1053 |
+
return {
|
| 1054 |
+
"patient_id": patient_id,
|
| 1055 |
+
"age": int(prof["Patient_Age"]) if pd.notna(prof["Patient_Age"]) else None,
|
| 1056 |
+
"gender": str(prof["Gender"]),
|
| 1057 |
+
"first_name": str(row.get("First_Name", "")),
|
| 1058 |
+
"last_name": str(row.get("Last_Name", "")),
|
| 1059 |
+
"race": str(row.get("Race", "")),
|
| 1060 |
+
"ethnicity": str(row.get("Ethnicity", "")),
|
| 1061 |
+
"address": str(row.get("Address", "")),
|
| 1062 |
+
"conditions": all_conds,
|
| 1063 |
+
"matching_conditions": sorted(prof["patient_conds"]),
|
| 1064 |
+
"medications": sorted(self.patient_med_keywords.get(patient_id, set())),
|
| 1065 |
+
"has_lab_data": patient_id in self.patient_lab_score,
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
def search_patients(self, query: str, limit: int = 20) -> list:
|
| 1069 |
+
query_l = query.lower().strip()
|
| 1070 |
+
results = []
|
| 1071 |
+
for pid, prof in self.patient_profiles.items():
|
| 1072 |
+
if query_l in pid.lower():
|
| 1073 |
+
det = self.patient_details_df[self.patient_details_df["Patient_ID"] == pid]
|
| 1074 |
+
if not det.empty:
|
| 1075 |
+
r = det.iloc[0]
|
| 1076 |
+
results.append({
|
| 1077 |
+
"patient_id": pid,
|
| 1078 |
+
"age": int(prof["Patient_Age"]) if pd.notna(prof["Patient_Age"]) else None,
|
| 1079 |
+
"gender": str(prof["Gender"]),
|
| 1080 |
+
"first_name": str(r.get("First_Name", "")),
|
| 1081 |
+
"last_name": str(r.get("Last_Name", "")),
|
| 1082 |
+
"n_conditions": len(prof["patient_conds"]),
|
| 1083 |
+
})
|
| 1084 |
+
if len(results) >= limit:
|
| 1085 |
+
break
|
| 1086 |
+
return results
|
| 1087 |
+
|
| 1088 |
+
# ------------------------------------------------------------------
|
| 1089 |
+
# MIMIC Validation
|
| 1090 |
+
# ------------------------------------------------------------------
|
| 1091 |
+
|
| 1092 |
+
def _map_icd_to_overlapping(self, icd_long_titles: list) -> list:
|
| 1093 |
+
matched = set()
|
| 1094 |
+
for title in icd_long_titles:
|
| 1095 |
+
t = title.lower().strip()
|
| 1096 |
+
if t in self.overlapping_conds:
|
| 1097 |
+
matched.add(t)
|
| 1098 |
+
continue
|
| 1099 |
+
for cond in self.overlapping_conds:
|
| 1100 |
+
if cond in t or t in cond:
|
| 1101 |
+
matched.add(cond)
|
| 1102 |
+
break
|
| 1103 |
+
t_words = set(t.split())
|
| 1104 |
+
for cond in self.overlapping_conds:
|
| 1105 |
+
c_words = set(cond.split())
|
| 1106 |
+
shorter = min(len(t_words), len(c_words))
|
| 1107 |
+
if shorter > 0 and len(t_words & c_words) / shorter >= 0.75:
|
| 1108 |
+
matched.add(cond)
|
| 1109 |
+
return list(matched)
|
| 1110 |
+
|
| 1111 |
+
def validate_mimic(self) -> list:
|
| 1112 |
+
print("[pipeline] Running MIMIC-IV validation β¦")
|
| 1113 |
+
patients = pd.read_csv(MIMIC_PATIENTS_PATH)
|
| 1114 |
+
diagnoses = pd.read_csv(MIMIC_DIAGNOSES_PATH)
|
| 1115 |
+
icd_dict = pd.read_csv(MIMIC_ICD_DICT_PATH)
|
| 1116 |
+
|
| 1117 |
+
icd_map = icd_dict.set_index(["icd_code", "icd_version"])["long_title"].to_dict()
|
| 1118 |
+
diagnoses["condition_name"] = diagnoses.apply(
|
| 1119 |
+
lambda r: icd_map.get((r["icd_code"], r["icd_version"]),
|
| 1120 |
+
str(r["icd_code"])).lower(), axis=1
|
| 1121 |
+
)
|
| 1122 |
+
|
| 1123 |
+
results = []
|
| 1124 |
+
for _, pat in patients.iterrows():
|
| 1125 |
+
sid = int(pat["subject_id"])
|
| 1126 |
+
age = float(pat["anchor_age"])
|
| 1127 |
+
gender = "M" if pat["gender"] == "M" else "F"
|
| 1128 |
+
|
| 1129 |
+
raw_conds = diagnoses[diagnoses["subject_id"] == sid]["condition_name"].dropna().unique().tolist()
|
| 1130 |
+
mapped_conds = self._map_icd_to_overlapping(raw_conds)
|
| 1131 |
+
all_conds = list(set(raw_conds + mapped_conds))
|
| 1132 |
+
|
| 1133 |
+
matches = self.match_patient(all_conds, age, gender, top_k=5)
|
| 1134 |
+
|
| 1135 |
+
results.append({
|
| 1136 |
+
"subject_id": sid,
|
| 1137 |
+
"age": int(age),
|
| 1138 |
+
"gender": gender,
|
| 1139 |
+
"n_conditions": len(raw_conds),
|
| 1140 |
+
"conditions": raw_conds[:5],
|
| 1141 |
+
"mapped_conditions": mapped_conds[:5],
|
| 1142 |
+
"n_matches": len(matches),
|
| 1143 |
+
"top_match": matches[0] if matches else None,
|
| 1144 |
+
})
|
| 1145 |
+
return results
|
| 1146 |
+
|
| 1147 |
+
# ------------------------------------------------------------------
|
| 1148 |
+
# Autocomplete
|
| 1149 |
+
# ------------------------------------------------------------------
|
| 1150 |
+
|
| 1151 |
+
def condition_autocomplete(self, query: str, limit: int = 15) -> list:
|
| 1152 |
+
q = query.lower().strip()
|
| 1153 |
+
return sorted([c for c in self.overlapping_conds if q in c])[:limit]
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.1.3
|
| 2 |
+
Werkzeug==3.1.3
|
| 3 |
+
numpy==2.4.3
|
| 4 |
+
pandas==3.0.1
|
| 5 |
+
scikit-learn==1.8.0
|
| 6 |
+
huggingface_hub==1.11.0
|
secondlife.db
ADDED
|
Binary file (81.9 kB). View file
|
|
|
static/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
templates/hospital.html
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-bs-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Hospital Portal β Second Life</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body{background:#0d1117;}
|
| 10 |
+
.patient-card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:1.25rem;height:100%;}
|
| 11 |
+
.form-control,.form-select{background:#0d1117 !important;border-color:#30363d;color:#e6edf3 !important;}
|
| 12 |
+
.form-control:focus{border-color:#58a6ff !important;box-shadow:0 0 0 .2rem rgba(88,166,255,.2) !important;}
|
| 13 |
+
.autocomplete-list{position:absolute;z-index:1000;width:100%;background:#161b22;border:1px solid #30363d;
|
| 14 |
+
border-radius:0 0 6px 6px;max-height:190px;overflow-y:auto;display:none;list-style:none;padding:0;margin:0;}
|
| 15 |
+
.autocomplete-list li{padding:.3rem .6rem;cursor:pointer;font-size:.85rem;color:#c9d1d9;}
|
| 16 |
+
.autocomplete-list li:hover{background:#21262d;}
|
| 17 |
+
.st-pending{color:#d29922;}.st-accepted{color:#2ea043;}.st-rejected{color:#f85149;}.st-completed{color:#58a6ff;}
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<nav class="navbar border-bottom border-secondary px-3 py-2" style="background:#161b22">
|
| 22 |
+
<span class="navbar-brand fw-bold" style="color:#58a6ff">⬑ Second Life</span>
|
| 23 |
+
<div class="d-flex align-items-center gap-3">
|
| 24 |
+
<span class="text-light small">{{ hospital.hospital_name }}</span>
|
| 25 |
+
<span class="badge bg-success">Hospital</span>
|
| 26 |
+
<button class="btn btn-sm btn-outline-secondary" id="logoutBtn">Logout</button>
|
| 27 |
+
</div>
|
| 28 |
+
</nav>
|
| 29 |
+
|
| 30 |
+
<div class="container-xl py-3">
|
| 31 |
+
<ul class="nav nav-tabs mb-4" id="mainTabs">
|
| 32 |
+
<li class="nav-item"><button class="nav-link active" data-tab="patients">Available Patients</button></li>
|
| 33 |
+
<li class="nav-item"><button class="nav-link" data-tab="search">Search by Condition</button></li>
|
| 34 |
+
<li class="nav-item"><button class="nav-link" data-tab="trials">My Trials</button></li>
|
| 35 |
+
<li class="nav-item"><button class="nav-link" data-tab="inbox">Inbox <span id="inboxBadge" class="badge bg-danger ms-1" style="display:none">0</span></button></li>
|
| 36 |
+
<li class="nav-item"><button class="nav-link" data-tab="connections">My Connections</button></li>
|
| 37 |
+
<li class="nav-item"><button class="nav-link" data-tab="profile">My Profile</button></li>
|
| 38 |
+
</ul>
|
| 39 |
+
|
| 40 |
+
<!-- ===== AVAILABLE PATIENTS ===== -->
|
| 41 |
+
<div id="tab-patients">
|
| 42 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 43 |
+
<div>
|
| 44 |
+
<h6 class="mb-0">Patients Open to Trials</h6>
|
| 45 |
+
<small class="text-muted">Patients who have opted in and are not yet connected to your hospital</small>
|
| 46 |
+
</div>
|
| 47 |
+
<button class="btn btn-sm btn-primary" id="refreshPatientsBtn">Refresh</button>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="alert alert-secondary py-2 small mb-3">
|
| 50 |
+
Only patients with <strong>Open to Trials</strong> toggled on and no existing connection with your hospital appear here.
|
| 51 |
+
</div>
|
| 52 |
+
<div id="pLoading" class="text-center py-4" style="display:none">
|
| 53 |
+
<div class="spinner-border text-primary spinner-border-sm"></div>
|
| 54 |
+
</div>
|
| 55 |
+
<div id="pEmpty" class="text-center py-5 text-muted" style="display:none">
|
| 56 |
+
No patients are currently open to trials.
|
| 57 |
+
</div>
|
| 58 |
+
<div class="row g-3" id="pGrid"></div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- ===== SEARCH ===== -->
|
| 62 |
+
<div id="tab-search" style="display:none">
|
| 63 |
+
<div class="row g-3 align-items-end mb-3">
|
| 64 |
+
<div class="col-md-6">
|
| 65 |
+
<label class="form-label small fw-bold">Search by Medical Condition</label>
|
| 66 |
+
<div class="position-relative">
|
| 67 |
+
<input type="text" class="form-control" id="searchInput"
|
| 68 |
+
placeholder="e.g. hypertension, diabetesβ¦" autocomplete="off">
|
| 69 |
+
<ul class="autocomplete-list" id="searchAutoList"></ul>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="col-md-2">
|
| 73 |
+
<button class="btn btn-primary w-100" id="searchBtn">Search</button>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="col-md-4">
|
| 76 |
+
<div class="form-check mb-1">
|
| 77 |
+
<input class="form-check-input" type="checkbox" id="searchIncludeConnected">
|
| 78 |
+
<label class="form-check-label small" for="searchIncludeConnected">
|
| 79 |
+
Include patients already connected to your hospital
|
| 80 |
+
</label>
|
| 81 |
+
</div>
|
| 82 |
+
<small class="text-muted">Default: only unconnected patients shown</small>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
<div id="sLoading" class="text-center py-4" style="display:none">
|
| 86 |
+
<div class="spinner-border text-primary spinner-border-sm"></div>
|
| 87 |
+
</div>
|
| 88 |
+
<div id="sEmpty" class="text-center py-5 text-muted" style="display:none">
|
| 89 |
+
No matching patients found for this condition.
|
| 90 |
+
</div>
|
| 91 |
+
<div class="row g-3" id="sGrid"></div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- ===== MY TRIALS ===== -->
|
| 95 |
+
<div id="tab-trials" style="display:none">
|
| 96 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 97 |
+
<div>
|
| 98 |
+
<h6 class="mb-0">Trials Relevant to Your Hospital</h6>
|
| 99 |
+
<small class="text-muted">Matched by facility name, state, or your research conditions</small>
|
| 100 |
+
</div>
|
| 101 |
+
<button class="btn btn-sm btn-primary" id="refreshTrialsBtn">Refresh</button>
|
| 102 |
+
</div>
|
| 103 |
+
<div id="tLoading" class="text-center py-4" style="display:none">
|
| 104 |
+
<div class="spinner-border text-primary spinner-border-sm"></div>
|
| 105 |
+
</div>
|
| 106 |
+
<div id="tEmpty" class="text-center py-5 text-muted" style="display:none">
|
| 107 |
+
No trials found. Update your profile with your location and research conditions.
|
| 108 |
+
</div>
|
| 109 |
+
<div class="row g-2" id="tGrid"></div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- ===== INBOX ===== -->
|
| 113 |
+
<div id="tab-inbox" style="display:none">
|
| 114 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 115 |
+
<h6 class="mb-0">Inbox</h6>
|
| 116 |
+
<button class="btn btn-sm btn-outline-secondary" onclick="loadInbox()">Refresh</button>
|
| 117 |
+
</div>
|
| 118 |
+
<div id="inboxLoading" class="text-center text-muted py-4">Loadingβ¦</div>
|
| 119 |
+
<div id="inboxEmpty" class="text-center text-muted py-4" style="display:none">No message threads yet. Accept a connection and start chatting.</div>
|
| 120 |
+
<div id="inboxList"></div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<!-- ===== PROFILE ===== -->
|
| 124 |
+
<div id="tab-profile" style="display:none">
|
| 125 |
+
<div class="row justify-content-center">
|
| 126 |
+
<div class="col-lg-6">
|
| 127 |
+
<div class="patient-card">
|
| 128 |
+
<h6 class="mb-3">Hospital Profile</h6>
|
| 129 |
+
<div class="mb-3">
|
| 130 |
+
<label class="form-label small fw-bold">Hospital / Institution Name</label>
|
| 131 |
+
<input type="text" class="form-control" id="profName">
|
| 132 |
+
</div>
|
| 133 |
+
<div class="mb-3">
|
| 134 |
+
<label class="form-label small fw-bold">Location <span class="text-muted fw-normal">(City, ST β e.g. Boston, MA)</span></label>
|
| 135 |
+
<input type="text" class="form-control" id="profLoc">
|
| 136 |
+
</div>
|
| 137 |
+
<div class="mb-3">
|
| 138 |
+
<label class="form-label small fw-bold">Research Conditions</label>
|
| 139 |
+
<div class="d-flex gap-2 mb-2">
|
| 140 |
+
<input type="text" class="form-control form-control-sm" id="profCondInput"
|
| 141 |
+
placeholder="Type a condition and press Enter" autocomplete="off">
|
| 142 |
+
</div>
|
| 143 |
+
<div id="profCondTags" class="d-flex flex-wrap gap-1 mb-1"></div>
|
| 144 |
+
</div>
|
| 145 |
+
<div id="profMsg" class="small mb-2" style="min-height:1.2rem"></div>
|
| 146 |
+
<button class="btn btn-success" id="saveProfileBtn">Save Changes</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- ===== CONNECTIONS ===== -->
|
| 153 |
+
<div id="tab-connections" style="display:none">
|
| 154 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 155 |
+
<h6 class="mb-0">Connection Requests</h6>
|
| 156 |
+
<button class="btn btn-sm btn-outline-secondary" id="refreshConnsBtn">Refresh</button>
|
| 157 |
+
</div>
|
| 158 |
+
<div id="cEmpty" class="text-center py-5 text-muted" style="display:none">
|
| 159 |
+
No connection requests yet.
|
| 160 |
+
</div>
|
| 161 |
+
<div id="cWrap" style="display:none">
|
| 162 |
+
<div class="table-responsive">
|
| 163 |
+
<table class="table table-dark table-hover table-sm align-middle">
|
| 164 |
+
<thead><tr>
|
| 165 |
+
<th>Patient</th><th>Age/Gender</th><th>Conditions</th>
|
| 166 |
+
<th>Trial</th><th>Initiated</th><th>Status</th><th>Message</th><th>Action</th><th>Chat</th>
|
| 167 |
+
</tr></thead>
|
| 168 |
+
<tbody id="cTbody"></tbody>
|
| 169 |
+
</table>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<!-- Connect with Patient Modal -->
|
| 176 |
+
<div class="modal fade" id="connectModal" tabindex="-1">
|
| 177 |
+
<div class="modal-dialog">
|
| 178 |
+
<div class="modal-content border-secondary" style="background:#161b22">
|
| 179 |
+
<div class="modal-header border-secondary">
|
| 180 |
+
<h5 class="modal-title">Connect with Patient</h5>
|
| 181 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="modal-body">
|
| 184 |
+
<p class="text-muted small" id="modalPatientInfo"></p>
|
| 185 |
+
<label class="form-label small">Message <span class="text-muted">(optional)</span></label>
|
| 186 |
+
<textarea class="form-control form-control-sm" id="modalMsg" rows="3"
|
| 187 |
+
placeholder="Introduce your trial or research programβ¦"></textarea>
|
| 188 |
+
<div class="alert alert-info py-2 small mt-3">
|
| 189 |
+
The patient will receive your request and can choose to accept or decline.
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
<div class="modal-footer border-secondary">
|
| 193 |
+
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
| 194 |
+
<button class="btn btn-success btn-sm" id="sendConnBtn">Send Request</button>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<!-- Messages Modal -->
|
| 201 |
+
<div class="modal fade" id="msgModal" tabindex="-1">
|
| 202 |
+
<div class="modal-dialog modal-lg">
|
| 203 |
+
<div class="modal-content border-secondary" style="background:#161b22">
|
| 204 |
+
<div class="modal-header border-secondary">
|
| 205 |
+
<h5 class="modal-title" id="msgModalTitle">Messages</h5>
|
| 206 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 207 |
+
</div>
|
| 208 |
+
<div class="modal-body p-0">
|
| 209 |
+
<div id="msgThread" style="height:320px;overflow-y:auto;padding:1rem;background:#0d1117"></div>
|
| 210 |
+
<div class="p-3 border-top border-secondary d-flex gap-2">
|
| 211 |
+
<input type="text" class="form-control form-control-sm" id="msgInput"
|
| 212 |
+
placeholder="Type a messageβ¦" maxlength="2000">
|
| 213 |
+
<button class="btn btn-success btn-sm px-3" id="msgSendBtn">Send</button>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
| 221 |
+
<script>
|
| 222 |
+
const HOSPITAL = {{ hospital | tojson }};
|
| 223 |
+
let connectModal, msgModal, targetPatientId, currentConnId;
|
| 224 |
+
let profConds = [];
|
| 225 |
+
|
| 226 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 227 |
+
connectModal = new bootstrap.Modal(document.getElementById('connectModal'));
|
| 228 |
+
msgModal = new bootstrap.Modal(document.getElementById('msgModal'));
|
| 229 |
+
|
| 230 |
+
document.querySelectorAll('#mainTabs .nav-link').forEach(b =>
|
| 231 |
+
b.addEventListener('click', () => switchTab(b.dataset.tab)));
|
| 232 |
+
document.getElementById('logoutBtn').addEventListener('click', logout);
|
| 233 |
+
document.getElementById('refreshPatientsBtn').addEventListener('click', loadPatients);
|
| 234 |
+
document.getElementById('refreshConnsBtn').addEventListener('click', loadConnections);
|
| 235 |
+
document.getElementById('refreshTrialsBtn').addEventListener('click', loadTrials);
|
| 236 |
+
document.getElementById('searchBtn').addEventListener('click', doSearch);
|
| 237 |
+
document.getElementById('sendConnBtn').addEventListener('click', submitConnect);
|
| 238 |
+
document.getElementById('saveProfileBtn').addEventListener('click', saveProfile);
|
| 239 |
+
document.getElementById('msgSendBtn').addEventListener('click', sendMsg);
|
| 240 |
+
document.getElementById('msgInput').addEventListener('keydown', e => {
|
| 241 |
+
if (e.key === 'Enter') sendMsg();
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
// Profile condition tag input
|
| 245 |
+
const pci = document.getElementById('profCondInput');
|
| 246 |
+
pci.addEventListener('keydown', e => {
|
| 247 |
+
if (e.key === 'Enter' || e.key === ',') {
|
| 248 |
+
e.preventDefault();
|
| 249 |
+
const val = pci.value.trim().toLowerCase();
|
| 250 |
+
if (val && !profConds.includes(val)) { profConds.push(val); renderProfTags(); }
|
| 251 |
+
pci.value = '';
|
| 252 |
+
}
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
// Condition autocomplete (search tab)
|
| 256 |
+
const si = document.getElementById('searchInput');
|
| 257 |
+
si.addEventListener('input', () => condAuto(si.value));
|
| 258 |
+
si.addEventListener('keydown', e => { if(e.key==='Enter') doSearch(); if(e.key==='Escape') hideAuto(); });
|
| 259 |
+
document.addEventListener('click', e => { if (!si.contains(e.target)) hideAuto(); });
|
| 260 |
+
|
| 261 |
+
// Event delegation for connections table
|
| 262 |
+
document.getElementById('cTbody').addEventListener('click', e => {
|
| 263 |
+
const btn = e.target.closest('[data-cid]');
|
| 264 |
+
if (!btn) return;
|
| 265 |
+
if (btn.dataset.st) updateStatus(btn.dataset.cid, btn.dataset.st);
|
| 266 |
+
else if ('chat' in btn.dataset) openMsgModal(btn.dataset.cid, btn.dataset.chat);
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
loadPatients();
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
function switchTab(name) {
|
| 273 |
+
document.querySelectorAll('#mainTabs .nav-link').forEach(b =>
|
| 274 |
+
b.classList.toggle('active', b.dataset.tab === name));
|
| 275 |
+
['patients','search','trials','inbox','connections','profile'].forEach(t =>
|
| 276 |
+
document.getElementById('tab-'+t).style.display = t === name ? '' : 'none');
|
| 277 |
+
if (name === 'connections') loadConnections();
|
| 278 |
+
if (name === 'profile') initProfile();
|
| 279 |
+
if (name === 'trials') loadTrials();
|
| 280 |
+
if (name === 'inbox') loadInbox();
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// ββ PROFILE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 284 |
+
function initProfile() {
|
| 285 |
+
document.getElementById('profName').value = HOSPITAL.hospital_name || '';
|
| 286 |
+
document.getElementById('profLoc').value = HOSPITAL.location || '';
|
| 287 |
+
profConds = Array.isArray(HOSPITAL.research_conditions) ? [...HOSPITAL.research_conditions] : [];
|
| 288 |
+
renderProfTags();
|
| 289 |
+
document.getElementById('profMsg').textContent = '';
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function renderProfTags() {
|
| 293 |
+
const wrap = document.getElementById('profCondTags');
|
| 294 |
+
wrap.innerHTML = '';
|
| 295 |
+
profConds.forEach((c, i) => {
|
| 296 |
+
const span = document.createElement('span');
|
| 297 |
+
span.className = 'badge bg-info text-dark d-inline-flex align-items-center gap-1';
|
| 298 |
+
const t = document.createElement('span'); t.textContent = c;
|
| 299 |
+
const x = document.createElement('button');
|
| 300 |
+
x.type = 'button'; x.className = 'btn-close'; x.style.fontSize = '.45rem';
|
| 301 |
+
x.addEventListener('click', () => { profConds.splice(i, 1); renderProfTags(); });
|
| 302 |
+
span.appendChild(t); span.appendChild(x); wrap.appendChild(span);
|
| 303 |
+
});
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
async function saveProfile() {
|
| 307 |
+
const body = {
|
| 308 |
+
hospital_name: document.getElementById('profName').value.trim(),
|
| 309 |
+
location: document.getElementById('profLoc').value.trim(),
|
| 310 |
+
research_conditions: profConds,
|
| 311 |
+
};
|
| 312 |
+
const msg = document.getElementById('profMsg');
|
| 313 |
+
msg.textContent = 'Savingβ¦'; msg.style.color = '#8b949e';
|
| 314 |
+
try {
|
| 315 |
+
const r = await fetch('/api/hospital/profile', {
|
| 316 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 317 |
+
body: JSON.stringify(body),
|
| 318 |
+
});
|
| 319 |
+
const d = await r.json();
|
| 320 |
+
if (r.ok) {
|
| 321 |
+
// Update the in-memory HOSPITAL object so navbar reflects change
|
| 322 |
+
HOSPITAL.hospital_name = d.hospital_name;
|
| 323 |
+
HOSPITAL.location = d.location;
|
| 324 |
+
HOSPITAL.research_conditions = d.research_conditions;
|
| 325 |
+
document.querySelector('.navbar .text-light.small').textContent = d.hospital_name;
|
| 326 |
+
msg.textContent = 'Saved.'; msg.style.color = '#2ea043';
|
| 327 |
+
} else {
|
| 328 |
+
msg.textContent = d.error || 'Save failed'; msg.style.color = '#f85149';
|
| 329 |
+
}
|
| 330 |
+
} catch(e) { msg.textContent = 'Network error'; msg.style.color = '#f85149'; }
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// ββ PATIENTS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 334 |
+
async function loadPatients() {
|
| 335 |
+
show('pLoading'); hide('pEmpty'); document.getElementById('pGrid').innerHTML = '';
|
| 336 |
+
try {
|
| 337 |
+
const d = await fetch('/api/hospital/patients').then(r=>r.json());
|
| 338 |
+
renderGrid(d.patients||[], 'pGrid', 'pEmpty');
|
| 339 |
+
} catch(e) { show('pEmpty'); }
|
| 340 |
+
hide('pLoading');
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async function doSearch() {
|
| 344 |
+
const cond = document.getElementById('searchInput').value.trim();
|
| 345 |
+
const ic = document.getElementById('searchIncludeConnected').checked ? '1' : '0';
|
| 346 |
+
hideAuto();
|
| 347 |
+
show('sLoading'); hide('sEmpty'); document.getElementById('sGrid').innerHTML = '';
|
| 348 |
+
try {
|
| 349 |
+
const url = '/api/hospital/patients?condition='+encodeURIComponent(cond)+'&include_connected='+ic;
|
| 350 |
+
const d = await fetch(url).then(r=>r.json());
|
| 351 |
+
renderGrid(d.patients||[], 'sGrid', 'sEmpty');
|
| 352 |
+
} catch(e) { show('sEmpty'); }
|
| 353 |
+
hide('sLoading');
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
function renderGrid(patients, gridId, emptyId) {
|
| 357 |
+
const grid = document.getElementById(gridId);
|
| 358 |
+
if (!patients.length) { show(emptyId); return; }
|
| 359 |
+
hide(emptyId);
|
| 360 |
+
patients.forEach(p => {
|
| 361 |
+
const age = p.dob ? Math.floor((Date.now()-new Date(p.dob))/31557600000) : '?';
|
| 362 |
+
const conds= (p.conditions||[]).slice(0,5);
|
| 363 |
+
const col = document.createElement('div');
|
| 364 |
+
col.className = 'col-xl-3 col-lg-4 col-md-6';
|
| 365 |
+
|
| 366 |
+
const card = document.createElement('div');
|
| 367 |
+
card.className = 'patient-card d-flex flex-column';
|
| 368 |
+
|
| 369 |
+
const name = document.createElement('h6');
|
| 370 |
+
name.className = 'mb-1';
|
| 371 |
+
name.textContent = (p.first_name||'') + ' ' + (p.last_name||'');
|
| 372 |
+
|
| 373 |
+
const meta = document.createElement('div');
|
| 374 |
+
meta.className = 'text-muted small mb-2';
|
| 375 |
+
meta.textContent = `Age ${age} β’ ${p.gender||'?'}`;
|
| 376 |
+
|
| 377 |
+
const condWrap = document.createElement('div');
|
| 378 |
+
condWrap.className = 'flex-grow-1 mb-3';
|
| 379 |
+
conds.forEach(c => {
|
| 380 |
+
const badge = document.createElement('span');
|
| 381 |
+
badge.className = 'badge bg-secondary me-1 mb-1';
|
| 382 |
+
badge.textContent = c;
|
| 383 |
+
condWrap.appendChild(badge);
|
| 384 |
+
});
|
| 385 |
+
if ((p.conditions||[]).length > 5) {
|
| 386 |
+
const more = document.createElement('span');
|
| 387 |
+
more.className = 'text-muted small';
|
| 388 |
+
more.textContent = `+${p.conditions.length-5} more`;
|
| 389 |
+
condWrap.appendChild(more);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
const btn = document.createElement('button');
|
| 393 |
+
btn.className = 'btn btn-sm btn-outline-success w-100';
|
| 394 |
+
btn.textContent = 'Connect with Patient';
|
| 395 |
+
btn.addEventListener('click', () => openConnectModal(p.id, name.textContent));
|
| 396 |
+
|
| 397 |
+
card.appendChild(name); card.appendChild(meta);
|
| 398 |
+
card.appendChild(condWrap); card.appendChild(btn);
|
| 399 |
+
col.appendChild(card);
|
| 400 |
+
grid.appendChild(col);
|
| 401 |
+
});
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
// ββ CONNECT WITH PATIENT ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 405 |
+
function openConnectModal(patientId, name) {
|
| 406 |
+
targetPatientId = patientId;
|
| 407 |
+
document.getElementById('modalPatientInfo').textContent = 'Patient: ' + name;
|
| 408 |
+
document.getElementById('modalMsg').value = '';
|
| 409 |
+
connectModal.show();
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
async function submitConnect() {
|
| 413 |
+
if (!targetPatientId) return;
|
| 414 |
+
try {
|
| 415 |
+
const r = await fetch('/api/hospital/connect', {
|
| 416 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 417 |
+
body: JSON.stringify({patient_id:targetPatientId, message:document.getElementById('modalMsg').value.trim()})
|
| 418 |
+
});
|
| 419 |
+
const d = await r.json();
|
| 420 |
+
if (r.ok) { connectModal.hide(); switchTab('connections'); }
|
| 421 |
+
else alert(d.error||'Failed');
|
| 422 |
+
} catch(e) { alert('Network error'); }
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ββ CONNECTIONS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 426 |
+
async function loadConnections() {
|
| 427 |
+
try {
|
| 428 |
+
const d = await fetch('/api/hospital/connections').then(r=>r.json());
|
| 429 |
+
const conns = d.connections||[];
|
| 430 |
+
const empty = document.getElementById('cEmpty');
|
| 431 |
+
const wrap = document.getElementById('cWrap');
|
| 432 |
+
const tbody = document.getElementById('cTbody');
|
| 433 |
+
if (!conns.length) { show('cEmpty'); hide('cWrap'); return; }
|
| 434 |
+
hide('cEmpty'); show('cWrap');
|
| 435 |
+
tbody.innerHTML = '';
|
| 436 |
+
conns.forEach(c => {
|
| 437 |
+
const age = c.dob ? Math.floor((Date.now()-new Date(c.dob))/31557600000) : '?';
|
| 438 |
+
const cstr = (Array.isArray(c.conditions)?c.conditions:[]).slice(0,3).join(', ');
|
| 439 |
+
const sc = {pending:'st-pending',accepted:'st-accepted',rejected:'st-rejected',completed:'st-completed'}[c.status]||'';
|
| 440 |
+
const pname = (c.first_name||'')+' '+(c.last_name||'');
|
| 441 |
+
const tr = document.createElement('tr');
|
| 442 |
+
tr.innerHTML = [
|
| 443 |
+
escH(pname),
|
| 444 |
+
escH(age+' / '+(c.gender||'?')),
|
| 445 |
+
escH(cstr||'β'),
|
| 446 |
+
escH((c.trial_title||'β').substring(0,40)),
|
| 447 |
+
escH(c.initiated_by||''),
|
| 448 |
+
`<span class="${sc} fw-bold">${escH(c.status)}</span>`,
|
| 449 |
+
escH((c.message||'').substring(0,50)),
|
| 450 |
+
statusBtns(c.id, c.status),
|
| 451 |
+
`<button class="btn btn-sm btn-outline-info py-0 px-2" style="font-size:.72rem"
|
| 452 |
+
data-cid="${escA(c.id)}" data-chat="${escA(pname)}">π¬ Chat</button>`,
|
| 453 |
+
].map(v=>`<td>${v}</td>`).join('');
|
| 454 |
+
tbody.appendChild(tr);
|
| 455 |
+
});
|
| 456 |
+
} catch(e) {}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
function statusBtns(cid, status) {
|
| 460 |
+
const sa = escA(cid);
|
| 461 |
+
if (status === 'pending') return `
|
| 462 |
+
<div class="d-flex gap-1">
|
| 463 |
+
<button class="btn btn-sm btn-success py-0 px-2" style="font-size:.72rem" data-cid="${sa}" data-st="accepted">Accept</button>
|
| 464 |
+
<button class="btn btn-sm btn-danger py-0 px-2" style="font-size:.72rem" data-cid="${sa}" data-st="rejected">Reject</button>
|
| 465 |
+
</div>`;
|
| 466 |
+
if (status === 'accepted') return `
|
| 467 |
+
<button class="btn btn-sm btn-secondary py-0 px-2" style="font-size:.72rem" data-cid="${sa}" data-st="completed">Complete</button>`;
|
| 468 |
+
return '';
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
async function updateStatus(cid, status) {
|
| 472 |
+
await fetch(`/api/hospital/connections/${cid}/status`, {
|
| 473 |
+
method:'PUT', headers:{'Content-Type':'application/json'},
|
| 474 |
+
body: JSON.stringify({status})
|
| 475 |
+
});
|
| 476 |
+
loadConnections();
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// ββ MY TRIALS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 480 |
+
async function loadTrials() {
|
| 481 |
+
show('tLoading'); hide('tEmpty'); document.getElementById('tGrid').innerHTML = '';
|
| 482 |
+
try {
|
| 483 |
+
const d = await fetch('/api/hospital/trials').then(r => r.json());
|
| 484 |
+
const trials = d.trials || [];
|
| 485 |
+
if (!trials.length) { show('tEmpty'); hide('tLoading'); return; }
|
| 486 |
+
trials.forEach(t => {
|
| 487 |
+
const stc = t.status === 'RECRUITING' ? 'success' : t.status.includes('ACTIVE') ? 'info' : 'secondary';
|
| 488 |
+
const tierCls = t.match_tier === 1 ? 'bg-success' : 'bg-secondary';
|
| 489 |
+
const div = document.createElement('div');
|
| 490 |
+
div.className = 'col-12';
|
| 491 |
+
div.innerHTML = `<div class="patient-card mb-2">
|
| 492 |
+
<div class="d-flex flex-wrap gap-1 align-items-center mb-2">
|
| 493 |
+
<span class="badge bg-${stc}">${escH(t.status)}</span>
|
| 494 |
+
${t.phase&&t.phase!=='N/A'?`<span class="badge bg-info text-dark">${escH(t.phase)}</span>`:''}
|
| 495 |
+
<span class="badge ${tierCls} ms-auto">${escH(t.match_reason)}</span>
|
| 496 |
+
</div>
|
| 497 |
+
<h6 class="mb-1">${escH(t.title)}</h6>
|
| 498 |
+
<div class="text-muted small mb-2">${escH(t.trial_id)}</div>
|
| 499 |
+
${t.summary?`<p class="text-muted mb-2" style="font-size:.8rem">${escH(t.summary.substring(0,260)+(t.summary.length>260?'β¦':''))}</p>`:''}
|
| 500 |
+
<div class="d-flex flex-wrap gap-3 small text-muted mb-2">
|
| 501 |
+
${t.facility_name?`<span>π ${escH(t.facility_name)}</span>`:''}
|
| 502 |
+
${t.location?`<span>πΊ ${escH(t.location)}</span>`:''}
|
| 503 |
+
${t.n_sites?`<span>π₯ ${t.n_sites} site${t.n_sites!==1?'s':''}</span>`:''}
|
| 504 |
+
${t.enrollment?`<span>π₯ ${t.enrollment}</span>`:''}
|
| 505 |
+
<span>Ages ${t.min_age}β${t.max_age} Β· ${escH(t.sex||'ALL')}</span>
|
| 506 |
+
</div>
|
| 507 |
+
<a href="https://clinicaltrials.gov/study/${escA(t.trial_id)}" target="_blank" rel="noopener"
|
| 508 |
+
class="btn btn-sm btn-outline-secondary">View on ClinicalTrials.gov β</a>
|
| 509 |
+
</div>`;
|
| 510 |
+
document.getElementById('tGrid').appendChild(div);
|
| 511 |
+
});
|
| 512 |
+
} catch(e) { show('tEmpty'); }
|
| 513 |
+
hide('tLoading');
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// ββ MESSAGING βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 517 |
+
async function openMsgModal(connId, label) {
|
| 518 |
+
currentConnId = connId;
|
| 519 |
+
document.getElementById('msgModalTitle').textContent = 'Chat β ' + label;
|
| 520 |
+
document.getElementById('msgThread').innerHTML =
|
| 521 |
+
'<div class="text-center text-muted small py-4">Loadingβ¦</div>';
|
| 522 |
+
document.getElementById('msgInput').value = '';
|
| 523 |
+
msgModal.show();
|
| 524 |
+
await loadMsgs();
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
async function loadMsgs() {
|
| 528 |
+
try {
|
| 529 |
+
const d = await fetch('/api/hospital/connections/'+encodeURIComponent(currentConnId)+'/messages').then(r=>r.json());
|
| 530 |
+
renderMsgs(d.messages || []);
|
| 531 |
+
} catch(e) {
|
| 532 |
+
document.getElementById('msgThread').innerHTML =
|
| 533 |
+
'<div class="text-danger small text-center py-4">Error loading messages.</div>';
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
function renderMsgs(msgs) {
|
| 538 |
+
const thread = document.getElementById('msgThread');
|
| 539 |
+
if (!msgs.length) {
|
| 540 |
+
thread.innerHTML = '<div class="text-muted small text-center py-4">No messages yet. Start the conversation.</div>';
|
| 541 |
+
return;
|
| 542 |
+
}
|
| 543 |
+
thread.innerHTML = '';
|
| 544 |
+
msgs.forEach(m => {
|
| 545 |
+
const mine = m.sender_role === 'hospital';
|
| 546 |
+
const wrap = document.createElement('div');
|
| 547 |
+
wrap.className = 'd-flex mb-2 ' + (mine ? 'justify-content-end' : 'justify-content-start');
|
| 548 |
+
const bubble = document.createElement('div');
|
| 549 |
+
bubble.style.cssText = 'max-width:72%;padding:.5rem .75rem;border-radius:12px;font-size:.85rem;'
|
| 550 |
+
+ (mine ? 'background:#1f6feb;color:#fff;' : 'background:#21262d;color:#c9d1d9;');
|
| 551 |
+
const bodyEl = document.createElement('div'); bodyEl.textContent = m.body;
|
| 552 |
+
const ts = document.createElement('div');
|
| 553 |
+
ts.style.cssText = 'font-size:.65rem;opacity:.6;margin-top:.2rem;text-align:right';
|
| 554 |
+
ts.textContent = (m.created_at||'').replace('T',' ').substring(0,16);
|
| 555 |
+
bubble.appendChild(bodyEl); bubble.appendChild(ts);
|
| 556 |
+
wrap.appendChild(bubble); thread.appendChild(wrap);
|
| 557 |
+
});
|
| 558 |
+
thread.scrollTop = thread.scrollHeight;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
async function sendMsg() {
|
| 562 |
+
const input = document.getElementById('msgInput');
|
| 563 |
+
const body = input.value.trim();
|
| 564 |
+
if (!body || !currentConnId) return;
|
| 565 |
+
input.value = '';
|
| 566 |
+
try {
|
| 567 |
+
await fetch('/api/hospital/connections/'+encodeURIComponent(currentConnId)+'/messages', {
|
| 568 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 569 |
+
body: JSON.stringify({body})
|
| 570 |
+
});
|
| 571 |
+
await loadMsgs();
|
| 572 |
+
} catch(e) {}
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// ββ INBOX βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 576 |
+
async function loadInbox() {
|
| 577 |
+
document.getElementById('inboxLoading').style.display = '';
|
| 578 |
+
document.getElementById('inboxEmpty').style.display = 'none';
|
| 579 |
+
document.getElementById('inboxList').innerHTML = '';
|
| 580 |
+
try {
|
| 581 |
+
const d = await fetch('/api/hospital/inbox').then(r => r.json());
|
| 582 |
+
document.getElementById('inboxLoading').style.display = 'none';
|
| 583 |
+
const threads = d.threads || [];
|
| 584 |
+
if (!threads.length) { document.getElementById('inboxEmpty').style.display = ''; return; }
|
| 585 |
+
let totalUnread = 0;
|
| 586 |
+
threads.forEach(t => {
|
| 587 |
+
totalUnread += t.unread_count || 0;
|
| 588 |
+
const pname = (t.first_name || '') + ' ' + (t.last_name || '');
|
| 589 |
+
const trial = (t.trial_title || 'General Inquiry').substring(0, 60);
|
| 590 |
+
const preview = (t.last_message || 'No messages yet').substring(0, 100);
|
| 591 |
+
const card = document.createElement('div');
|
| 592 |
+
card.className = 'card bg-secondary mb-2';
|
| 593 |
+
const body = document.createElement('div');
|
| 594 |
+
body.className = 'card-body d-flex justify-content-between align-items-center gap-3';
|
| 595 |
+
const info = document.createElement('div');
|
| 596 |
+
info.style.minWidth = '0';
|
| 597 |
+
const nameEl = document.createElement('strong'); nameEl.textContent = pname;
|
| 598 |
+
info.appendChild(nameEl);
|
| 599 |
+
if (t.unread_count) {
|
| 600 |
+
const bEl = document.createElement('span');
|
| 601 |
+
bEl.className = 'badge bg-danger ms-2'; bEl.textContent = t.unread_count;
|
| 602 |
+
info.appendChild(bEl);
|
| 603 |
+
}
|
| 604 |
+
const trialEl = document.createElement('div');
|
| 605 |
+
trialEl.className = 'text-muted small text-truncate'; trialEl.textContent = trial;
|
| 606 |
+
const previewEl = document.createElement('div');
|
| 607 |
+
previewEl.className = 'text-secondary small fst-italic text-truncate'; previewEl.textContent = preview;
|
| 608 |
+
info.appendChild(trialEl); info.appendChild(previewEl);
|
| 609 |
+
const btn = document.createElement('button');
|
| 610 |
+
btn.className = 'btn btn-sm btn-outline-light flex-shrink-0';
|
| 611 |
+
btn.textContent = 'Open';
|
| 612 |
+
btn.dataset.cid = t.id; btn.dataset.chat = pname;
|
| 613 |
+
body.appendChild(info); body.appendChild(btn);
|
| 614 |
+
card.appendChild(body);
|
| 615 |
+
document.getElementById('inboxList').appendChild(card);
|
| 616 |
+
});
|
| 617 |
+
const badge = document.getElementById('inboxBadge');
|
| 618 |
+
if (totalUnread > 0) { badge.textContent = totalUnread; badge.style.display = ''; }
|
| 619 |
+
else badge.style.display = 'none';
|
| 620 |
+
} catch(e) {
|
| 621 |
+
document.getElementById('inboxLoading').style.display = 'none';
|
| 622 |
+
document.getElementById('inboxEmpty').style.display = '';
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
// ββ CONDITION AUTOCOMPLETE ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 627 |
+
async function condAuto(q) {
|
| 628 |
+
if (q.length < 2) { hideAuto(); return; }
|
| 629 |
+
try {
|
| 630 |
+
const d = await fetch('/api/conditions/autocomplete?q='+encodeURIComponent(q)).then(r=>r.json());
|
| 631 |
+
const list = document.getElementById('searchAutoList');
|
| 632 |
+
list.innerHTML = '';
|
| 633 |
+
if (!(d.results||[]).length) { list.style.display='none'; return; }
|
| 634 |
+
d.results.forEach(item => {
|
| 635 |
+
const li = document.createElement('li');
|
| 636 |
+
li.textContent = item;
|
| 637 |
+
li.addEventListener('mousedown', e => {
|
| 638 |
+
e.preventDefault();
|
| 639 |
+
document.getElementById('searchInput').value = item;
|
| 640 |
+
hideAuto(); doSearch();
|
| 641 |
+
});
|
| 642 |
+
list.appendChild(li);
|
| 643 |
+
});
|
| 644 |
+
list.style.display = 'block';
|
| 645 |
+
} catch(e) { hideAuto(); }
|
| 646 |
+
}
|
| 647 |
+
function hideAuto() { document.getElementById('searchAutoList').style.display = 'none'; }
|
| 648 |
+
|
| 649 |
+
// ββ UTILS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 650 |
+
function show(id) { document.getElementById(id).style.display = ''; }
|
| 651 |
+
function hide(id) { document.getElementById(id).style.display = 'none'; }
|
| 652 |
+
function escH(s) {
|
| 653 |
+
const d = document.createElement('div');
|
| 654 |
+
d.appendChild(document.createTextNode(String(s||'')));
|
| 655 |
+
return d.innerHTML;
|
| 656 |
+
}
|
| 657 |
+
function escA(s) {
|
| 658 |
+
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
| 659 |
+
}
|
| 660 |
+
async function logout() {
|
| 661 |
+
await fetch('/auth/logout', {method:'POST'}); location.href = '/';
|
| 662 |
+
}
|
| 663 |
+
</script>
|
| 664 |
+
</body>
|
| 665 |
+
</html>
|
templates/landing.html
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-bs-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Second Life β Clinical Trial Matching</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body{background:linear-gradient(135deg,#0d1117 0%,#161b22 100%);min-height:100vh;}
|
| 10 |
+
.hero-title{font-size:3rem;font-weight:800;background:linear-gradient(90deg,#58a6ff,#79c0ff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
|
| 11 |
+
.portal-card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:2rem;}
|
| 12 |
+
.form-control,.form-select{background:#0d1117;border-color:#30363d;color:#e6edf3;}
|
| 13 |
+
.form-control:focus,.form-select:focus{background:#0d1117;border-color:#58a6ff;color:#e6edf3;box-shadow:0 0 0 .2rem rgba(88,166,255,.25);}
|
| 14 |
+
.nav-tabs .nav-link{color:#8b949e;border-color:transparent;}
|
| 15 |
+
.nav-tabs .nav-link.active{color:#e6edf3;background:#161b22;border-color:#30363d #30363d #161b22;}
|
| 16 |
+
.err{color:#f85149;font-size:.85rem;min-height:1.2rem;}
|
| 17 |
+
</style>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<div class="container py-5">
|
| 21 |
+
<div class="text-center mb-5">
|
| 22 |
+
<div class="text-primary mb-2" style="font-size:2rem">⬑</div>
|
| 23 |
+
<h1 class="hero-title">Second Life</h1>
|
| 24 |
+
<p class="text-secondary fs-5 mb-1">AI-Powered Clinical Trial Matching</p>
|
| 25 |
+
<p class="text-muted small">Connecting patients with life-changing research opportunities</p>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="row g-4 justify-content-center">
|
| 29 |
+
<!-- Patient Portal -->
|
| 30 |
+
<div class="col-lg-5">
|
| 31 |
+
<div class="portal-card">
|
| 32 |
+
<div class="text-center mb-3" style="font-size:2.5rem">π§ββοΈ</div>
|
| 33 |
+
<h4 class="text-center mb-1">Patient Portal</h4>
|
| 34 |
+
<p class="text-muted text-center small mb-3">Find clinical trials matched to your conditions</p>
|
| 35 |
+
<ul class="nav nav-tabs mb-3" id="patientTabs">
|
| 36 |
+
<li class="nav-item"><a class="nav-link active" href="#" data-pt="p-login">Login</a></li>
|
| 37 |
+
<li class="nav-item"><a class="nav-link" href="#" data-pt="p-register">Register</a></li>
|
| 38 |
+
</ul>
|
| 39 |
+
<!-- Login -->
|
| 40 |
+
<div id="p-login">
|
| 41 |
+
<input type="text" class="form-control mb-2" id="pl-user" placeholder="Username">
|
| 42 |
+
<input type="password" class="form-control mb-3" id="pl-pass" placeholder="Password">
|
| 43 |
+
<div class="err mb-2" id="pl-err"></div>
|
| 44 |
+
<button class="btn btn-primary w-100" onclick="patientLogin()">Login as Patient</button>
|
| 45 |
+
<p class="text-muted text-center mt-2 small">Demo: john_doe / pass123</p>
|
| 46 |
+
</div>
|
| 47 |
+
<!-- Register -->
|
| 48 |
+
<div id="p-register" style="display:none">
|
| 49 |
+
<div class="row g-2 mb-2">
|
| 50 |
+
<div class="col"><input type="text" class="form-control form-control-sm" id="pr-fn" placeholder="First Name"></div>
|
| 51 |
+
<div class="col"><input type="text" class="form-control form-control-sm" id="pr-ln" placeholder="Last Name"></div>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="row g-2 mb-2">
|
| 54 |
+
<div class="col"><input type="date" class="form-control form-control-sm" id="pr-dob"></div>
|
| 55 |
+
<div class="col">
|
| 56 |
+
<select class="form-select form-select-sm" id="pr-gender">
|
| 57 |
+
<option value="">Gender</option>
|
| 58 |
+
<option value="M">Male</option>
|
| 59 |
+
<option value="F">Female</option>
|
| 60 |
+
<option value="O">Other</option>
|
| 61 |
+
</select>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<input type="text" class="form-control form-control-sm mb-2" id="pr-addr" placeholder="Address (123 Main St Boston MA 02101)">
|
| 65 |
+
<input type="text" class="form-control form-control-sm mb-2" id="pr-user" placeholder="Username">
|
| 66 |
+
<input type="password" class="form-control form-control-sm mb-3" id="pr-pass" placeholder="Password">
|
| 67 |
+
<div class="err mb-2" id="pr-err"></div>
|
| 68 |
+
<button class="btn btn-primary w-100" onclick="patientRegister()">Create Patient Account</button>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<!-- Hospital Portal -->
|
| 74 |
+
<div class="col-lg-5">
|
| 75 |
+
<div class="portal-card">
|
| 76 |
+
<div class="text-center mb-3" style="font-size:2.5rem">π₯</div>
|
| 77 |
+
<h4 class="text-center mb-1">Hospital / Research Portal</h4>
|
| 78 |
+
<p class="text-muted text-center small mb-3">Find eligible patients for your clinical trials</p>
|
| 79 |
+
<ul class="nav nav-tabs mb-3" id="hospitalTabs">
|
| 80 |
+
<li class="nav-item"><a class="nav-link active" href="#" data-ht="h-login">Login</a></li>
|
| 81 |
+
<li class="nav-item"><a class="nav-link" href="#" data-ht="h-register">Register</a></li>
|
| 82 |
+
</ul>
|
| 83 |
+
<!-- Login -->
|
| 84 |
+
<div id="h-login">
|
| 85 |
+
<input type="text" class="form-control mb-2" id="hl-user" placeholder="Username">
|
| 86 |
+
<input type="password" class="form-control mb-3" id="hl-pass" placeholder="Password">
|
| 87 |
+
<div class="err mb-2" id="hl-err"></div>
|
| 88 |
+
<button class="btn btn-success w-100" onclick="hospitalLogin()">Login as Hospital</button>
|
| 89 |
+
<p class="text-muted text-center mt-2 small">Demo: mgh / mgh123</p>
|
| 90 |
+
</div>
|
| 91 |
+
<!-- Register -->
|
| 92 |
+
<div id="h-register" style="display:none">
|
| 93 |
+
<input type="text" class="form-control form-control-sm mb-2" id="hr-name" placeholder="Hospital / Institution Name">
|
| 94 |
+
<input type="text" class="form-control form-control-sm mb-2" id="hr-loc" placeholder="Location (City, ST) e.g. Boston, MA">
|
| 95 |
+
<input type="text" class="form-control form-control-sm mb-2" id="hr-conds"
|
| 96 |
+
placeholder="Research conditions (comma-separated, e.g. diabetes, hypertension)">
|
| 97 |
+
<input type="text" class="form-control form-control-sm mb-2" id="hr-user" placeholder="Username">
|
| 98 |
+
<input type="password" class="form-control form-control-sm mb-3" id="hr-pass" placeholder="Password">
|
| 99 |
+
<div class="err mb-2" id="hr-err"></div>
|
| 100 |
+
<button class="btn btn-success w-100" onclick="hospitalRegister()">Create Hospital Account</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<p class="text-center text-muted small mt-4">DSCI 5260 | Group 7 β Second Life Clinical Trial Matching</p>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
| 110 |
+
<script>
|
| 111 |
+
// Enter key submit helpers
|
| 112 |
+
function _onEnter(inputId, fn) {
|
| 113 |
+
document.getElementById(inputId).addEventListener('keydown', e => { if(e.key==='Enter') fn(); });
|
| 114 |
+
}
|
| 115 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 116 |
+
_onEnter('pl-pass', patientLogin);
|
| 117 |
+
_onEnter('pl-user', patientLogin);
|
| 118 |
+
_onEnter('pr-pass', patientRegister);
|
| 119 |
+
_onEnter('hl-pass', hospitalLogin);
|
| 120 |
+
_onEnter('hl-user', hospitalLogin);
|
| 121 |
+
_onEnter('hr-pass', hospitalRegister);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
// Tab switching
|
| 125 |
+
document.querySelectorAll('#patientTabs .nav-link').forEach(a => {
|
| 126 |
+
a.addEventListener('click', e => {
|
| 127 |
+
e.preventDefault();
|
| 128 |
+
document.querySelectorAll('#patientTabs .nav-link').forEach(x => x.classList.remove('active'));
|
| 129 |
+
a.classList.add('active');
|
| 130 |
+
document.getElementById('p-login').style.display = a.dataset.pt === 'p-login' ? '' : 'none';
|
| 131 |
+
document.getElementById('p-register').style.display = a.dataset.pt === 'p-register' ? '' : 'none';
|
| 132 |
+
});
|
| 133 |
+
});
|
| 134 |
+
document.querySelectorAll('#hospitalTabs .nav-link').forEach(a => {
|
| 135 |
+
a.addEventListener('click', e => {
|
| 136 |
+
e.preventDefault();
|
| 137 |
+
document.querySelectorAll('#hospitalTabs .nav-link').forEach(x => x.classList.remove('active'));
|
| 138 |
+
a.classList.add('active');
|
| 139 |
+
document.getElementById('h-login').style.display = a.dataset.ht === 'h-login' ? '' : 'none';
|
| 140 |
+
document.getElementById('h-register').style.display = a.dataset.ht === 'h-register' ? '' : 'none';
|
| 141 |
+
});
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
async function patientLogin() {
|
| 145 |
+
const u = document.getElementById('pl-user').value.trim();
|
| 146 |
+
const p = document.getElementById('pl-pass').value;
|
| 147 |
+
const e = document.getElementById('pl-err');
|
| 148 |
+
e.textContent = '';
|
| 149 |
+
if (!u || !p) { e.textContent = 'Enter username and password'; return; }
|
| 150 |
+
const r = await post('/auth/patient/login', {username:u, password:p});
|
| 151 |
+
if (r.ok) location.href = '/patient'; else e.textContent = (await r.json()).error || 'Login failed';
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async function patientRegister() {
|
| 155 |
+
const body = {
|
| 156 |
+
username: document.getElementById('pr-user').value.trim(),
|
| 157 |
+
password: document.getElementById('pr-pass').value,
|
| 158 |
+
first_name: document.getElementById('pr-fn').value.trim(),
|
| 159 |
+
last_name: document.getElementById('pr-ln').value.trim(),
|
| 160 |
+
dob: document.getElementById('pr-dob').value,
|
| 161 |
+
gender: document.getElementById('pr-gender').value,
|
| 162 |
+
address: document.getElementById('pr-addr').value.trim(),
|
| 163 |
+
};
|
| 164 |
+
const e = document.getElementById('pr-err');
|
| 165 |
+
e.textContent = '';
|
| 166 |
+
if (!body.username || !body.password) { e.textContent = 'Username and password required'; return; }
|
| 167 |
+
const r = await post('/auth/patient/register', body);
|
| 168 |
+
if (r.ok) location.href = '/patient'; else e.textContent = (await r.json()).error || 'Registration failed';
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function hospitalLogin() {
|
| 172 |
+
const u = document.getElementById('hl-user').value.trim();
|
| 173 |
+
const p = document.getElementById('hl-pass').value;
|
| 174 |
+
const e = document.getElementById('hl-err');
|
| 175 |
+
e.textContent = '';
|
| 176 |
+
if (!u || !p) { e.textContent = 'Enter username and password'; return; }
|
| 177 |
+
const r = await post('/auth/hospital/login', {username:u, password:p});
|
| 178 |
+
if (r.ok) location.href = '/hospital'; else e.textContent = (await r.json()).error || 'Login failed';
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async function hospitalRegister() {
|
| 182 |
+
const condsRaw = document.getElementById('hr-conds').value.trim();
|
| 183 |
+
const body = {
|
| 184 |
+
username: document.getElementById('hr-user').value.trim(),
|
| 185 |
+
password: document.getElementById('hr-pass').value,
|
| 186 |
+
hospital_name: document.getElementById('hr-name').value.trim(),
|
| 187 |
+
location: document.getElementById('hr-loc').value.trim(),
|
| 188 |
+
research_conditions: condsRaw ? condsRaw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) : [],
|
| 189 |
+
};
|
| 190 |
+
const e = document.getElementById('hr-err');
|
| 191 |
+
e.textContent = '';
|
| 192 |
+
if (!body.username || !body.password || !body.hospital_name) { e.textContent = 'All fields required'; return; }
|
| 193 |
+
const r = await post('/auth/hospital/register', body);
|
| 194 |
+
if (r.ok) location.href = '/hospital'; else e.textContent = (await r.json()).error || 'Registration failed';
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function post(url, body) {
|
| 198 |
+
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
| 199 |
+
}
|
| 200 |
+
</script>
|
| 201 |
+
</body>
|
| 202 |
+
</html>
|
templates/patient.html
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-bs-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Patient Portal β Second Life</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body{background:#0d1117;}
|
| 10 |
+
.trial-card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:1.5rem;}
|
| 11 |
+
.score-circle{width:72px;height:72px;border-radius:50%;border:3px solid currentColor;display:flex;flex-direction:column;align-items:center;justify-content:center;font-weight:700;font-size:1.1rem;line-height:1.1;flex-shrink:0;}
|
| 12 |
+
.score-circle small{font-size:.6rem;font-weight:500;}
|
| 13 |
+
.score-high{color:#2ea043;}.score-mid{color:#d29922;}.score-low{color:#8b949e;}
|
| 14 |
+
.detail-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;background:#0d1117;border-radius:8px;padding:.75rem;}
|
| 15 |
+
@media(max-width:576px){.detail-grid{grid-template-columns:repeat(2,1fr);}}
|
| 16 |
+
.detail-grid .lbl{font-size:.7rem;color:#8b949e;text-transform:uppercase;letter-spacing:.05em;}
|
| 17 |
+
.detail-grid .val{font-size:.9rem;font-weight:600;}
|
| 18 |
+
.detail-grid .col-span-2{grid-column:span 2;}
|
| 19 |
+
.bar-section{background:#0d1117;border-radius:8px;padding:.75rem;}
|
| 20 |
+
.bar-lbl{font-size:.75rem;color:#8b949e;}.bar-val{font-size:.75rem;font-weight:600;}
|
| 21 |
+
.progress{height:5px;background:#21262d;}
|
| 22 |
+
.sec-head{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin-bottom:.5rem;font-weight:600;}
|
| 23 |
+
.autocomplete-list{position:absolute;z-index:1000;width:100%;background:#161b22;border:1px solid #30363d;border-radius:0 0 6px 6px;max-height:190px;overflow-y:auto;display:none;list-style:none;padding:0;margin:0;}
|
| 24 |
+
.autocomplete-list li{padding:.3rem .6rem;cursor:pointer;font-size:.85rem;}
|
| 25 |
+
.autocomplete-list li:hover{background:#21262d;}
|
| 26 |
+
.criteria-pre{white-space:pre-wrap;font-family:inherit;font-size:.78rem;background:#0d1117;padding:.6rem;border-radius:4px;max-height:200px;overflow-y:auto;}
|
| 27 |
+
.st-pending{color:#d29922;}.st-accepted{color:#2ea043;}.st-rejected{color:#f85149;}.st-completed{color:#58a6ff;}
|
| 28 |
+
.form-control,.form-select{background:#0d1117 !important;border-color:#30363d;color:#e6edf3 !important;}
|
| 29 |
+
.form-control:focus,.form-select:focus{border-color:#58a6ff !important;box-shadow:0 0 0 .2rem rgba(88,166,255,.2) !important;}
|
| 30 |
+
</style>
|
| 31 |
+
</head>
|
| 32 |
+
<body>
|
| 33 |
+
<!-- Navbar -->
|
| 34 |
+
<nav class="navbar border-bottom border-secondary px-3 py-2" style="background:#161b22">
|
| 35 |
+
<span class="navbar-brand fw-bold" style="color:#58a6ff">⬑ Second Life</span>
|
| 36 |
+
<div class="d-flex align-items-center gap-3">
|
| 37 |
+
<span class="text-light small">{{ patient.first_name }} {{ patient.last_name }}</span>
|
| 38 |
+
<span class="badge bg-primary">Patient</span>
|
| 39 |
+
<button class="btn btn-sm btn-outline-secondary" id="logoutBtn">Logout</button>
|
| 40 |
+
</div>
|
| 41 |
+
</nav>
|
| 42 |
+
<!-- System banner -->
|
| 43 |
+
<div id="sysBanner" class="alert alert-warning rounded-0 mb-0 py-2 text-center small" style="display:none">
|
| 44 |
+
β³ Pipeline still loading β trial matches will be available once model training completes.
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="container-xl py-3">
|
| 48 |
+
<ul class="nav nav-tabs mb-4" id="mainTabs">
|
| 49 |
+
<li class="nav-item"><button class="nav-link active" data-tab="profile">My Profile</button></li>
|
| 50 |
+
<li class="nav-item"><button class="nav-link" data-tab="trials">Suggested Trials</button></li>
|
| 51 |
+
<li class="nav-item"><button class="nav-link" data-tab="connections">My Connections</button></li>
|
| 52 |
+
<li class="nav-item"><button class="nav-link" data-tab="inbox">Inbox <span id="inboxBadge" class="badge bg-danger ms-1" style="display:none">0</span></button></li>
|
| 53 |
+
</ul>
|
| 54 |
+
|
| 55 |
+
<!-- ===== PROFILE TAB ===== -->
|
| 56 |
+
<div id="tab-profile">
|
| 57 |
+
<div class="row g-4">
|
| 58 |
+
<div class="col-lg-7">
|
| 59 |
+
<div class="card border-secondary" style="background:#161b22">
|
| 60 |
+
<div class="card-header border-secondary">Personal Information</div>
|
| 61 |
+
<div class="card-body">
|
| 62 |
+
<div class="row g-3">
|
| 63 |
+
<div class="col-sm-6">
|
| 64 |
+
<label class="form-label small">First Name</label>
|
| 65 |
+
<input type="text" class="form-control form-control-sm" id="pf-fn">
|
| 66 |
+
</div>
|
| 67 |
+
<div class="col-sm-6">
|
| 68 |
+
<label class="form-label small">Last Name</label>
|
| 69 |
+
<input type="text" class="form-control form-control-sm" id="pf-ln">
|
| 70 |
+
</div>
|
| 71 |
+
<div class="col-sm-6">
|
| 72 |
+
<label class="form-label small">Date of Birth</label>
|
| 73 |
+
<input type="date" class="form-control form-control-sm" id="pf-dob">
|
| 74 |
+
</div>
|
| 75 |
+
<div class="col-sm-6">
|
| 76 |
+
<label class="form-label small">Gender</label>
|
| 77 |
+
<select class="form-select form-select-sm" id="pf-gender">
|
| 78 |
+
<option value="">Select</option>
|
| 79 |
+
<option value="M">Male</option>
|
| 80 |
+
<option value="F">Female</option>
|
| 81 |
+
<option value="O">Other</option>
|
| 82 |
+
</select>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="col-12">
|
| 85 |
+
<label class="form-label small">Address</label>
|
| 86 |
+
<input type="text" class="form-control form-control-sm" id="pf-addr" placeholder="123 Main St Boston MA 02101">
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="card border-secondary mt-3" style="background:#161b22">
|
| 93 |
+
<div class="card-header border-secondary d-flex justify-content-between">
|
| 94 |
+
<span>Medical Conditions</span>
|
| 95 |
+
<small class="text-muted">Used for trial matching</small>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="card-body">
|
| 98 |
+
<div id="condTags" class="d-flex flex-wrap gap-1 mb-2" style="min-height:1.8rem"></div>
|
| 99 |
+
<div class="position-relative">
|
| 100 |
+
<input type="text" class="form-control form-control-sm" id="condInput"
|
| 101 |
+
placeholder="Type to search and add conditionsβ¦" autocomplete="off">
|
| 102 |
+
<ul class="autocomplete-list" id="condAutoList"></ul>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div class="card border-secondary mt-3" style="background:#161b22">
|
| 108 |
+
<div class="card-header border-secondary">Medications</div>
|
| 109 |
+
<div class="card-body">
|
| 110 |
+
<div id="medTags" class="d-flex flex-wrap gap-1 mb-2" style="min-height:1.8rem"></div>
|
| 111 |
+
<input type="text" class="form-control form-control-sm" id="medInput"
|
| 112 |
+
placeholder="Type medication name and press Enterβ¦">
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="card border-secondary mt-3" style="background:#161b22">
|
| 117 |
+
<div class="card-header border-secondary d-flex justify-content-between">
|
| 118 |
+
<span>Documents</span>
|
| 119 |
+
<small class="text-muted">PDF, DOCX, TXT, PNG, JPG β max 10 MB</small>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="card-body">
|
| 122 |
+
<div id="docList" class="mb-2"></div>
|
| 123 |
+
<div class="d-flex gap-2">
|
| 124 |
+
<input type="file" class="form-control form-control-sm" id="docFileInput"
|
| 125 |
+
accept=".pdf,.docx,.doc,.txt,.png,.jpg,.jpeg" style="flex:1">
|
| 126 |
+
<button class="btn btn-sm btn-outline-primary" id="docUploadBtn">Upload</button>
|
| 127 |
+
</div>
|
| 128 |
+
<div id="docMsg" class="small mt-1" style="min-height:1rem"></div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div class="col-lg-5">
|
| 134 |
+
<div class="card border-secondary" style="background:#161b22">
|
| 135 |
+
<div class="card-header border-secondary">Trial Participation</div>
|
| 136 |
+
<div class="card-body">
|
| 137 |
+
<div class="form-check form-switch mb-3">
|
| 138 |
+
<input class="form-check-input" type="checkbox" id="openToTrials">
|
| 139 |
+
<label class="form-check-label" for="openToTrials">
|
| 140 |
+
<strong>Open to Clinical Trials</strong>
|
| 141 |
+
<div class="text-muted small">Allow hospitals to see your basic profile when searching for eligible patients.</div>
|
| 142 |
+
</label>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="alert alert-info py-2 small">
|
| 145 |
+
π Your detailed information is only shared when <strong>you</strong> click "Connect with Hospital."
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="card border-secondary mt-3" style="background:#161b22">
|
| 150 |
+
<div class="card-header border-secondary">Account</div>
|
| 151 |
+
<div class="card-body small text-muted" id="acctInfo"></div>
|
| 152 |
+
</div>
|
| 153 |
+
<button class="btn btn-primary w-100 mt-3" id="saveBtn">Save Profile</button>
|
| 154 |
+
<div id="saveMsg" class="text-center small mt-2" style="min-height:1.2rem"></div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- ===== TRIALS TAB ===== -->
|
| 160 |
+
<div id="tab-trials" style="display:none">
|
| 161 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 162 |
+
<div>
|
| 163 |
+
<h6 class="mb-0">Trial Recommendations</h6>
|
| 164 |
+
<small class="text-muted">AI-ranked by eligibility probability using your profile data</small>
|
| 165 |
+
</div>
|
| 166 |
+
<button class="btn btn-primary btn-sm" id="getMatchesBtn">Get My Matches</button>
|
| 167 |
+
</div>
|
| 168 |
+
<div id="trialsLoading" class="text-center py-5" style="display:none">
|
| 169 |
+
<div class="spinner-border text-primary"></div>
|
| 170 |
+
<p class="text-muted mt-2 small">Analyzing your profile against clinical trialsβ¦</p>
|
| 171 |
+
</div>
|
| 172 |
+
<div id="trialsMsg" class="text-center py-5 text-muted" style="display:none"></div>
|
| 173 |
+
<div class="alert alert-secondary py-2 small mb-3" id="trialsDisclaimer" style="display:none">
|
| 174 |
+
<strong>About these scores:</strong> Match percentages are predictions from a model trained on
|
| 175 |
+
synthetic patient data and rule-based labels β not validated clinical eligibility determinations.
|
| 176 |
+
Always consult a healthcare provider before enrolling in any trial.
|
| 177 |
+
</div>
|
| 178 |
+
<div id="trialsResults"></div>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<!-- ===== CONNECTIONS TAB ===== -->
|
| 182 |
+
<div id="tab-connections" style="display:none">
|
| 183 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 184 |
+
<h6 class="mb-0">My Connections</h6>
|
| 185 |
+
<button class="btn btn-sm btn-outline-secondary" id="refreshConnsBtn">Refresh</button>
|
| 186 |
+
</div>
|
| 187 |
+
<div id="connsMsg" class="text-center py-5 text-muted" style="display:none">
|
| 188 |
+
No connections yet. Find a trial and click "Connect with Hospital."
|
| 189 |
+
</div>
|
| 190 |
+
<div id="connsWrap" style="display:none">
|
| 191 |
+
<div class="table-responsive">
|
| 192 |
+
<table class="table table-dark table-hover table-sm align-middle">
|
| 193 |
+
<thead><tr>
|
| 194 |
+
<th>Hospital</th><th>Trial</th><th>Initiated By</th>
|
| 195 |
+
<th>Status</th><th>Message</th><th>Date</th><th>Chat</th>
|
| 196 |
+
</tr></thead>
|
| 197 |
+
<tbody id="connsTbody"></tbody>
|
| 198 |
+
</table>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<!-- ===== INBOX TAB ===== -->
|
| 204 |
+
<div id="tab-inbox" style="display:none">
|
| 205 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 206 |
+
<h6 class="mb-0">Inbox</h6>
|
| 207 |
+
<button class="btn btn-sm btn-outline-secondary" onclick="loadInbox()">Refresh</button>
|
| 208 |
+
</div>
|
| 209 |
+
<div id="inboxLoading" class="text-center text-muted py-4">Loadingβ¦</div>
|
| 210 |
+
<div id="inboxEmpty" class="text-center text-muted py-4" style="display:none">No message threads yet. Connect with a hospital to start messaging.</div>
|
| 211 |
+
<div id="inboxList"></div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<!-- Messages Modal -->
|
| 216 |
+
<div class="modal fade" id="msgModal" tabindex="-1">
|
| 217 |
+
<div class="modal-dialog modal-lg">
|
| 218 |
+
<div class="modal-content border-secondary" style="background:#161b22">
|
| 219 |
+
<div class="modal-header border-secondary">
|
| 220 |
+
<h5 class="modal-title" id="msgModalTitle">Messages</h5>
|
| 221 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 222 |
+
</div>
|
| 223 |
+
<div class="modal-body p-0">
|
| 224 |
+
<div id="msgThread" style="height:320px;overflow-y:auto;padding:1rem;background:#0d1117"></div>
|
| 225 |
+
<div class="p-3 border-top border-secondary d-flex gap-2">
|
| 226 |
+
<input type="text" class="form-control form-control-sm" id="msgInput"
|
| 227 |
+
placeholder="Type a messageβ¦" maxlength="2000">
|
| 228 |
+
<button class="btn btn-primary btn-sm px-3" id="msgSendBtn">Send</button>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<!-- Connect with Hospital Modal -->
|
| 236 |
+
<div class="modal fade" id="connectModal" tabindex="-1">
|
| 237 |
+
<div class="modal-dialog">
|
| 238 |
+
<div class="modal-content border-secondary" style="background:#161b22">
|
| 239 |
+
<div class="modal-header border-secondary">
|
| 240 |
+
<h5 class="modal-title">Connect with Hospital</h5>
|
| 241 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
| 242 |
+
</div>
|
| 243 |
+
<div class="modal-body">
|
| 244 |
+
<p class="text-muted small" id="modalTrialInfo"></p>
|
| 245 |
+
<div id="hospitalList" class="mb-3" style="max-height:420px;overflow-y:auto"></div>
|
| 246 |
+
<label class="form-label small">Message <span class="text-muted">(optional)</span></label>
|
| 247 |
+
<textarea class="form-control form-control-sm" id="modalMsg" rows="3"
|
| 248 |
+
placeholder="Introduce yourself or ask a questionβ¦"></textarea>
|
| 249 |
+
<div class="alert alert-info py-2 small mt-3">
|
| 250 |
+
π By connecting, you share your name, age, gender, and conditions with the selected hospital.
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="modal-footer border-secondary">
|
| 254 |
+
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
| 255 |
+
<button class="btn btn-primary btn-sm" id="sendConnBtn" disabled>Send Request</button>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
| 262 |
+
<script>
|
| 263 |
+
const PATIENT = {{ patient | tojson }};
|
| 264 |
+
let conditions = [...(PATIENT.conditions || [])];
|
| 265 |
+
let medications = [...(PATIENT.medications || [])];
|
| 266 |
+
let documents = [...(PATIENT.documents || [])];
|
| 267 |
+
let sysReady = false;
|
| 268 |
+
let connectModal, msgModal, currentTrialId, currentTrialTitle, selectedHospId, currentConnId;
|
| 269 |
+
|
| 270 |
+
// ββ INIT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 271 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 272 |
+
connectModal = new bootstrap.Modal(document.getElementById('connectModal'));
|
| 273 |
+
msgModal = new bootstrap.Modal(document.getElementById('msgModal'));
|
| 274 |
+
document.getElementById('msgSendBtn').addEventListener('click', sendMsg);
|
| 275 |
+
document.getElementById('msgInput').addEventListener('keydown', e => {
|
| 276 |
+
if (e.key === 'Enter') sendMsg();
|
| 277 |
+
});
|
| 278 |
+
document.getElementById('docUploadBtn').addEventListener('click', uploadDoc);
|
| 279 |
+
|
| 280 |
+
document.querySelectorAll('#mainTabs .nav-link').forEach(b =>
|
| 281 |
+
b.addEventListener('click', () => switchTab(b.dataset.tab)));
|
| 282 |
+
document.getElementById('logoutBtn').addEventListener('click', logout);
|
| 283 |
+
document.getElementById('saveBtn').addEventListener('click', saveProfile);
|
| 284 |
+
document.getElementById('getMatchesBtn').addEventListener('click', loadTrials);
|
| 285 |
+
document.getElementById('refreshConnsBtn').addEventListener('click', loadConnections);
|
| 286 |
+
document.getElementById('sendConnBtn').addEventListener('click', submitConnect);
|
| 287 |
+
|
| 288 |
+
// Condition autocomplete
|
| 289 |
+
const ci = document.getElementById('condInput');
|
| 290 |
+
ci.addEventListener('input', () => condSearch(ci.value));
|
| 291 |
+
ci.addEventListener('keydown', e => { if(e.key==='Escape') hideCond(); });
|
| 292 |
+
document.addEventListener('click', e => {
|
| 293 |
+
if (!ci.contains(e.target) && !document.getElementById('condAutoList').contains(e.target))
|
| 294 |
+
hideCond();
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
// Medication input
|
| 298 |
+
document.getElementById('medInput').addEventListener('keydown', e => {
|
| 299 |
+
if (e.key === 'Enter') {
|
| 300 |
+
const v = e.target.value.trim();
|
| 301 |
+
if (v && !medications.includes(v)) { medications.push(v); renderMedTags(); }
|
| 302 |
+
e.target.value = '';
|
| 303 |
+
}
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
// Hospital selection in modal (event delegation)
|
| 307 |
+
document.getElementById('hospitalList').addEventListener('click', e => {
|
| 308 |
+
const item = e.target.closest('[data-hid]');
|
| 309 |
+
if (!item) return;
|
| 310 |
+
selectedHospId = item.dataset.hid;
|
| 311 |
+
document.querySelectorAll('#hospitalList [data-hid]')
|
| 312 |
+
.forEach(el => el.classList.remove('active'));
|
| 313 |
+
item.classList.add('active');
|
| 314 |
+
document.getElementById('sendConnBtn').disabled = false;
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
// Trial results event delegation (interest + connect)
|
| 318 |
+
document.getElementById('trialsResults').addEventListener('click', e => {
|
| 319 |
+
const btn = e.target.closest('[data-action]');
|
| 320 |
+
if (!btn) return;
|
| 321 |
+
const {action, trialId, trialTitle, score} = btn.dataset;
|
| 322 |
+
if (action === 'interest') toggleInterest(btn, trialId, trialTitle, parseFloat(score));
|
| 323 |
+
if (action === 'connect') openConnectModal(trialId, trialTitle);
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
initProfile();
|
| 327 |
+
checkStatus().then(() => { if (!sysReady) startStatusPoll(); });
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
// ββ STATUS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 331 |
+
let _statusPoll = null;
|
| 332 |
+
async function checkStatus() {
|
| 333 |
+
try {
|
| 334 |
+
const d = await fetch('/api/status').then(r => r.json());
|
| 335 |
+
sysReady = !!d.ready;
|
| 336 |
+
document.getElementById('sysBanner').style.display = sysReady ? 'none' : '';
|
| 337 |
+
if (sysReady && _statusPoll) { clearInterval(_statusPoll); _statusPoll = null; }
|
| 338 |
+
} catch(e) {}
|
| 339 |
+
}
|
| 340 |
+
function startStatusPoll() {
|
| 341 |
+
if (_statusPoll) return;
|
| 342 |
+
_statusPoll = setInterval(async () => { await checkStatus(); }, 5000);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// ββ TABS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
function switchTab(name) {
|
| 347 |
+
document.querySelectorAll('#mainTabs .nav-link').forEach(b =>
|
| 348 |
+
b.classList.toggle('active', b.dataset.tab === name));
|
| 349 |
+
['profile','trials','connections','inbox'].forEach(t =>
|
| 350 |
+
document.getElementById('tab-'+t).style.display = t === name ? '' : 'none');
|
| 351 |
+
if (name === 'connections') loadConnections();
|
| 352 |
+
if (name === 'inbox') loadInbox();
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// ββ PROFILE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 356 |
+
function initProfile() {
|
| 357 |
+
document.getElementById('pf-fn').value = PATIENT.first_name || '';
|
| 358 |
+
document.getElementById('pf-ln').value = PATIENT.last_name || '';
|
| 359 |
+
document.getElementById('pf-dob').value = PATIENT.dob || '';
|
| 360 |
+
document.getElementById('pf-gender').value = PATIENT.gender || '';
|
| 361 |
+
document.getElementById('pf-addr').value = PATIENT.address || '';
|
| 362 |
+
document.getElementById('openToTrials').checked = !!PATIENT.open_to_trials;
|
| 363 |
+
document.getElementById('acctInfo').innerHTML =
|
| 364 |
+
'Username: <strong>' + escH(PATIENT.username) + '</strong><br>' +
|
| 365 |
+
'Member since: <strong>' + escH((PATIENT.created_at||'').split('T')[0]) + '</strong>';
|
| 366 |
+
renderCondTags(); renderMedTags(); renderDocList();
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
async function saveProfile() {
|
| 370 |
+
const btn = document.getElementById('saveBtn');
|
| 371 |
+
const msg = document.getElementById('saveMsg');
|
| 372 |
+
btn.disabled = true; btn.textContent = 'Savingβ¦'; msg.textContent = '';
|
| 373 |
+
try {
|
| 374 |
+
const r = await fetch('/api/patient/profile', {
|
| 375 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 376 |
+
body: JSON.stringify({
|
| 377 |
+
first_name: document.getElementById('pf-fn').value.trim(),
|
| 378 |
+
last_name: document.getElementById('pf-ln').value.trim(),
|
| 379 |
+
dob: document.getElementById('pf-dob').value,
|
| 380 |
+
gender: document.getElementById('pf-gender').value,
|
| 381 |
+
address: document.getElementById('pf-addr').value.trim(),
|
| 382 |
+
conditions, medications,
|
| 383 |
+
open_to_trials: document.getElementById('openToTrials').checked ? 1 : 0,
|
| 384 |
+
})
|
| 385 |
+
});
|
| 386 |
+
const d = await r.json();
|
| 387 |
+
msg.className = r.ok ? 'text-success small mt-2' : 'text-danger small mt-2';
|
| 388 |
+
msg.textContent = r.ok ? 'β Profile saved' : (d.error || 'Save failed');
|
| 389 |
+
} catch(e) {
|
| 390 |
+
msg.className = 'text-danger small mt-2'; msg.textContent = 'Network error';
|
| 391 |
+
}
|
| 392 |
+
btn.disabled = false; btn.textContent = 'Save Profile';
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// ββ CONDITION TAGS (XSS-safe β no innerHTML for user data) βββββββββββββββββββ
|
| 396 |
+
function renderCondTags() {
|
| 397 |
+
const wrap = document.getElementById('condTags');
|
| 398 |
+
wrap.innerHTML = '';
|
| 399 |
+
conditions.forEach(v => wrap.appendChild(makeTag(v, 'bg-secondary', () => {
|
| 400 |
+
conditions = conditions.filter(c => c !== v); renderCondTags();
|
| 401 |
+
})));
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
function renderMedTags() {
|
| 405 |
+
const wrap = document.getElementById('medTags');
|
| 406 |
+
wrap.innerHTML = '';
|
| 407 |
+
medications.forEach(v => wrap.appendChild(makeTag(v, 'bg-info text-dark', () => {
|
| 408 |
+
medications = medications.filter(m => m !== v); renderMedTags();
|
| 409 |
+
})));
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
function makeTag(val, cls, onRemove) {
|
| 413 |
+
const span = document.createElement('span');
|
| 414 |
+
span.className = `badge ${cls} d-inline-flex align-items-center gap-1`;
|
| 415 |
+
const t = document.createElement('span');
|
| 416 |
+
t.textContent = val;
|
| 417 |
+
const x = document.createElement('button');
|
| 418 |
+
x.type = 'button'; x.className = 'btn-close btn-close-white'; x.style.fontSize = '.45rem';
|
| 419 |
+
if (cls.includes('text-dark')) x.className = 'btn-close';
|
| 420 |
+
x.addEventListener('click', onRemove);
|
| 421 |
+
span.appendChild(t); span.appendChild(x);
|
| 422 |
+
return span;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ββ CONDITION AUTOCOMPLETE ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 426 |
+
async function condSearch(q) {
|
| 427 |
+
if (q.length < 2) { hideCond(); return; }
|
| 428 |
+
try {
|
| 429 |
+
const d = await fetch('/api/conditions/autocomplete?q=' + encodeURIComponent(q)).then(r=>r.json());
|
| 430 |
+
const list = document.getElementById('condAutoList');
|
| 431 |
+
list.innerHTML = '';
|
| 432 |
+
if (!(d.results||[]).length) { list.style.display='none'; return; }
|
| 433 |
+
d.results.forEach(item => {
|
| 434 |
+
const li = document.createElement('li');
|
| 435 |
+
li.textContent = item;
|
| 436 |
+
li.addEventListener('mousedown', e => {
|
| 437 |
+
e.preventDefault();
|
| 438 |
+
if (!conditions.includes(item)) { conditions.push(item); renderCondTags(); }
|
| 439 |
+
document.getElementById('condInput').value = ''; hideCond();
|
| 440 |
+
});
|
| 441 |
+
list.appendChild(li);
|
| 442 |
+
});
|
| 443 |
+
list.style.display = 'block';
|
| 444 |
+
} catch(e) { hideCond(); }
|
| 445 |
+
}
|
| 446 |
+
function hideCond() { document.getElementById('condAutoList').style.display = 'none'; }
|
| 447 |
+
|
| 448 |
+
// ββ TRIALS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 449 |
+
async function loadTrials() {
|
| 450 |
+
if (!sysReady) { await checkStatus(); }
|
| 451 |
+
if (!sysReady) { alert('Pipeline still loading. Please wait a moment.'); return; }
|
| 452 |
+
document.getElementById('trialsLoading').style.display = '';
|
| 453 |
+
document.getElementById('trialsMsg').style.display = 'none';
|
| 454 |
+
document.getElementById('trialsDisclaimer').style.display = 'none';
|
| 455 |
+
document.getElementById('trialsResults').innerHTML = '';
|
| 456 |
+
document.getElementById('getMatchesBtn').disabled = true;
|
| 457 |
+
try {
|
| 458 |
+
const r = await fetch('/api/patient/matches');
|
| 459 |
+
const d = await r.json();
|
| 460 |
+
if (!r.ok) throw new Error(d.error || 'Failed');
|
| 461 |
+
if (!(d.results||[]).length) {
|
| 462 |
+
const m = document.getElementById('trialsMsg');
|
| 463 |
+
m.style.display = ''; m.textContent = d.message || 'No matches found.';
|
| 464 |
+
} else {
|
| 465 |
+
renderTrials(d.results);
|
| 466 |
+
}
|
| 467 |
+
} catch(e) {
|
| 468 |
+
const m = document.getElementById('trialsMsg');
|
| 469 |
+
m.style.display = ''; m.className = 'text-danger text-center py-5';
|
| 470 |
+
m.textContent = e.message;
|
| 471 |
+
}
|
| 472 |
+
document.getElementById('trialsLoading').style.display = 'none';
|
| 473 |
+
document.getElementById('getMatchesBtn').disabled = false;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
function renderTrials(trials) {
|
| 477 |
+
const wrap = document.getElementById('trialsResults');
|
| 478 |
+
wrap.innerHTML = '';
|
| 479 |
+
document.getElementById('trialsDisclaimer').style.display = trials.length ? '' : 'none';
|
| 480 |
+
trials.forEach(t => { wrap.insertAdjacentHTML('beforeend', buildCard(t)); });
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function buildCard(t) {
|
| 484 |
+
const sc = t.eligibility_probability >= 75 ? 'score-high' : t.eligibility_probability >= 50 ? 'score-mid' : 'score-low';
|
| 485 |
+
const stc = t.status === 'RECRUITING' ? 'success' : t.status.includes('ACTIVE') ? 'info' : 'secondary';
|
| 486 |
+
const overlaps = new Set(t.overlap_conditions || []);
|
| 487 |
+
|
| 488 |
+
const bars = [
|
| 489 |
+
['Eligibility Probability', t.eligibility_probability, 'primary'],
|
| 490 |
+
['Age Compatibility', t.age_compatibility, 'info'],
|
| 491 |
+
['Gender Compatibility', t.gender_compatibility, 'success'],
|
| 492 |
+
['Match Score', t.match_score, 'warning'],
|
| 493 |
+
['Geo Feasibility', t.geo_feasibility, 'secondary'],
|
| 494 |
+
['Med Compatibility', t.med_compatibility, 'secondary'],
|
| 495 |
+
['Rarity Score', Math.round(t.condition_rarity_score*100), 'secondary'],
|
| 496 |
+
].map(([l,v,c]) => `
|
| 497 |
+
<div class="mb-1">
|
| 498 |
+
<div class="d-flex justify-content-between">
|
| 499 |
+
<span class="bar-lbl">${escH(l)}</span>
|
| 500 |
+
<span class="bar-val">${v}%</span>
|
| 501 |
+
</div>
|
| 502 |
+
<div class="progress"><div class="progress-bar bg-${c}" style="width:${Math.min(100,v)}%"></div></div>
|
| 503 |
+
</div>`).join('');
|
| 504 |
+
|
| 505 |
+
const condTags = (t.trial_conditions||[]).map(c =>
|
| 506 |
+
`<span class="badge ${overlaps.has(c)?'bg-success':'bg-dark border border-secondary'} me-1 mb-1">${escH(c)}</span>`
|
| 507 |
+
).join('');
|
| 508 |
+
|
| 509 |
+
const interested = t.interest_status === 'interested';
|
| 510 |
+
|
| 511 |
+
return `
|
| 512 |
+
<div class="trial-card mb-4">
|
| 513 |
+
<div class="d-flex gap-3 align-items-start mb-3">
|
| 514 |
+
<div class="score-circle ${sc}">
|
| 515 |
+
<span>${t.eligibility_probability}</span><small>%</small>
|
| 516 |
+
</div>
|
| 517 |
+
<div class="flex-grow-1 min-w-0">
|
| 518 |
+
<div class="text-muted small mb-1">${escH(t.trial_id)} • ${escH(t.phase||'N/A')}</div>
|
| 519 |
+
<h6 class="mb-2">${escH(t.title)}</h6>
|
| 520 |
+
<div class="d-flex flex-wrap gap-1">
|
| 521 |
+
<span class="badge bg-${stc}">${escH(t.status)}</span>
|
| 522 |
+
${t.phase&&t.phase!=='N/A'?`<span class="badge bg-info text-dark">${escH(t.phase)}</span>`:''}
|
| 523 |
+
<span class="badge bg-secondary">${escH(t.sex||'ALL')}</span>
|
| 524 |
+
<span class="badge bg-secondary">${t.min_age}β${t.max_age} yrs</span>
|
| 525 |
+
</div>
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
${t.summary?`<p class="text-muted small mb-3">${escH(t.summary)}</p>`:''}
|
| 529 |
+
<div class="detail-grid mb-3">
|
| 530 |
+
<div><div class="lbl">Age Range</div><div class="val">${t.min_age}β${t.max_age} yrs</div></div>
|
| 531 |
+
<div><div class="lbl">Gender</div><div class="val">${escH(t.sex||'ALL')}</div></div>
|
| 532 |
+
<div><div class="lbl">Sites</div><div class="val">${t.n_sites||'β'}</div></div>
|
| 533 |
+
<div><div class="lbl">Enrollment</div><div class="val">${t.enrollment||'β'}</div></div>
|
| 534 |
+
${t.facility_name?`<div class="col-span-2"><div class="lbl">Lead Site</div><div class="val">${escH(t.facility_name)}</div></div>`:''}
|
| 535 |
+
${t.location?`<div class="col-span-2"><div class="lbl">Location</div><div class="val">${escH(t.location)}</div></div>`:''}
|
| 536 |
+
</div>
|
| 537 |
+
<div class="bar-section mb-3"><div class="sec-head">Match Analysis</div>${bars}</div>
|
| 538 |
+
<div class="mb-3">
|
| 539 |
+
<div class="sec-head">Conditions</div>
|
| 540 |
+
<div>${condTags}</div>
|
| 541 |
+
${overlaps.size>0?'<div class="small text-success mt-1">π’ Green = conditions matching your profile</div>':''}
|
| 542 |
+
</div>
|
| 543 |
+
${t.criteria?`<details class="mb-3"><summary class="small text-muted" style="cursor:pointer">βΆ Eligibility Criteria</summary>
|
| 544 |
+
<pre class="criteria-pre mt-2">${escH(t.criteria)}</pre></details>`:''}
|
| 545 |
+
<div class="d-flex flex-wrap gap-2 pt-2 border-top border-secondary mt-2">
|
| 546 |
+
<a href="https://clinicaltrials.gov/study/${escA(t.trial_id)}" target="_blank" rel="noopener"
|
| 547 |
+
class="btn btn-sm btn-outline-secondary">View on ClinicalTrials.gov β</a>
|
| 548 |
+
<button class="btn btn-sm ${interested?'btn-success':'btn-outline-success'}"
|
| 549 |
+
data-action="interest" data-trial-id="${escA(t.trial_id)}"
|
| 550 |
+
data-trial-title="${escA(t.title)}" data-score="${t.combined_score}">
|
| 551 |
+
${interested?'β Interested':"I'm Interested"}</button>
|
| 552 |
+
<button class="btn btn-sm btn-outline-primary"
|
| 553 |
+
data-action="connect" data-trial-id="${escA(t.trial_id)}"
|
| 554 |
+
data-trial-title="${escA(t.title)}">Connect with Hospital</button>
|
| 555 |
+
</div>
|
| 556 |
+
</div>`;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
async function toggleInterest(btn, trialId, title, score) {
|
| 560 |
+
const isOn = btn.classList.contains('btn-success');
|
| 561 |
+
if (isOn) {
|
| 562 |
+
await fetch('/api/patient/interest/'+encodeURIComponent(trialId), {method:'DELETE'});
|
| 563 |
+
btn.className = 'btn btn-sm btn-outline-success';
|
| 564 |
+
btn.textContent = "I'm Interested";
|
| 565 |
+
} else {
|
| 566 |
+
await fetch('/api/patient/interest', {
|
| 567 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 568 |
+
body: JSON.stringify({trial_id:trialId, trial_title:title, match_score:score})
|
| 569 |
+
});
|
| 570 |
+
btn.className = 'btn btn-sm btn-success';
|
| 571 |
+
btn.textContent = 'β Interested';
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// ββ CONNECT WITH HOSPITAL βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 576 |
+
function _buildHospBtn(h) {
|
| 577 |
+
const btn = document.createElement('button');
|
| 578 |
+
btn.type = 'button';
|
| 579 |
+
btn.className = 'list-group-item list-group-item-action';
|
| 580 |
+
btn.dataset.hid = h.id;
|
| 581 |
+
const nm = document.createElement('strong'); nm.textContent = h.hospital_name;
|
| 582 |
+
const lc = document.createElement('small');
|
| 583 |
+
lc.className = 'd-block text-muted'; lc.textContent = h.location || '';
|
| 584 |
+
btn.appendChild(nm); btn.appendChild(lc);
|
| 585 |
+
if (h.match_reason) {
|
| 586 |
+
const badge = document.createElement('span');
|
| 587 |
+
badge.className = `badge ${h.match_tier === 1 ? 'bg-success' : 'bg-secondary'} ms-1`;
|
| 588 |
+
badge.style.fontSize = '.65rem';
|
| 589 |
+
badge.textContent = h.match_reason;
|
| 590 |
+
btn.appendChild(badge);
|
| 591 |
+
}
|
| 592 |
+
return btn;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
async function openConnectModal(trialId, title) {
|
| 596 |
+
currentTrialId = trialId; currentTrialTitle = title;
|
| 597 |
+
selectedHospId = null;
|
| 598 |
+
document.getElementById('sendConnBtn').disabled = true;
|
| 599 |
+
document.getElementById('modalTrialInfo').textContent = 'Trial: ' + title;
|
| 600 |
+
document.getElementById('modalMsg').value = '';
|
| 601 |
+
const list = document.getElementById('hospitalList');
|
| 602 |
+
list.innerHTML = '<div class="list-group-item text-muted small">Loading hospitalsβ¦</div>';
|
| 603 |
+
connectModal.show();
|
| 604 |
+
try {
|
| 605 |
+
const d = await fetch('/api/patient/hospitals-for-trial?trial_id='+encodeURIComponent(trialId)).then(r=>r.json());
|
| 606 |
+
list.innerHTML = '';
|
| 607 |
+
const hospitals = d.hospitals || [];
|
| 608 |
+
if (!hospitals.length) {
|
| 609 |
+
list.innerHTML = '<div class="list-group-item text-muted small">No registered hospitals for this trial.</div>';
|
| 610 |
+
return;
|
| 611 |
+
}
|
| 612 |
+
const verified = hospitals.filter(h => h.match_tier === 1);
|
| 613 |
+
const related = hospitals.filter(h => h.match_tier !== 1);
|
| 614 |
+
|
| 615 |
+
if (verified.length) {
|
| 616 |
+
const head = document.createElement('div');
|
| 617 |
+
head.className = 'sec-head text-success mb-1 mt-2';
|
| 618 |
+
head.textContent = 'Verified Trial Sites';
|
| 619 |
+
const grp = document.createElement('div');
|
| 620 |
+
grp.className = 'list-group mb-3';
|
| 621 |
+
verified.forEach(h => grp.appendChild(_buildHospBtn(h)));
|
| 622 |
+
list.appendChild(head); list.appendChild(grp);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
if (related.length) {
|
| 626 |
+
const head = document.createElement('div');
|
| 627 |
+
head.className = 'sec-head mb-1 mt-2';
|
| 628 |
+
head.innerHTML = 'Related Hospitals'
|
| 629 |
+
+ ' <span class="text-muted fw-normal" style="text-transform:none;letter-spacing:0;font-size:.68rem">'
|
| 630 |
+
+ 'β not confirmed trial sites</span>';
|
| 631 |
+
const grp = document.createElement('div');
|
| 632 |
+
grp.className = 'list-group mb-2';
|
| 633 |
+
related.forEach(h => grp.appendChild(_buildHospBtn(h)));
|
| 634 |
+
list.appendChild(head); list.appendChild(grp);
|
| 635 |
+
}
|
| 636 |
+
} catch(e) {
|
| 637 |
+
list.innerHTML = '<div class="list-group-item text-danger small">Error loading hospitals.</div>';
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
async function submitConnect() {
|
| 642 |
+
if (!selectedHospId) return;
|
| 643 |
+
try {
|
| 644 |
+
const r = await fetch('/api/patient/connect', {
|
| 645 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 646 |
+
body: JSON.stringify({hospital_id:selectedHospId, trial_id:currentTrialId,
|
| 647 |
+
trial_title:currentTrialTitle,
|
| 648 |
+
message:document.getElementById('modalMsg').value.trim()})
|
| 649 |
+
});
|
| 650 |
+
const d = await r.json();
|
| 651 |
+
if (r.ok) { connectModal.hide(); switchTab('connections'); }
|
| 652 |
+
else alert(d.error || 'Request failed');
|
| 653 |
+
} catch(e) { alert('Network error'); }
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
// ββ CONNECTIONS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 657 |
+
async function loadConnections() {
|
| 658 |
+
try {
|
| 659 |
+
const d = await fetch('/api/patient/connections').then(r=>r.json());
|
| 660 |
+
const conns = d.connections || [];
|
| 661 |
+
const msg = document.getElementById('connsMsg');
|
| 662 |
+
const wrap = document.getElementById('connsWrap');
|
| 663 |
+
const tbody= document.getElementById('connsTbody');
|
| 664 |
+
if (!conns.length) { msg.style.display=''; wrap.style.display='none'; return; }
|
| 665 |
+
msg.style.display='none'; wrap.style.display='';
|
| 666 |
+
tbody.innerHTML = '';
|
| 667 |
+
conns.forEach(c => {
|
| 668 |
+
const sc = {pending:'st-pending',accepted:'st-accepted',rejected:'st-rejected',completed:'st-completed'}[c.status]||'';
|
| 669 |
+
const tr = document.createElement('tr');
|
| 670 |
+
const hname = c.hospital_name || 'β';
|
| 671 |
+
tr.innerHTML = [
|
| 672 |
+
escH(hname),
|
| 673 |
+
escH((c.trial_title||'β').substring(0,50)),
|
| 674 |
+
escH(c.initiated_by||''),
|
| 675 |
+
`<span class="${sc} fw-bold">${escH(c.status)}</span>`,
|
| 676 |
+
escH((c.message||'').substring(0,60)),
|
| 677 |
+
escH((c.created_at||'').split('T')[0]),
|
| 678 |
+
`<button class="btn btn-sm btn-outline-info py-0 px-2" style="font-size:.72rem"
|
| 679 |
+
onclick="openMsgModal('${escA(c.id)}','${escA(hname)}')">π¬ Chat</button>`,
|
| 680 |
+
].map(v=>`<td>${v}</td>`).join('');
|
| 681 |
+
tbody.appendChild(tr);
|
| 682 |
+
});
|
| 683 |
+
} catch(e) {}
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
// ββ DOCUMENTS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 687 |
+
function renderDocList() {
|
| 688 |
+
const wrap = document.getElementById('docList');
|
| 689 |
+
wrap.innerHTML = '';
|
| 690 |
+
if (!documents.length) {
|
| 691 |
+
const p = document.createElement('p');
|
| 692 |
+
p.className = 'text-muted small mb-1'; p.textContent = 'No documents uploaded yet.';
|
| 693 |
+
wrap.appendChild(p); return;
|
| 694 |
+
}
|
| 695 |
+
documents.forEach(doc => {
|
| 696 |
+
const row = document.createElement('div');
|
| 697 |
+
row.className = 'd-flex align-items-center gap-2 mb-1 small';
|
| 698 |
+
const nm = document.createElement('span'); nm.className = 'flex-grow-1 text-truncate'; nm.textContent = doc.filename;
|
| 699 |
+
const dt = document.createElement('small'); dt.className = 'text-muted flex-shrink-0';
|
| 700 |
+
dt.textContent = (doc.uploaded_at||'').substring(0,10);
|
| 701 |
+
const dl = document.createElement('a');
|
| 702 |
+
dl.href = '/api/patient/documents/'+encodeURIComponent(doc.id)+'/download';
|
| 703 |
+
dl.className = 'btn btn-sm btn-outline-secondary py-0 px-1 flex-shrink-0';
|
| 704 |
+
dl.style.fontSize = '.7rem'; dl.textContent = 'β'; dl.title = 'Download';
|
| 705 |
+
const del = document.createElement('button');
|
| 706 |
+
del.type = 'button'; del.className = 'btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0';
|
| 707 |
+
del.style.fontSize = '.7rem'; del.textContent = 'β'; del.title = 'Delete';
|
| 708 |
+
del.addEventListener('click', () => deleteDoc(doc.id));
|
| 709 |
+
row.appendChild(nm); row.appendChild(dt); row.appendChild(dl); row.appendChild(del);
|
| 710 |
+
wrap.appendChild(row);
|
| 711 |
+
});
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
async function uploadDoc() {
|
| 715 |
+
const input = document.getElementById('docFileInput');
|
| 716 |
+
const msg = document.getElementById('docMsg');
|
| 717 |
+
if (!input.files.length) { msg.className = 'text-warning small mt-1'; msg.textContent = 'Select a file first.'; return; }
|
| 718 |
+
const file = input.files[0];
|
| 719 |
+
if (file.size > 10 * 1024 * 1024) { msg.className = 'text-danger small mt-1'; msg.textContent = 'File too large (max 10 MB).'; return; }
|
| 720 |
+
const form = new FormData(); form.append('file', file);
|
| 721 |
+
msg.className = 'text-muted small mt-1'; msg.textContent = 'Uploadingβ¦';
|
| 722 |
+
try {
|
| 723 |
+
const r = await fetch('/api/patient/documents', {method:'POST', body:form});
|
| 724 |
+
const d = await r.json();
|
| 725 |
+
if (r.ok) {
|
| 726 |
+
documents.push(d.document); renderDocList(); input.value = '';
|
| 727 |
+
msg.className = 'text-success small mt-1'; msg.textContent = 'β Uploaded';
|
| 728 |
+
} else {
|
| 729 |
+
msg.className = 'text-danger small mt-1'; msg.textContent = d.error || 'Upload failed';
|
| 730 |
+
}
|
| 731 |
+
} catch(e) { msg.className = 'text-danger small mt-1'; msg.textContent = 'Network error'; }
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
async function deleteDoc(docId) {
|
| 735 |
+
try {
|
| 736 |
+
const r = await fetch('/api/patient/documents/'+encodeURIComponent(docId), {method:'DELETE'});
|
| 737 |
+
if (r.ok) { documents = documents.filter(d => d.id !== docId); renderDocList(); }
|
| 738 |
+
} catch(e) {}
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
// ββ MESSAGING βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 742 |
+
async function openMsgModal(connId, label) {
|
| 743 |
+
currentConnId = connId;
|
| 744 |
+
document.getElementById('msgModalTitle').textContent = 'Chat β ' + label;
|
| 745 |
+
document.getElementById('msgThread').innerHTML =
|
| 746 |
+
'<div class="text-center text-muted small py-4">Loadingβ¦</div>';
|
| 747 |
+
document.getElementById('msgInput').value = '';
|
| 748 |
+
msgModal.show();
|
| 749 |
+
await loadMsgs();
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
async function loadMsgs() {
|
| 753 |
+
try {
|
| 754 |
+
const d = await fetch('/api/patient/connections/'+encodeURIComponent(currentConnId)+'/messages').then(r=>r.json());
|
| 755 |
+
renderMsgs(d.messages || []);
|
| 756 |
+
} catch(e) {
|
| 757 |
+
document.getElementById('msgThread').innerHTML =
|
| 758 |
+
'<div class="text-danger small text-center py-4">Error loading messages.</div>';
|
| 759 |
+
}
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
function renderMsgs(msgs) {
|
| 763 |
+
const thread = document.getElementById('msgThread');
|
| 764 |
+
if (!msgs.length) {
|
| 765 |
+
thread.innerHTML = '<div class="text-muted small text-center py-4">No messages yet. Start the conversation.</div>';
|
| 766 |
+
return;
|
| 767 |
+
}
|
| 768 |
+
thread.innerHTML = '';
|
| 769 |
+
msgs.forEach(m => {
|
| 770 |
+
const mine = m.sender_role === 'patient';
|
| 771 |
+
const wrap = document.createElement('div');
|
| 772 |
+
wrap.className = 'd-flex mb-2 ' + (mine ? 'justify-content-end' : 'justify-content-start');
|
| 773 |
+
const bubble = document.createElement('div');
|
| 774 |
+
bubble.style.cssText = 'max-width:72%;padding:.5rem .75rem;border-radius:12px;font-size:.85rem;'
|
| 775 |
+
+ (mine ? 'background:#1f6feb;color:#fff;' : 'background:#21262d;color:#c9d1d9;');
|
| 776 |
+
const bodyEl = document.createElement('div'); bodyEl.textContent = m.body;
|
| 777 |
+
const ts = document.createElement('div');
|
| 778 |
+
ts.style.cssText = 'font-size:.65rem;opacity:.6;margin-top:.2rem;text-align:right';
|
| 779 |
+
ts.textContent = (m.created_at||'').replace('T',' ').substring(0,16);
|
| 780 |
+
bubble.appendChild(bodyEl); bubble.appendChild(ts);
|
| 781 |
+
wrap.appendChild(bubble); thread.appendChild(wrap);
|
| 782 |
+
});
|
| 783 |
+
thread.scrollTop = thread.scrollHeight;
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
async function sendMsg() {
|
| 787 |
+
const input = document.getElementById('msgInput');
|
| 788 |
+
const body = input.value.trim();
|
| 789 |
+
if (!body || !currentConnId) return;
|
| 790 |
+
input.value = '';
|
| 791 |
+
try {
|
| 792 |
+
await fetch('/api/patient/connections/'+encodeURIComponent(currentConnId)+'/messages', {
|
| 793 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 794 |
+
body: JSON.stringify({body})
|
| 795 |
+
});
|
| 796 |
+
await loadMsgs();
|
| 797 |
+
} catch(e) {}
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
// ββ INBOX βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 801 |
+
async function loadInbox() {
|
| 802 |
+
document.getElementById('inboxLoading').style.display = '';
|
| 803 |
+
document.getElementById('inboxEmpty').style.display = 'none';
|
| 804 |
+
document.getElementById('inboxList').innerHTML = '';
|
| 805 |
+
try {
|
| 806 |
+
const d = await fetch('/api/patient/inbox').then(r => r.json());
|
| 807 |
+
document.getElementById('inboxLoading').style.display = 'none';
|
| 808 |
+
const threads = d.threads || [];
|
| 809 |
+
if (!threads.length) { document.getElementById('inboxEmpty').style.display = ''; return; }
|
| 810 |
+
let totalUnread = 0;
|
| 811 |
+
threads.forEach(t => {
|
| 812 |
+
totalUnread += t.unread_count || 0;
|
| 813 |
+
const hname = t.hospital_name || 'Hospital';
|
| 814 |
+
const trial = (t.trial_title || 'General Inquiry').substring(0, 60);
|
| 815 |
+
const preview = (t.last_message || 'No messages yet').substring(0, 100);
|
| 816 |
+
const card = document.createElement('div');
|
| 817 |
+
card.className = 'card bg-secondary mb-2';
|
| 818 |
+
const body = document.createElement('div');
|
| 819 |
+
body.className = 'card-body d-flex justify-content-between align-items-center gap-3';
|
| 820 |
+
const info = document.createElement('div');
|
| 821 |
+
info.style.minWidth = '0';
|
| 822 |
+
const nameEl = document.createElement('strong'); nameEl.textContent = hname;
|
| 823 |
+
info.appendChild(nameEl);
|
| 824 |
+
if (t.unread_count) {
|
| 825 |
+
const bEl = document.createElement('span');
|
| 826 |
+
bEl.className = 'badge bg-danger ms-2'; bEl.textContent = t.unread_count;
|
| 827 |
+
info.appendChild(bEl);
|
| 828 |
+
}
|
| 829 |
+
const trialEl = document.createElement('div');
|
| 830 |
+
trialEl.className = 'text-muted small text-truncate'; trialEl.textContent = trial;
|
| 831 |
+
const previewEl = document.createElement('div');
|
| 832 |
+
previewEl.className = 'text-secondary small fst-italic text-truncate'; previewEl.textContent = preview;
|
| 833 |
+
info.appendChild(trialEl); info.appendChild(previewEl);
|
| 834 |
+
const btn = document.createElement('button');
|
| 835 |
+
btn.className = 'btn btn-sm btn-outline-light flex-shrink-0';
|
| 836 |
+
btn.textContent = 'Open';
|
| 837 |
+
btn.onclick = () => openMsgModal(t.id, hname);
|
| 838 |
+
body.appendChild(info); body.appendChild(btn);
|
| 839 |
+
card.appendChild(body);
|
| 840 |
+
document.getElementById('inboxList').appendChild(card);
|
| 841 |
+
});
|
| 842 |
+
const badge = document.getElementById('inboxBadge');
|
| 843 |
+
if (totalUnread > 0) { badge.textContent = totalUnread; badge.style.display = ''; }
|
| 844 |
+
else badge.style.display = 'none';
|
| 845 |
+
} catch(e) {
|
| 846 |
+
document.getElementById('inboxLoading').style.display = 'none';
|
| 847 |
+
document.getElementById('inboxEmpty').style.display = '';
|
| 848 |
+
}
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
// ββ UTILS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 852 |
+
function escH(s) {
|
| 853 |
+
const d = document.createElement('div');
|
| 854 |
+
d.appendChild(document.createTextNode(String(s||'')));
|
| 855 |
+
return d.innerHTML;
|
| 856 |
+
}
|
| 857 |
+
function escA(s) {
|
| 858 |
+
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
| 859 |
+
}
|
| 860 |
+
async function logout() {
|
| 861 |
+
await fetch('/auth/logout', {method:'POST'});
|
| 862 |
+
location.href = '/';
|
| 863 |
+
}
|
| 864 |
+
</script>
|
| 865 |
+
</body>
|
| 866 |
+
</html>
|