MrNoOne07 commited on
Commit
d77360c
Β·
verified Β·
1 Parent(s): 54dcecb

Deploy Second Life Flask app

Browse files
.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&lt;br&gt;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&lt;br&gt;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&lt;br&gt;Handles login, APIs, sessions, and requests&lt;br&gt;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&lt;br&gt;Uploads folder for patient documents&lt;br&gt;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&lt;br&gt;Feature engineering + Random Forest model&lt;br&gt;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&lt;br&gt;ClinicalTrials.gov / AACT trial CSVs&lt;br&gt;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 -&amp;gt; Flask app -&amp;gt; matching engine -&amp;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

  • SHA256: f275ea340ea91d6993d2fe1b36c3154248d03aecf00394b1b2162db9e5953e1a
  • Pointer size: 131 Bytes
  • Size of remote file: 348 kB
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&#xa;conditions Β· meds&#xa;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&#xa;AI-ranked top-20&#xa;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&#xa;status Β· chat modal&#xa;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 πŸ”΄&#xa;unread count badge&#xa;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&#xa;&#xa;Login Β· Register&#xa;Patient &amp; 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&#xa;Patients&#xa;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&#xa;Condition&#xa;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&#xa;3-tier active&#xa;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 πŸ”΄&#xa;unread badge&#xa;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&#xa;Connections&#xa;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&#xa;name Β· location&#xa;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&#xa;(no session required)&#xa;&#xa;POST /auth/patient/register&#xa;POST /auth/patient/login&#xa;POST /auth/hospital/register&#xa;POST /auth/hospital/login&#xa;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&#xa;(patient session)&#xa;&#xa;GET | POST /api/patient/profile&#xa;GET /api/patient/matches&#xa;GET | POST | DELETE&#xa; /api/patient/interest&#xa;GET /api/patient/connections&#xa;POST /api/patient/connect&#xa;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&#xa;(hospital session)&#xa;&#xa;GET | POST /api/hospital/profile&#xa;GET /api/hospital/patients&#xa;GET /api/hospital/trials&#xa;POST /api/hospital/connect&#xa;GET /api/hospital/connections&#xa;PUT /api/hospital/connections/&#xa; &lt;cid&gt;/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&#xa;&#xa;GET | POST&#xa; /api/patient/connections/&lt;cid&gt;/messages&#xa;GET /api/patient/inbox&#xa;&#xa;GET | POST&#xa; /api/hospital/connections/&lt;cid&gt;/messages&#xa;GET /api/hospital/inbox&#xa;&#xa;β†’ mark_messages_read() on GET&#xa;β†’ 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&#xa;&#xa;GET /api/patient/documents&#xa;POST /api/patient/documents&#xa; β†’ multipart/form-data&#xa; β†’ secure_filename()&#xa; β†’ ALLOWED: .pdf .docx .doc&#xa; .txt .png .jpg .jpeg&#xa; β†’ max 10 MB&#xa;DELETE /api/patient/documents/&lt;id&gt;&#xa;GET /api/patient/documents/&lt;id&gt;/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&#xa;&#xa;GET /api/status&#xa; β†’ ready: true|false + stats&#xa;GET /api/conditions/autocomplete&#xa;&#xa;─────────────────────&#xa;πŸš€ Boot Thread (background)&#xa;β‘  db.init_db()&#xa; β†’ create 5 tables&#xa; β†’ seed 25 patients + 3 hospitals&#xa;β‘‘ pipeline.load()&#xa; β†’ read 11 CSV files into memory&#xa;β‘’ pipeline.train()&#xa; β†’ 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&#xa;25 rows&#xa;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&#xa;3 rows&#xa;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&#xa;UNIQUE(patient_id,&#xa;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&#xa;UNIQUE(patient, hospital,&#xa;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&#xa;sender_role: patient|hospital&#xa;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()&#xa;Reads 11 CSV files β†’ memory&#xa;trial_profiles Β· active_trial_ids&#xa;trial_us_states Β· facility_tokens&#xa;drug_keywords Β· summaries&#xa;patient_conditions Β· meds Β· labs&#xa;~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()&#xa;3000 patients Γ— 30 trials&#xa;_compute_features() 17 feats&#xa;pseudo-label: 6-feat weighted score&#xa;GroupShuffleSplit 80/20 (patient-level)&#xa;RandomForest n=200, depth=12&#xa;CalibratedClassifierCV isotonic cv=3&#xa;β†’ 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()&#xa;Input: conditions, age,&#xa; gender, patient_id&#xa;Scores all active trials&#xa;combined = 0.6 Γ— RF_prob&#xa; + 0.4 Γ— match_score&#xa;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()&#xa;Input: name, location,&#xa; research_conditions&#xa;Tier1: facility Jaccard β‰₯ 0.25&#xa;Tier2: same US state&#xa;Tier3: condition overlap&#xa;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()&#xa;Input: trial_id&#xa;Tier1: Jaccard β‰₯ 0.25 β†’ 🟒&#xa;Tier2: same state β†’ ⬜&#xa;Tier3: cond overlap β†’ ⬜&#xa;Tier4: fallback β†’ ⬜&#xa;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&#xa;SQLite file&#xa;5 tables Β· auto-created&#xa;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&#xa;RandomForest +&#xa;CalibratedClassifierCV&#xa;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/&#xa;&lt;patient_id&gt;/&#xa;&lt;doc_id&gt;_filename&#xa;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&#xa;&#xa;patients_details.csv&#xa; 265,893 patients Β· demographics&#xa;final_patients_conditions.csv&#xa; 967k rows Β· 106 conditions&#xa;patients_medications.csv&#xa; 213,182 patients&#xa;patients_observations.csv&#xa; 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&#xa;&#xa;trail_studies.csv β€” 65k recruiting trials&#xa;trail_conditions.csv β€” 34k with conditions&#xa;trail_eligibilities.csv β€” age Β· gender&#xa;trail_facilities.csv β€” 189k with US geo&#xa;trail_interventions.csv β€” 196k with drugs&#xa;trail_brief_summaries.csv β€” summaries&#xa;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 &rarr; Flask Server &rarr; Business Logic (DB + ML) &rarr; 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 &amp; 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/&lt;cid&gt;/messages GET Β· POST<br/>/api/patient/inbox GET<br/>/api/hospital/connections/&lt;cid&gt;/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/&lt;cid&gt;/status PUT"]
195
+ end
196
+ subgraph DOC_GRP["Documents"]
197
+ DR["/api/patient/documents GET Β· POST<br/>/api/patient/documents/&lt;id&gt; DELETE<br/>/api/patient/documents/&lt;id&gt;/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 (&lt;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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,'&amp;').replace(/"/g,'&quot;')
276
+ .replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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)} &bull; ${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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
859
+ }
860
+ async function logout() {
861
+ await fetch('/auth/logout', {method:'POST'});
862
+ location.href = '/';
863
+ }
864
+ </script>
865
+ </body>
866
+ </html>