SCGR commited on
Commit
65933cd
·
1 Parent(s): 87d93b3

contribute flow & UI refine

Browse files
frontend/src/components/Card.tsx DELETED
@@ -1,20 +0,0 @@
1
- import React from 'react'
2
-
3
- export interface CardProps {
4
- className?: string
5
- children: React.ReactNode
6
- }
7
-
8
- export default function Card({ children, className = '' }: CardProps) {
9
- return (
10
- <div
11
- className={
12
- `bg-white rounded-lg shadow p-6 ` +
13
- className
14
- }
15
- >
16
- {children}
17
- </div>
18
- )
19
- }
20
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/HeaderNav.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useLocation, useNavigate } from "react-router-dom";
2
- import { Button, PageContainer } from "@ifrc-go/ui";
3
  import {
4
  UploadCloudLineIcon,
5
  AnalysisIcon,
@@ -28,46 +28,62 @@ export default function HeaderNav() {
28
  >
29
  <div
30
  className="flex items-center gap-4 min-w-0 cursor-pointer group transition-all duration-200 hover:scale-105"
31
- onClick={() => navigate('/')}
 
 
 
 
 
 
 
 
 
 
 
32
  >
33
  <div className="p-2 rounded-lg bg-gradient-to-br from-ifrcRed/10 to-ifrcRed/20 group-hover:from-ifrcRed/20 group-hover:to-ifrcRed/30 transition-all duration-200">
34
  <GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
35
  </div>
36
  <div className="flex flex-col">
37
  <span className="font-bold text-xl text-gray-900 leading-tight">PromptAid Vision</span>
38
- <span className="text-sm text-gray-500 font-medium">AI-Powered Image Analysis</span>
39
  </div>
40
  </div>
41
 
42
- <nav className="flex items-center space-x-2 bg-gray-50/80 rounded-xl p-2 backdrop-blur-sm">
43
  {navItems.map(({ to, label, Icon }) => {
44
- const isActive = location.pathname === to;
 
45
  return (
46
  <div key={to} className="relative">
47
- <Button
48
- name={label.toLowerCase()}
49
- variant={isActive ? "primary" : "tertiary"}
50
- size={1}
51
- className={`transition-all duration-200 ${
52
- isActive
53
- ? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
54
- : 'hover:bg-white hover:shadow-md hover:scale-105'
55
- }`}
56
- onClick={() => {
 
57
  if (location.pathname === "/upload") {
58
- const uploadPage = document.querySelector('[data-step="2"]');
59
- if (uploadPage && !confirm("Changes will not be saved")) {
 
 
 
60
  return;
61
  }
62
  }
63
  navigate(to);
64
  }}
65
- >
66
- <Icon className={`w-4 h-4 transition-transform duration-200 ${
67
- isActive ? 'scale-110' : 'group-hover:scale-110'
68
- }`} />
69
- <span className="inline ml-2 font-semibold">{label}</span>
70
- </Button>
 
71
  {isActive && (
72
  <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-ifrcRed rounded-full animate-pulse"></div>
73
  )}
@@ -81,7 +97,18 @@ export default function HeaderNav() {
81
  variant="tertiary"
82
  size={1}
83
  className="transition-all duration-200 hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105"
84
- onClick={() => navigate('/help')}
 
 
 
 
 
 
 
 
 
 
 
85
  >
86
  <QuestionLineIcon className="w-4 h-4" />
87
  <span className="inline ml-2 font-semibold">Help & Support</span>
 
1
  import { useLocation, useNavigate } from "react-router-dom";
2
+ import { Button, PageContainer, Container } from "@ifrc-go/ui";
3
  import {
4
  UploadCloudLineIcon,
5
  AnalysisIcon,
 
28
  >
29
  <div
30
  className="flex items-center gap-4 min-w-0 cursor-pointer group transition-all duration-200 hover:scale-105"
31
+ onClick={() => {
32
+ if (location.pathname === "/upload") {
33
+ if ((window as any).confirmNavigationIfNeeded) {
34
+ (window as any).confirmNavigationIfNeeded('/');
35
+ return;
36
+ }
37
+ if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
38
+ return;
39
+ }
40
+ }
41
+ navigate('/');
42
+ }}
43
  >
44
  <div className="p-2 rounded-lg bg-gradient-to-br from-ifrcRed/10 to-ifrcRed/20 group-hover:from-ifrcRed/20 group-hover:to-ifrcRed/30 transition-all duration-200">
45
  <GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
46
  </div>
47
  <div className="flex flex-col">
48
  <span className="font-bold text-xl text-gray-900 leading-tight">PromptAid Vision</span>
 
49
  </div>
50
  </div>
51
 
52
+ <nav className="flex items-center space-x-4 bg-gray-50/80 rounded-xl p-2 backdrop-blur-sm">
53
  {navItems.map(({ to, label, Icon }) => {
54
+ const isActive = location.pathname === to ||
55
+ (to === '/explore' && location.pathname.startsWith('/map/'));
56
  return (
57
  <div key={to} className="relative">
58
+ <Container withInternalPadding className="p-2">
59
+ <Button
60
+ name={label.toLowerCase()}
61
+ variant={isActive ? "primary" : "tertiary"}
62
+ size={1}
63
+ className={`transition-all duration-200 ${
64
+ isActive
65
+ ? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
66
+ : 'hover:bg-white hover:shadow-md hover:scale-105'
67
+ }`}
68
+ onClick={() => {
69
  if (location.pathname === "/upload") {
70
+ if ((window as any).confirmNavigationIfNeeded) {
71
+ (window as any).confirmNavigationIfNeeded(to);
72
+ return;
73
+ }
74
+ if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
75
  return;
76
  }
77
  }
78
  navigate(to);
79
  }}
80
+ >
81
+ <Icon className={`w-4 h-4 transition-transform duration-200 ${
82
+ isActive ? 'scale-110' : 'group-hover:scale-110'
83
+ }`} />
84
+ <span className="inline ml-2 font-semibold">{label}</span>
85
+ </Button>
86
+ </Container>
87
  {isActive && (
88
  <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-ifrcRed rounded-full animate-pulse"></div>
89
  )}
 
97
  variant="tertiary"
98
  size={1}
99
  className="transition-all duration-200 hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105"
100
+ onClick={() => {
101
+ if (location.pathname === "/upload") {
102
+ if ((window as any).confirmNavigationIfNeeded) {
103
+ (window as any).confirmNavigationIfNeeded('/help');
104
+ return;
105
+ }
106
+ if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
107
+ return;
108
+ }
109
+ }
110
+ navigate('/help');
111
+ }}
112
  >
113
  <QuestionLineIcon className="w-4 h-4" />
114
  <span className="inline ml-2 font-semibold">Help & Support</span>
frontend/src/pages/DevPage.tsx CHANGED
@@ -35,7 +35,7 @@ export default function DevPage() {
35
  }
36
  }
37
  })
38
- .catch(err => {
39
 
40
  });
41
  };
@@ -183,10 +183,10 @@ export default function DevPage() {
183
  onClick={() => {
184
  fetch('/api/models')
185
  .then(r => r.json())
186
- .then(data => {
187
  alert('Models API response received successfully');
188
  })
189
- .catch(err => {
190
  alert('Models API error occurred');
191
  });
192
  }}
@@ -202,10 +202,10 @@ export default function DevPage() {
202
  if (!selectedModel) return;
203
  fetch(`/api/models/${selectedModel}/test`)
204
  .then(r => r.json())
205
- .then(data => {
206
  alert('Model test completed successfully');
207
  })
208
- .catch(err => {
209
  alert('Model test failed');
210
  });
211
  }}
 
35
  }
36
  }
37
  })
38
+ .catch(() => {
39
 
40
  });
41
  };
 
183
  onClick={() => {
184
  fetch('/api/models')
185
  .then(r => r.json())
186
+ .then(() => {
187
  alert('Models API response received successfully');
188
  })
189
+ .catch(() => {
190
  alert('Models API error occurred');
191
  });
192
  }}
 
202
  if (!selectedModel) return;
203
  fetch(`/api/models/${selectedModel}/test`)
204
  .then(r => r.json())
205
+ .then(() => {
206
  alert('Model test completed successfully');
207
  })
208
+ .catch(() => {
209
  alert('Model test failed');
210
  });
211
  }}
frontend/src/pages/ExplorePage/ExplorePage.module.css CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  .metadataTags {
2
  display: flex;
3
  flex-wrap: wrap;
@@ -7,7 +11,7 @@
7
 
8
  .metadataTag {
9
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
10
- background-color: var(--go-ui-color-red-10);
11
  color: var(--go-ui-color-red-90);
12
  font-size: var(--go-ui-font-size-xs);
13
  border-radius: var(--go-ui-border-radius-md);
@@ -18,30 +22,29 @@
18
  }
19
 
20
  .metadataTag:hover {
21
- background-color: var(--go-ui-color-red-20);
22
- transform: translateY(-1px);
23
- box-shadow: var(--go-ui-box-shadow-xs);
24
  }
25
 
26
  .metadataTagSource {
27
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
28
- background-color: var(--go-ui-color-blue-10);
29
- color: var(--go-ui-color-blue-90);
30
  font-size: var(--go-ui-font-size-xs);
31
  border-radius: var(--go-ui-border-radius-md);
32
  font-weight: var(--go-ui-font-weight-medium);
33
- border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-blue-20);
34
  white-space: nowrap;
35
  }
36
 
37
  .metadataTagType {
38
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
39
- background-color: var(--go-ui-color-red-90);
40
- color: var(--go-ui-color-white);
41
  font-size: var(--go-ui-font-size-xs);
42
  border-radius: var(--go-ui-border-radius-md);
43
  font-weight: var(--go-ui-font-weight-medium);
44
- border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-90);
45
  white-space: nowrap;
46
  }
47
 
 
1
+ .tabSelector {
2
+ margin-bottom: var(--go-ui-spacing-lg);
3
+ }
4
+
5
  .metadataTags {
6
  display: flex;
7
  flex-wrap: wrap;
 
11
 
12
  .metadataTag {
13
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
14
+ background-color: var(--go-ui-color-red-5);
15
  color: var(--go-ui-color-red-90);
16
  font-size: var(--go-ui-font-size-xs);
17
  border-radius: var(--go-ui-border-radius-md);
 
22
  }
23
 
24
  .metadataTag:hover {
25
+ background-color: var(--go-ui-color-red-10);
26
+ border-color: var(--go-ui-color-red-30);
 
27
  }
28
 
29
  .metadataTagSource {
30
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
31
+ background-color: var(--go-ui-color-red-5);
32
+ color: var(--go-ui-color-red-90);
33
  font-size: var(--go-ui-font-size-xs);
34
  border-radius: var(--go-ui-border-radius-md);
35
  font-weight: var(--go-ui-font-weight-medium);
36
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
37
  white-space: nowrap;
38
  }
39
 
40
  .metadataTagType {
41
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
42
+ background-color: var(--go-ui-color-red-5);
43
+ color: var(--go-ui-color-red-90);
44
  font-size: var(--go-ui-font-size-xs);
45
  border-radius: var(--go-ui-border-radius-md);
46
  font-weight: var(--go-ui-font-weight-medium);
47
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
48
  white-space: nowrap;
49
  }
50
 
frontend/src/pages/ExplorePage/ExplorePage.tsx CHANGED
@@ -1,11 +1,9 @@
1
- import { PageContainer, TextInput, SelectInput, MultiSelectInput, Button, Container } from '@ifrc-go/ui';
2
  import { useState, useEffect, useMemo } from 'react';
3
  import { useNavigate } from 'react-router-dom';
4
- import { StarLineIcon } from '@ifrc-go/icons';
5
  import styles from './ExplorePage.module.css';
6
 
7
- interface CaptionWithImageOut {
8
- cap_id: string;
9
  image_id: string;
10
  title: string;
11
  prompt: string;
@@ -31,19 +29,25 @@ interface CaptionWithImageOut {
31
 
32
  export default function ExplorePage() {
33
  const navigate = useNavigate();
34
- const [captions, setCaptions] = useState<CaptionWithImageOut[]>([]);
 
35
  const [search, setSearch] = useState('');
36
  const [srcFilter, setSrcFilter] = useState('');
37
  const [catFilter, setCatFilter] = useState('');
38
  const [regionFilter, setRegionFilter] = useState('');
39
  const [countryFilter, setCountryFilter] = useState('');
40
- const [showStarredOnly, setShowStarredOnly] = useState(false);
41
  const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
42
  const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
43
  const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
44
  const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
 
45
  const [isLoadingFilters, setIsLoadingFilters] = useState(true);
46
 
 
 
 
 
 
47
  const fetchCaptions = () => {
48
  setIsLoadingFilters(true);
49
  fetch('/api/captions')
@@ -55,10 +59,12 @@ export default function ExplorePage() {
55
  })
56
  .then(data => {
57
  if (Array.isArray(data)) {
58
- setCaptions(data);
59
-
 
 
 
60
  } else {
61
-
62
  setCaptions([]);
63
  }
64
  })
@@ -107,191 +113,147 @@ export default function ExplorePage() {
107
  fetch('/api/countries').then(r => {
108
  if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
109
  return r.json();
110
- })
111
- ]).then(([sourcesData, typesData, regionsData, countriesData]) => {
112
-
113
-
114
- if (Array.isArray(sourcesData)) {
115
- setSources(sourcesData);
116
- } else {
117
-
118
- setSources([]);
119
- }
120
-
121
- if (Array.isArray(typesData)) {
122
- setTypes(typesData);
123
- } else {
124
-
125
- setTypes([]);
126
- }
127
-
128
- if (Array.isArray(regionsData)) {
129
- setRegions(regionsData);
130
- } else {
131
-
132
- setRegions([]);
133
- }
134
-
135
- if (Array.isArray(countriesData)) {
136
- setCountries(countriesData);
137
- } else {
138
-
139
- setCountries([]);
140
- }
141
-
142
- setIsLoadingFilters(false);
143
  }).catch(() => {
144
-
145
- setSources([]);
146
- setTypes([]);
147
- setRegions([]);
148
- setCountries([]);
149
  setIsLoadingFilters(false);
150
  });
151
  }, []);
152
 
153
  const filtered = useMemo(() => {
154
- if (!Array.isArray(captions)) {
155
-
156
- return [];
157
- }
158
-
159
  return captions.filter(c => {
160
- const searchLower = search.toLowerCase();
161
- const searchMatch = !search ||
162
- c.file_key.toLowerCase().includes(searchLower) ||
163
- c.source.toLowerCase().includes(searchLower) ||
164
- c.event_type.toLowerCase().includes(searchLower) ||
165
- c.title.toLowerCase().includes(searchLower) ||
166
- (c.edited && c.edited.toLowerCase().includes(searchLower)) ||
167
- (c.generated && c.generated.toLowerCase().includes(searchLower));
168
 
169
- const sourceMatch = !srcFilter || c.source === srcFilter;
170
- const typeMatch = !catFilter || c.event_type === catFilter;
171
- const regionMatch = !regionFilter || (c.countries && c.countries.some(c => c.r_code === regionFilter));
172
- const countryMatch = !countryFilter || (c.countries && c.countries.some(c => c.c_code === countryFilter));
173
- const starredMatch = !showStarredOnly || c.starred === true;
 
174
 
175
- return searchMatch && sourceMatch && typeMatch && regionMatch && countryMatch && starredMatch;
176
  });
177
  }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]);
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  return (
180
  <PageContainer>
181
  <Container
182
- heading="Explore Examples"
183
  headingLevel={2}
184
  withHeaderBorder
185
  withInternalPadding
186
  className="max-w-7xl mx-auto"
187
  >
188
- <div className="space-y-6">
189
- {/* Header Section */}
190
- <div className="flex justify-between items-center">
191
- <div>
192
- <p className="text-gray-600 mt-1">Browse and search through uploaded crisis maps</p>
193
- </div>
194
- <div className="flex gap-2">
195
- <Button
196
- name="reference-examples"
197
- variant={showStarredOnly ? "primary" : "secondary"}
198
- onClick={() => setShowStarredOnly(!showStarredOnly)}
199
- >
200
- <StarLineIcon className="w-4 h-4" />
201
- <span className="inline ml-2">Reference Examples</span>
202
- </Button>
203
- <Button
204
- name="export"
205
- variant="secondary"
206
- onClick={() => {
207
- const data = {
208
- captions: captions,
209
- filters: {
210
- sources: sources,
211
- types: types,
212
- regions: regions,
213
- countries: countries
214
- },
215
- timestamp: new Date().toISOString()
216
- };
217
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
218
- const url = URL.createObjectURL(blob);
219
- const a = document.createElement('a');
220
- a.href = url;
221
- a.download = `promptaid-vision-captions-${new Date().toISOString().split('T')[0]}.json`;
222
- document.body.appendChild(a);
223
- a.click();
224
- document.body.removeChild(a);
225
- URL.revokeObjectURL(url);
226
- }}
227
- >
228
- Export
229
- </Button>
230
- </div>
231
- </div>
232
 
233
- {/* Filters Bar */}
234
- <Container heading="Search & Filters" headingLevel={3} withHeaderBorder withInternalPadding>
235
- <div className="flex flex-wrap gap-4 items-center">
236
- <TextInput
237
- name="search"
238
- placeholder="Search by filename, title…"
239
- value={search}
240
- onChange={(e) => setSearch(e || '')}
241
- className="flex-1 min-w-[12rem]"
242
- />
 
243
 
244
- <SelectInput
245
- name="source"
246
- placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
247
- options={sources}
248
- value={srcFilter || null}
249
- onChange={(v) => setSrcFilter(v as string || '')}
250
- keySelector={(o) => o.s_code}
251
- labelSelector={(o) => o.label}
252
- required={false}
253
- disabled={isLoadingFilters}
254
- />
255
 
256
- <SelectInput
257
- name="type"
258
- placeholder={isLoadingFilters ? "Loading..." : "All Types"}
259
- options={types}
260
- value={catFilter || null}
261
- onChange={(v) => setCatFilter(v as string || '')}
262
- keySelector={(o) => o.t_code}
263
- labelSelector={(o) => o.label}
264
- required={false}
265
- disabled={isLoadingFilters}
266
- />
267
 
268
- <SelectInput
269
- name="region"
270
- placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
271
- options={regions}
272
- value={regionFilter || null}
273
- onChange={(v) => setRegionFilter(v as string || '')}
274
- keySelector={(o) => o.r_code}
275
- labelSelector={(o) => o.label}
276
- required={false}
277
- disabled={isLoadingFilters}
278
- />
279
 
280
- <MultiSelectInput
281
- name="country"
282
- placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
283
- options={countries}
284
- value={countryFilter ? [countryFilter] : []}
285
- onChange={(v) => setCountryFilter((v as string[])[0] || '')}
286
- keySelector={(o) => o.c_code}
287
- labelSelector={(o) => o.label}
288
- disabled={isLoadingFilters}
289
- />
 
290
  </div>
291
- </Container>
292
 
293
- {/* Results Section */}
294
- <Container heading="Results" headingLevel={3} withHeaderBorder withInternalPadding>
295
  <div className="space-y-4">
296
  <div className="flex justify-between items-center">
297
  <p className="text-sm text-gray-600">
@@ -302,7 +264,7 @@ export default function ExplorePage() {
302
  {/* List */}
303
  <div className="space-y-4">
304
  {filtered.map(c => (
305
- <div key={c.cap_id} className={styles.mapItem} onClick={() => navigate(`/map/${c.image_id}?captionId=${c.cap_id}`)}>
306
  <div className={styles.mapItemImage} style={{ width: '120px', height: '80px' }}>
307
  {c.image_url ? (
308
  <img
@@ -325,17 +287,24 @@ export default function ExplorePage() {
325
  <div className={styles.mapItemMetadata}>
326
  <div className={styles.metadataTags}>
327
  <span className={styles.metadataTagSource}>
328
- {c.source}
329
  </span>
330
  <span className={styles.metadataTagType}>
331
- {c.event_type}
332
- </span>
333
- <span className={styles.metadataTag}>
334
- {c.epsg}
335
  </span>
336
  <span className={styles.metadataTag}>
337
- {c.image_type}
338
  </span>
 
 
 
 
 
 
 
 
 
 
339
  </div>
340
  </div>
341
  </div>
@@ -349,8 +318,15 @@ export default function ExplorePage() {
349
  )}
350
  </div>
351
  </div>
352
- </Container>
353
- </div>
 
 
 
 
 
 
 
354
  </Container>
355
  </PageContainer>
356
  );
 
1
+ import { PageContainer, TextInput, SelectInput, MultiSelectInput, Container, SegmentInput } from '@ifrc-go/ui';
2
  import { useState, useEffect, useMemo } from 'react';
3
  import { useNavigate } from 'react-router-dom';
 
4
  import styles from './ExplorePage.module.css';
5
 
6
+ interface ImageWithCaptionOut {
 
7
  image_id: string;
8
  title: string;
9
  prompt: string;
 
29
 
30
  export default function ExplorePage() {
31
  const navigate = useNavigate();
32
+ const [view, setView] = useState<'explore' | 'mapDetails'>('explore');
33
+ const [captions, setCaptions] = useState<ImageWithCaptionOut[]>([]);
34
  const [search, setSearch] = useState('');
35
  const [srcFilter, setSrcFilter] = useState('');
36
  const [catFilter, setCatFilter] = useState('');
37
  const [regionFilter, setRegionFilter] = useState('');
38
  const [countryFilter, setCountryFilter] = useState('');
 
39
  const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
40
  const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
41
  const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
42
  const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
43
+ const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
44
  const [isLoadingFilters, setIsLoadingFilters] = useState(true);
45
 
46
+ const viewOptions = [
47
+ { key: 'explore' as const, label: 'Explore' },
48
+ { key: 'mapDetails' as const, label: 'Map Details' }
49
+ ];
50
+
51
  const fetchCaptions = () => {
52
  setIsLoadingFilters(true);
53
  fetch('/api/captions')
 
59
  })
60
  .then(data => {
61
  if (Array.isArray(data)) {
62
+ const imagesWithCaptions = data.filter((item: any) => {
63
+ const hasCaption = item.title && item.generated && item.model;
64
+ return hasCaption;
65
+ });
66
+ setCaptions(imagesWithCaptions);
67
  } else {
 
68
  setCaptions([]);
69
  }
70
  })
 
113
  fetch('/api/countries').then(r => {
114
  if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
115
  return r.json();
116
+ }),
117
+ fetch('/api/image-types').then(r => {
118
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
119
+ return r.json();
120
+ }),
121
+ ]).then(([sourcesData, typesData, regionsData, countriesData, imageTypesData]) => {
122
+ setSources(sourcesData);
123
+ setTypes(typesData);
124
+ setRegions(regionsData);
125
+ setCountries(countriesData);
126
+ setImageTypes(imageTypesData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  }).catch(() => {
128
+ }).finally(() => {
 
 
 
 
129
  setIsLoadingFilters(false);
130
  });
131
  }, []);
132
 
133
  const filtered = useMemo(() => {
 
 
 
 
 
134
  return captions.filter(c => {
135
+ const matchesSearch = !search ||
136
+ c.title?.toLowerCase().includes(search.toLowerCase()) ||
137
+ c.generated?.toLowerCase().includes(search.toLowerCase()) ||
138
+ c.source?.toLowerCase().includes(search.toLowerCase()) ||
139
+ c.event_type?.toLowerCase().includes(search.toLowerCase());
 
 
 
140
 
141
+ const matchesSource = !srcFilter || c.source === srcFilter;
142
+ const matchesCategory = !catFilter || c.event_type === catFilter;
143
+ const matchesRegion = !regionFilter ||
144
+ c.countries.some(country => country.r_code === regionFilter);
145
+ const matchesCountry = !countryFilter ||
146
+ c.countries.some(country => country.c_code === countryFilter);
147
 
148
+ return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry;
149
  });
150
  }, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]);
151
 
152
+ const toggleStarred = (imageId: string) => {
153
+ setCaptions(prev => prev.map(c =>
154
+ c.image_id === imageId ? { ...c, starred: !c.starred } : c
155
+ ));
156
+
157
+ fetch(`/api/images/${imageId}/caption`, {
158
+ method: 'PUT',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ starred: !captions.find(c => c.image_id === imageId)?.starred })
161
+ }).catch(() => {
162
+ setCaptions(prev => prev.map(c =>
163
+ c.image_id === imageId ? { ...c, starred: !c.starred } : c
164
+ ));
165
+ });
166
+ };
167
+
168
  return (
169
  <PageContainer>
170
  <Container
171
+ heading="Explore"
172
  headingLevel={2}
173
  withHeaderBorder
174
  withInternalPadding
175
  className="max-w-7xl mx-auto"
176
  >
177
+ <div className={styles.tabSelector}>
178
+ <SegmentInput
179
+ name="explore-view"
180
+ value={view}
181
+ onChange={(value) => {
182
+ if (value === 'explore' || value === 'mapDetails') {
183
+ setView(value);
184
+ if (value === 'mapDetails' && captions.length > 0) {
185
+ navigate(`/map/${captions[0].image_id}`);
186
+ }
187
+ }
188
+ }}
189
+ options={viewOptions}
190
+ keySelector={(o) => o.key}
191
+ labelSelector={(o) => o.label}
192
+ />
193
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
+ {view === 'explore' ? (
196
+ <div className="space-y-6">
197
+ {/* Search and Filters */}
198
+ <div className="space-y-4">
199
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
200
+ <TextInput
201
+ name="search"
202
+ placeholder="Search examples..."
203
+ value={search}
204
+ onChange={(v) => setSearch(v || '')}
205
+ />
206
 
207
+ <SelectInput
208
+ name="source"
209
+ placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
210
+ options={sources}
211
+ value={srcFilter || null}
212
+ onChange={(v) => setSrcFilter(v as string || '')}
213
+ keySelector={(o) => o.s_code}
214
+ labelSelector={(o) => o.label}
215
+ required={false}
216
+ disabled={isLoadingFilters}
217
+ />
218
 
219
+ <SelectInput
220
+ name="category"
221
+ placeholder={isLoadingFilters ? "Loading..." : "All Categories"}
222
+ options={types}
223
+ value={catFilter || null}
224
+ onChange={(v) => setCatFilter(v as string || '')}
225
+ keySelector={(o) => o.t_code}
226
+ labelSelector={(o) => o.label}
227
+ required={false}
228
+ disabled={isLoadingFilters}
229
+ />
230
 
231
+ <SelectInput
232
+ name="region"
233
+ placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
234
+ options={regions}
235
+ value={regionFilter || null}
236
+ onChange={(v) => setRegionFilter(v as string || '')}
237
+ keySelector={(o) => o.r_code}
238
+ labelSelector={(o) => o.label}
239
+ required={false}
240
+ disabled={isLoadingFilters}
241
+ />
242
 
243
+ <MultiSelectInput
244
+ name="country"
245
+ placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
246
+ options={countries}
247
+ value={countryFilter ? [countryFilter] : []}
248
+ onChange={(v) => setCountryFilter((v as string[])[0] || '')}
249
+ keySelector={(o) => o.c_code}
250
+ labelSelector={(o) => o.label}
251
+ disabled={isLoadingFilters}
252
+ />
253
+ </div>
254
  </div>
 
255
 
256
+ {/* Results Section */}
 
257
  <div className="space-y-4">
258
  <div className="flex justify-between items-center">
259
  <p className="text-sm text-gray-600">
 
264
  {/* List */}
265
  <div className="space-y-4">
266
  {filtered.map(c => (
267
+ <div key={c.image_id} className={styles.mapItem} onClick={() => navigate(`/map/${c.image_id}`)}>
268
  <div className={styles.mapItemImage} style={{ width: '120px', height: '80px' }}>
269
  {c.image_url ? (
270
  <img
 
287
  <div className={styles.mapItemMetadata}>
288
  <div className={styles.metadataTags}>
289
  <span className={styles.metadataTagSource}>
290
+ {sources.find(s => s.s_code === c.source)?.label || c.source}
291
  </span>
292
  <span className={styles.metadataTagType}>
293
+ {types.find(t => t.t_code === c.event_type)?.label || c.event_type}
 
 
 
294
  </span>
295
  <span className={styles.metadataTag}>
296
+ {imageTypes.find(it => it.image_type === c.image_type)?.label || c.image_type}
297
  </span>
298
+ {c.countries && c.countries.length > 0 && (
299
+ <>
300
+ <span className={styles.metadataTag}>
301
+ {regions.find(r => r.r_code === c.countries[0].r_code)?.label || 'Unknown Region'}
302
+ </span>
303
+ <span className={styles.metadataTag}>
304
+ {c.countries.map(country => country.label).join(', ')}
305
+ </span>
306
+ </>
307
+ )}
308
  </div>
309
  </div>
310
  </div>
 
318
  )}
319
  </div>
320
  </div>
321
+ </div>
322
+ ) : (
323
+ <div className="space-y-6">
324
+ <div className="text-center py-12">
325
+ <p className="text-gray-500">Map Details view coming soon...</p>
326
+ <p className="text-sm text-gray-400 mt-2">This will show detailed information about individual maps</p>
327
+ </div>
328
+ </div>
329
+ )}
330
  </Container>
331
  </PageContainer>
332
  );
frontend/src/pages/MapDetailsPage/MapDetailPage.module.css CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  .backButton {
2
  margin-bottom: var(--go-ui-spacing-lg);
3
  }
@@ -43,7 +47,7 @@
43
 
44
  .metadataTag {
45
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
46
- background-color: var(--go-ui-color-red-10);
47
  color: var(--go-ui-color-red-90);
48
  font-size: var(--go-ui-font-size-sm);
49
  border-radius: var(--go-ui-border-radius-md);
@@ -53,9 +57,8 @@
53
  }
54
 
55
  .metadataTag:hover {
56
- background-color: var(--go-ui-color-red-20);
57
- transform: translateY(-1px);
58
- box-shadow: var(--go-ui-box-shadow-xs);
59
  }
60
 
61
  .captionContainer {
 
1
+ .tabSelector {
2
+ margin-bottom: var(--go-ui-spacing-lg);
3
+ }
4
+
5
  .backButton {
6
  margin-bottom: var(--go-ui-spacing-lg);
7
  }
 
47
 
48
  .metadataTag {
49
  padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
50
+ background-color: var(--go-ui-color-red-5);
51
  color: var(--go-ui-color-red-90);
52
  font-size: var(--go-ui-font-size-sm);
53
  border-radius: var(--go-ui-border-radius-md);
 
57
  }
58
 
59
  .metadataTag:hover {
60
+ background-color: var(--go-ui-color-red-10);
61
+ border-color: var(--go-ui-color-red-30);
 
62
  }
63
 
64
  .captionContainer {
frontend/src/pages/MapDetailsPage/MapDetailPage.tsx CHANGED
@@ -1,38 +1,53 @@
1
- import { PageContainer, Button, Container, Spinner } from '@ifrc-go/ui';
 
2
  import { useState, useEffect } from 'react';
3
- import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
4
  import styles from './MapDetailPage.module.css';
5
 
6
  interface MapOut {
7
  image_id: string;
8
  file_key: string;
9
- image_url: string;
10
  source: string;
11
  event_type: string;
12
  epsg: string;
13
  image_type: string;
14
- countries?: Array<{
 
15
  c_code: string;
16
  label: string;
17
  r_code: string;
18
  }>;
19
- captions?: Array<{
20
- title: string;
21
- generated: string;
22
- edited?: string;
23
- cap_id?: string;
24
- }>;
 
 
 
 
 
 
 
25
  }
26
 
27
  export default function MapDetailPage() {
28
  const { mapId } = useParams<{ mapId: string }>();
29
  const navigate = useNavigate();
30
- const [searchParams] = useSearchParams();
31
- const captionId = searchParams.get('captionId');
32
  const [map, setMap] = useState<MapOut | null>(null);
33
  const [loading, setLoading] = useState(true);
34
  const [error, setError] = useState<string | null>(null);
 
 
 
 
35
 
 
 
 
 
36
 
37
  useEffect(() => {
38
  if (!mapId) {
@@ -58,14 +73,76 @@ export default function MapDetailPage() {
58
  });
59
  }, [mapId]);
60
 
61
- const handleContribute = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  if (!map) return;
63
 
64
- const url = captionId ?
65
- `/upload?mapId=${map.image_id}&step=2&captionId=${captionId}` :
66
- `/upload?mapId=${map.image_id}&step=2`;
67
- navigate(url);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  };
 
69
 
70
  if (loading) {
71
  return (
@@ -103,123 +180,121 @@ export default function MapDetailPage() {
103
 
104
  return (
105
  <PageContainer>
106
- <div className={styles.backButton}>
107
- <Button
108
- name="back"
109
- variant="secondary"
110
- onClick={() => navigate('/explore')}
111
- >
112
- ← Back to Explore
113
- </Button>
114
- </div>
115
-
116
- <div className={styles.gridLayout}>
117
- {/* Image Section */}
118
- <Container
119
- heading="Map Image"
120
- headingLevel={3}
121
- withHeaderBorder
122
- withInternalPadding
123
- spacing="comfortable"
124
- >
125
- <div className={styles.imageContainer}>
126
- {map.image_url ? (
127
- <img
128
- src={map.image_url}
129
- alt={map.file_key}
130
- />
131
- ) : (
132
- <div className={styles.imagePlaceholder}>
133
- No image available
134
- </div>
135
- )}
136
- </div>
137
- </Container>
138
 
139
- {/* Details Section */}
140
- <div className={styles.detailsSection}>
141
- <Container
142
- heading="Title"
143
- headingLevel={3}
144
- withHeaderBorder
145
- withInternalPadding
146
- spacing="comfortable"
147
- >
148
- <div className="text-gray-700">
149
- {map.captions && map.captions.length > 0 ? map.captions[0].title : '— no title —'}
150
- </div>
151
- </Container>
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- <Container
154
- heading="Metadata"
155
- headingLevel={3}
156
- withHeaderBorder
157
- withInternalPadding
158
- spacing="comfortable"
159
- >
160
- <div className={styles.metadataTags}>
161
- <span className={styles.metadataTag}>
162
- {map.source}
163
- </span>
164
- <span className={styles.metadataTag}>
165
- {map.event_type}
166
- </span>
167
- <span className={styles.metadataTag}>
168
- {map.epsg}
169
- </span>
170
- <span className={styles.metadataTag}>
171
- {map.image_type}
172
- </span>
173
- </div>
174
- </Container>
 
 
 
 
 
 
 
 
 
175
 
176
- <Container
177
- heading="Generated Caption"
178
- headingLevel={3}
179
- withHeaderBorder
180
- withInternalPadding
181
- spacing="comfortable"
182
- >
183
- <div className={styles.captionContainer}>
184
- {map.captions && map.captions.length > 0 ? (
185
- map.captions.map((caption, index) => (
186
- <div
187
- key={index}
188
- className={`${styles.captionText} ${
189
- captionId && map.captions && map.captions[index] &&
190
- 'cap_id' in map.captions[index] &&
191
- map.captions[index].cap_id === captionId ?
192
- styles.highlightedCaption : ''
193
- }`}
194
- >
195
- <p>{caption.edited || caption.generated}</p>
196
- {captionId && map.captions && map.captions[index] &&
197
- 'cap_id' in map.captions[index] &&
198
- map.captions[index].cap_id === captionId && (
199
- <div className={styles.captionHighlight}>
200
- ← This is the caption you selected
201
  </div>
 
 
202
  )}
203
  </div>
204
- ))
205
- ) : (
206
- <p>— no caption yet —</p>
207
- )}
208
  </div>
209
- </Container>
210
- </div>
211
- </div>
212
 
213
- {/* Contribute Section */}
214
- <div className={styles.contributeSection}>
215
- <Button
216
- name="contribute"
217
- onClick={handleContribute}
218
- className={styles.contributeButton}
219
- >
220
- Contribute
221
- </Button>
222
- </div>
 
 
 
223
  </PageContainer>
224
  );
225
  }
 
1
+ import { PageContainer, Container, Button, Spinner, SegmentInput } from '@ifrc-go/ui';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
  import { useState, useEffect } from 'react';
 
4
  import styles from './MapDetailPage.module.css';
5
 
6
  interface MapOut {
7
  image_id: string;
8
  file_key: string;
9
+ sha256: string;
10
  source: string;
11
  event_type: string;
12
  epsg: string;
13
  image_type: string;
14
+ image_url: string;
15
+ countries: Array<{
16
  c_code: string;
17
  label: string;
18
  r_code: string;
19
  }>;
20
+ title?: string;
21
+ prompt?: string;
22
+ model?: string;
23
+ schema_id?: string;
24
+ raw_json?: any;
25
+ generated?: string;
26
+ edited?: string;
27
+ accuracy?: number;
28
+ context?: number;
29
+ usability?: number;
30
+ starred?: boolean;
31
+ created_at?: string;
32
+ updated_at?: string;
33
  }
34
 
35
  export default function MapDetailPage() {
36
  const { mapId } = useParams<{ mapId: string }>();
37
  const navigate = useNavigate();
38
+ const [view, setView] = useState<'explore' | 'mapDetails'>('mapDetails');
 
39
  const [map, setMap] = useState<MapOut | null>(null);
40
  const [loading, setLoading] = useState(true);
41
  const [error, setError] = useState<string | null>(null);
42
+ const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
43
+ const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
44
+ const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
45
+ const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
46
 
47
+ const viewOptions = [
48
+ { key: 'explore' as const, label: 'Explore' },
49
+ { key: 'mapDetails' as const, label: 'Map Details' }
50
+ ];
51
 
52
  useEffect(() => {
53
  if (!mapId) {
 
73
  });
74
  }, [mapId]);
75
 
76
+ useEffect(() => {
77
+ Promise.all([
78
+ fetch('/api/sources').then(r => r.json()),
79
+ fetch('/api/types').then(r => r.json()),
80
+ fetch('/api/image-types').then(r => r.json()),
81
+ fetch('/api/regions').then(r => r.json()),
82
+ ]).then(([sourcesData, typesData, imageTypesData, regionsData]) => {
83
+ setSources(sourcesData);
84
+ setTypes(typesData);
85
+ setImageTypes(imageTypesData);
86
+ setRegions(regionsData);
87
+ }).catch(console.error);
88
+ }, []);
89
+
90
+ const [isGenerating, setIsGenerating] = useState(false);
91
+
92
+ const handleContribute = async () => {
93
  if (!map) return;
94
 
95
+ setIsGenerating(true);
96
+
97
+ try {
98
+ const res = await fetch('/api/contribute/from-url', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({
102
+ url: map.image_url,
103
+ source: map.source,
104
+ event_type: map.event_type,
105
+ epsg: map.epsg,
106
+ image_type: map.image_type,
107
+ countries: map.countries.map(c => c.c_code),
108
+ }),
109
+ });
110
+
111
+ if (!res.ok) {
112
+ const errorData = await res.json();
113
+ throw new Error(errorData.error || 'Failed to create contribution');
114
+ }
115
+
116
+ const json = await res.json();
117
+ const newId = json.image_id as string;
118
+
119
+ const modelName = localStorage.getItem('selectedVlmModel');
120
+ const capRes = await fetch(`/api/images/${newId}/caption`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
123
+ body: new URLSearchParams({
124
+ title: 'Generated Caption',
125
+ prompt: 'Analyze this crisis map and provide a detailed description of the emergency situation, affected areas, and key information shown in the map.',
126
+ ...(modelName && { model_name: modelName }),
127
+ }),
128
+ });
129
+
130
+ if (!capRes.ok) {
131
+ const errorData = await capRes.json();
132
+ throw new Error(errorData.error || 'Failed to generate caption');
133
+ }
134
+
135
+ const url = `/upload?imageUrl=${encodeURIComponent(json.image_url)}&isContribution=true&step=2a&imageId=${newId}`;
136
+ navigate(url);
137
+
138
+ } catch (error: any) {
139
+ console.error('Contribution failed:', error);
140
+ alert(`Contribution failed: ${error.message || 'Unknown error'}`);
141
+ } finally {
142
+ setIsGenerating(false);
143
+ }
144
  };
145
+
146
 
147
  if (loading) {
148
  return (
 
180
 
181
  return (
182
  <PageContainer>
183
+ <Container
184
+ heading="Explore"
185
+ headingLevel={2}
186
+ withHeaderBorder
187
+ withInternalPadding
188
+ className="max-w-7xl mx-auto"
189
+ >
190
+ <div className={styles.tabSelector}>
191
+ <SegmentInput
192
+ name="map-details-view"
193
+ value={view}
194
+ onChange={(value) => {
195
+ if (value === 'mapDetails' || value === 'explore') {
196
+ setView(value);
197
+ if (value === 'explore') {
198
+ navigate('/explore');
199
+ }
200
+ }
201
+ }}
202
+ options={viewOptions}
203
+ keySelector={(o) => o.key}
204
+ labelSelector={(o) => o.label}
205
+ />
206
+ </div>
 
 
 
 
 
 
 
 
207
 
208
+ {view === 'mapDetails' ? (
209
+ <>
210
+ <div className={styles.gridLayout}>
211
+ {/* Image Section */}
212
+ <Container
213
+ heading={map.title || "Map Image"}
214
+ headingLevel={2}
215
+ withHeaderBorder
216
+ withInternalPadding
217
+ spacing="comfortable"
218
+ >
219
+ <div className={styles.imageContainer}>
220
+ {map.image_url ? (
221
+ <img
222
+ src={map.image_url}
223
+ alt={map.file_key}
224
+ />
225
+ ) : (
226
+ <div className={styles.imagePlaceholder}>
227
+ No image available
228
+ </div>
229
+ )}
230
+ </div>
231
+ </Container>
232
 
233
+ {/* Details Section */}
234
+ <div className={styles.detailsSection}>
235
+ <Container
236
+ heading="Tags"
237
+ headingLevel={3}
238
+ withHeaderBorder
239
+ withInternalPadding
240
+ spacing="comfortable"
241
+ >
242
+ <div className={styles.metadataTags}>
243
+ <span className={styles.metadataTag}>
244
+ {sources.find(s => s.s_code === map.source)?.label || map.source}
245
+ </span>
246
+ <span className={styles.metadataTag}>
247
+ {types.find(t => t.t_code === map.event_type)?.label || map.event_type}
248
+ </span>
249
+ <span className={styles.metadataTag}>
250
+ {imageTypes.find(it => it.image_type === map.image_type)?.label || map.image_type}
251
+ </span>
252
+ {map.countries && map.countries.length > 0 && (
253
+ <>
254
+ <span className={styles.metadataTag}>
255
+ {regions.find(r => r.r_code === map.countries[0].r_code)?.label || 'Unknown Region'}
256
+ </span>
257
+ <span className={styles.metadataTag}>
258
+ {map.countries.map(country => country.label).join(', ')}
259
+ </span>
260
+ </>
261
+ )}
262
+ </div>
263
+ </Container>
264
 
265
+ <Container
266
+ heading="Description"
267
+ headingLevel={3}
268
+ withHeaderBorder
269
+ withInternalPadding
270
+ spacing="comfortable"
271
+ >
272
+ <div className={styles.captionContainer}>
273
+ {map.generated ? (
274
+ <div className={styles.captionText}>
275
+ <p>{map.edited || map.generated}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  </div>
277
+ ) : (
278
+ <p>— no caption yet —</p>
279
  )}
280
  </div>
281
+ </Container>
282
+ </div>
 
 
283
  </div>
 
 
 
284
 
285
+ {/* Contribute Section */}
286
+ <div className="flex justify-center mt-8">
287
+ <Button
288
+ name="contribute"
289
+ onClick={handleContribute}
290
+ disabled={isGenerating}
291
+ >
292
+ {isGenerating ? 'Generating...' : 'Contribute'}
293
+ </Button>
294
+ </div>
295
+ </>
296
+ ) : null}
297
+ </Container>
298
  </PageContainer>
299
  );
300
  }
frontend/src/pages/UploadPage/UploadPage.module.css CHANGED
@@ -493,3 +493,5 @@
493
  padding: var(--go-ui-spacing-lg);
494
  box-shadow: var(--go-ui-box-shadow-xs);
495
  }
 
 
 
493
  padding: var(--go-ui-spacing-lg);
494
  box-shadow: var(--go-ui-box-shadow-xs);
495
  }
496
+
497
+
frontend/src/pages/UploadPage/UploadPage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useState, useEffect, useRef } from 'react';
2
  import type { DragEvent } from 'react';
3
  import {
4
  PageContainer, Heading, Button,
@@ -9,15 +9,17 @@ import {
9
  ArrowRightLineIcon,
10
  DeleteBinLineIcon,
11
  } from '@ifrc-go/icons';
12
- import { Link, useSearchParams } from 'react-router-dom';
13
  import styles from './UploadPage.module.css';
14
 
15
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
16
 
17
  export default function UploadPage() {
18
  const [searchParams] = useSearchParams();
 
19
  const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
20
  const [isLoading, setIsLoading] = useState(false);
 
21
  const stepRef = useRef(step);
22
  const uploadedImageIdRef = useRef<string | null>(null);
23
  const [preview, setPreview] = useState<string | null>(null);
@@ -59,7 +61,10 @@ export default function UploadPage() {
59
  fetch('/api/image-types').then(r => r.json()),
60
  fetch('/api/countries').then(r => r.json()),
61
  fetch('/api/models').then(r => r.json())
62
- ]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData]) => {
 
 
 
63
  setSources(sourcesData);
64
  setTypes(typesData);
65
  setSpatialReferences(spatialData);
@@ -73,73 +78,143 @@ export default function UploadPage() {
73
  });
74
  }, []);
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  useEffect(() => {
77
- const handleBeforeUnload = () => {
78
- if (uploadedImageIdRef.current && stepRef.current !== 3) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
80
  }
81
  };
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  window.addEventListener('beforeunload', handleBeforeUnload);
 
 
84
  return () => {
85
  window.removeEventListener('beforeunload', handleBeforeUnload);
86
- if (uploadedImageIdRef.current && stepRef.current !== 3) {
87
- fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
88
- }
89
  };
90
  }, []);
91
 
92
- const [captionId, setCaptionId] = useState<string | null>(null);
93
  const [imageUrl, setImageUrl] = useState<string|null>(null);
94
  const [draft, setDraft] = useState('');
95
 
96
  useEffect(() => {
97
- const mapId = searchParams.get('mapId');
98
  const stepParam = searchParams.get('step');
99
- const captionIdParam = searchParams.get('captionId');
100
 
101
- if (mapId && stepParam === '2') {
102
- fetch(`/api/images/${mapId}`)
103
- .then(response => response.json())
104
- .then(mapData => {
105
- setImageUrl(mapData.image_url);
106
- setSource(mapData.source);
107
- setEventType(mapData.event_type);
108
- setEpsg(mapData.epsg);
109
- setImageType(mapData.image_type);
110
-
111
- setUploadedImageId(mapId);
112
-
113
- if (captionIdParam) {
114
- setCaptionId(captionIdParam);
115
- const existingCaption = mapData.captions?.find((c: any) => c.cap_id === captionIdParam);
116
- if (existingCaption) {
117
- setDraft(existingCaption.edited || existingCaption.generated);
118
- setTitle(existingCaption.title || 'Generated Caption');
119
- }
120
-
121
- if (mapData.countries && Array.isArray(mapData.countries)) {
122
- setCountries(mapData.countries.map((c: any) => c.c_code));
123
- }
124
-
125
- handleStepChange('2a');
126
- } else {
127
- setCaptionId(null);
128
- setDraft('');
129
- setTitle('');
130
-
131
-
132
- if (mapData.countries && Array.isArray(mapData.countries)) {
133
- setCountries(mapData.countries.map((c: any) => c.c_code));
134
- }
135
-
136
- handleStepChange('2a');
137
- }
138
- })
139
- .catch(err => {
140
- alert('Failed to load map data. Please try again.');
141
- });
142
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }, [searchParams]);
144
 
145
  const resetToStep1 = () => {
@@ -147,7 +222,7 @@ export default function UploadPage() {
147
  setFile(null);
148
  setPreview(null);
149
  setImageUrl(null);
150
- setCaptionId(null);
151
  setDraft('');
152
  setTitle('');
153
  setScores({ accuracy: 50, context: 50, usability: 50 });
@@ -162,15 +237,15 @@ export default function UploadPage() {
162
  const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
163
 
164
 
165
- const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
166
  e.preventDefault();
167
  const dropped = e.dataTransfer.files?.[0];
168
  if (dropped) setFile(dropped);
169
- }, []);
170
 
171
- const onFileChange = useCallback((file: File | undefined, _name: string) => {
172
  if (file) setFile(file);
173
- }, []);
174
 
175
  useEffect(() => {
176
  if (!file) {
@@ -242,16 +317,18 @@ export default function UploadPage() {
242
  );
243
  const capJson = await readJsonSafely(capRes);
244
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
245
- setCaptionId(capJson.cap_id);
 
246
 
247
  const extractedMetadata = capJson.raw_json?.extracted_metadata;
248
  if (extractedMetadata) {
249
- if (extractedMetadata.title) setTitle(extractedMetadata.title);
250
- if (extractedMetadata.source) setSource(extractedMetadata.source);
251
- if (extractedMetadata.type) setEventType(extractedMetadata.type);
252
- if (extractedMetadata.epsg) setEpsg(extractedMetadata.epsg);
253
- if (extractedMetadata.countries && Array.isArray(extractedMetadata.countries)) {
254
- setCountries(extractedMetadata.countries);
 
255
  }
256
  }
257
 
@@ -264,8 +341,69 @@ export default function UploadPage() {
264
  }
265
  }
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  async function handleSubmit() {
268
- if (!captionId) return alert("No caption to submit");
 
269
 
270
  try {
271
  const metadataBody = {
@@ -275,6 +413,7 @@ export default function UploadPage() {
275
  image_type: imageType,
276
  countries: countries,
277
  };
 
278
  const metadataRes = await fetch(`/api/images/${uploadedImageId}`, {
279
  method: "PUT",
280
  headers: { "Content-Type": "application/json" },
@@ -290,7 +429,8 @@ export default function UploadPage() {
290
  context: scores.context,
291
  usability: scores.usability,
292
  };
293
- const captionRes = await fetch(`/api/captions/${captionId}`, {
 
294
  method: "PUT",
295
  headers: { "Content-Type": "application/json" },
296
  body: JSON.stringify(captionBody),
@@ -306,115 +446,50 @@ export default function UploadPage() {
306
  }
307
 
308
  async function handleDelete() {
 
309
  if (!uploadedImageId) {
310
-
311
- alert('No caption to delete. Please try refreshing the page.');
312
  return;
313
  }
314
 
315
- if (confirm("Are you sure you want to delete this caption? This action cannot be undone.")) {
316
  try {
317
- const captionsResponse = await fetch(`/api/images/${uploadedImageId}/captions`);
318
- let hasOtherCaptions = false;
 
 
319
 
320
- if (captionsResponse.ok) {
321
- const captions = await captionsResponse.json();
322
- hasOtherCaptions = captions.some((cap: any) => cap.cap_id !== captionId);
323
  }
324
 
325
- if (hasOtherCaptions) {
326
- if (captionId) {
327
- const capRes = await fetch(`/api/captions/${captionId}`, {
328
- method: "DELETE",
329
- });
330
- if (!capRes.ok) {
331
- throw new Error('Failed to delete caption');
332
- }
333
- }
334
  } else {
335
- const res = await fetch(`/api/images/${uploadedImageId}`, {
336
- method: "DELETE",
337
- });
338
-
339
- if (!res.ok) {
340
- const json = await readJsonSafely(res);
341
-
342
- throw new Error(json.error || `Delete failed with status ${res.status}`);
343
- }
344
  }
345
-
346
- resetToStep1();
347
  } catch (err) {
348
-
349
  handleApiError(err, 'Delete');
350
  }
351
  }
352
  }
353
 
354
- const handleProcessCaption = useCallback(async () => {
355
- if (!uploadedImageId) {
356
- alert('No image ID available to create a new caption.');
357
- return;
358
- }
359
-
360
- setIsLoading(true);
361
 
362
- try {
363
- if (captionId) {
364
- const captionBody = {
365
- title: title,
366
- edited: draft || '',
367
- };
368
- const captionRes = await fetch(`/api/captions/${captionId}`, {
369
- method: "PUT",
370
- headers: { "Content-Type": "application/json" },
371
- body: JSON.stringify(captionBody),
372
- });
373
- if (!captionRes.ok) throw new Error('Failed to update caption');
374
- } else {
375
- const capRes = await fetch(
376
- `/api/images/${uploadedImageId}/caption`,
377
- {
378
- method: 'POST',
379
- headers: {
380
- 'Content-Type': 'application/x-www-form-urlencoded',
381
- },
382
- body: new URLSearchParams({
383
- title: 'New Contribution Caption',
384
- prompt: 'Describe this crisis map in detail',
385
- ...(localStorage.getItem(SELECTED_MODEL_KEY) && {
386
- model_name: localStorage.getItem(SELECTED_MODEL_KEY)!
387
- })
388
- })
389
- }
390
- );
391
- const capJson = await readJsonSafely(capRes);
392
- if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
393
-
394
- setCaptionId(capJson.cap_id);
395
- setDraft(capJson.generated);
396
- }
397
-
398
- handleStepChange('2b');
399
- } catch (err) {
400
- handleApiError(err, 'Create New Caption');
401
- } finally {
402
- setIsLoading(false);
403
- }
404
- }, [uploadedImageId, title, captionId, draft]);
405
 
406
  return (
407
  <PageContainer>
408
- <div className={styles.uploadContainer} data-step={step}>
409
- {/* Drop-zone */}
410
- {step === 1 && (
411
- <Container
412
- heading="Upload Your Image"
413
- headingLevel={2}
414
- withHeaderBorder
415
- withInternalPadding
416
- headingClassName="text-center"
417
- >
418
  <div className="space-y-6">
419
  <p className="text-gray-700 leading-relaxed max-w-2xl mx-auto">
420
  This app evaluates how well multimodal AI models turn emergency maps
@@ -479,8 +554,7 @@ export default function UploadPage() {
479
  </label>
480
  </div>
481
  </div>
482
- </Container>
483
- )}
484
 
485
  {/* Loading state */}
486
  {isLoading && (
@@ -490,16 +564,33 @@ export default function UploadPage() {
490
  </div>
491
  )}
492
 
 
 
 
 
 
 
 
 
493
  {/* Generate button */}
494
  {step === 1 && !isLoading && (
495
  <div className={styles.generateButtonContainer}>
496
- <Button
497
- name="generate"
498
- disabled={!file}
499
- onClick={handleGenerate}
500
- >
501
- Generate
502
- </Button>
 
 
 
 
 
 
 
 
 
503
  </div>
504
  )}
505
 
@@ -612,18 +703,17 @@ export default function UploadPage() {
612
  </IconButton>
613
  <Button
614
  name="confirm-metadata"
615
- onClick={() => {
616
- if (imageUrl && !file) {
617
- handleProcessCaption();
 
 
618
  } else {
619
  handleStepChange('2b');
620
  }
621
  }}
622
  >
623
- {imageUrl && !file ?
624
- (captionId ? 'Edit Caption' : 'Create New Caption') :
625
- 'Next'
626
- }
627
  </Button>
628
  </div>
629
  </Container>
@@ -778,7 +868,9 @@ export default function UploadPage() {
778
  </div>
779
  </div>
780
  )}
781
- </div>
 
 
782
  </PageContainer>
783
  );
784
  }
 
1
+ import { useState, useEffect, useRef } from 'react';
2
  import type { DragEvent } from 'react';
3
  import {
4
  PageContainer, Heading, Button,
 
9
  ArrowRightLineIcon,
10
  DeleteBinLineIcon,
11
  } from '@ifrc-go/icons';
12
+ import { Link, useSearchParams, useNavigate } from 'react-router-dom';
13
  import styles from './UploadPage.module.css';
14
 
15
  const SELECTED_MODEL_KEY = 'selectedVlmModel';
16
 
17
  export default function UploadPage() {
18
  const [searchParams] = useSearchParams();
19
+ const navigate = useNavigate();
20
  const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
21
  const [isLoading, setIsLoading] = useState(false);
22
+ const [isLoadingContribution, setIsLoadingContribution] = useState(false);
23
  const stepRef = useRef(step);
24
  const uploadedImageIdRef = useRef<string | null>(null);
25
  const [preview, setPreview] = useState<string | null>(null);
 
61
  fetch('/api/image-types').then(r => r.json()),
62
  fetch('/api/countries').then(r => r.json()),
63
  fetch('/api/models').then(r => r.json())
64
+ ]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData, modelsData]) => {
65
+ if (!localStorage.getItem(SELECTED_MODEL_KEY) && modelsData?.length) {
66
+ localStorage.setItem(SELECTED_MODEL_KEY, modelsData[0].m_code);
67
+ }
68
  setSources(sourcesData);
69
  setTypes(typesData);
70
  setSpatialReferences(spatialData);
 
78
  });
79
  }, []);
80
 
81
+
82
+
83
+ const handleNavigation = (to: string) => {
84
+ if (uploadedImageIdRef.current) {
85
+ if (confirm("Leave page? Your uploaded image will be deleted.")) {
86
+ fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" })
87
+ .then(() => {
88
+ navigate(to);
89
+ })
90
+ .catch(console.error);
91
+ }
92
+ } else {
93
+ navigate(to);
94
+ }
95
+ };
96
+
97
  useEffect(() => {
98
+ (window as any).confirmNavigationIfNeeded = (to: string) => {
99
+ handleNavigation(to);
100
+ };
101
+
102
+ return () => {
103
+ delete (window as any).confirmNavigationIfNeeded;
104
+ };
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
109
+ if (uploadedImageIdRef.current) {
110
+ const message = 'You have an uploaded image that will be deleted if you leave this page. Are you sure you want to leave?';
111
+ event.preventDefault();
112
+ event.returnValue = message;
113
+ return message;
114
+ }
115
+ };
116
+
117
+ const handleCleanup = () => {
118
+ if (uploadedImageIdRef.current) {
119
  fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
120
  }
121
  };
122
 
123
+ const handleGlobalClick = (event: MouseEvent) => {
124
+ const target = event.target as HTMLElement;
125
+ const link = target.closest('a[href]') || target.closest('[data-navigate]');
126
+
127
+ if (link && uploadedImageIdRef.current) {
128
+ const href = link.getAttribute('href') || link.getAttribute('data-navigate');
129
+ if (href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
130
+ event.preventDefault();
131
+ event.stopPropagation();
132
+ handleNavigation(href);
133
+ }
134
+ }
135
+ };
136
+
137
  window.addEventListener('beforeunload', handleBeforeUnload);
138
+ document.addEventListener('click', handleGlobalClick, true);
139
+
140
  return () => {
141
  window.removeEventListener('beforeunload', handleBeforeUnload);
142
+ document.removeEventListener('click', handleGlobalClick, true);
143
+ handleCleanup();
 
144
  };
145
  }, []);
146
 
 
147
  const [imageUrl, setImageUrl] = useState<string|null>(null);
148
  const [draft, setDraft] = useState('');
149
 
150
  useEffect(() => {
151
+ const imageUrlParam = searchParams.get('imageUrl');
152
  const stepParam = searchParams.get('step');
153
+ const imageIdParam = searchParams.get('imageId');
154
 
155
+ if (imageUrlParam) {
156
+ setImageUrl(imageUrlParam);
157
+
158
+ if (stepParam === '2a' && imageIdParam) {
159
+ setIsLoadingContribution(true);
160
+ setUploadedImageId(imageIdParam);
161
+ fetch(`/api/images/${imageIdParam}`)
162
+ .then(res => res.json())
163
+ .then(data => {
164
+ if (data.image_type) setImageType(data.image_type);
165
+
166
+ if (data.generated) setDraft(data.generated);
167
+
168
+ let extractedMetadata = data.raw_json?.extracted_metadata;
169
+ console.log('Raw extracted_metadata:', extractedMetadata);
170
+
171
+ if (!extractedMetadata && data.generated) {
172
+ try {
173
+ const parsedGenerated = JSON.parse(data.generated);
174
+ console.log('Parsed generated field:', parsedGenerated);
175
+ if (parsedGenerated.metadata) {
176
+ extractedMetadata = parsedGenerated;
177
+ console.log('Using metadata from generated field');
178
+ }
179
+ } catch (e) {
180
+ console.log('Could not parse generated field as JSON:', e);
181
+ }
182
+ }
183
+
184
+ if (extractedMetadata) {
185
+ const metadata = extractedMetadata.metadata || extractedMetadata;
186
+ console.log('Final metadata to apply:', metadata);
187
+ if (metadata.title) {
188
+ console.log('Setting title to:', metadata.title);
189
+ setTitle(metadata.title);
190
+ }
191
+ if (metadata.source) {
192
+ console.log('Setting source to:', metadata.source);
193
+ setSource(metadata.source);
194
+ }
195
+ if (metadata.type) {
196
+ console.log('Setting event type to:', metadata.type);
197
+ setEventType(metadata.type);
198
+ }
199
+ if (metadata.epsg) {
200
+ console.log('Setting EPSG to:', metadata.epsg);
201
+ setEpsg(metadata.epsg);
202
+ }
203
+ if (metadata.countries && Array.isArray(metadata.countries)) {
204
+ console.log('Setting countries to:', metadata.countries);
205
+ setCountries(metadata.countries);
206
+ }
207
+ } else {
208
+ console.log('No metadata found to extract');
209
+ }
210
+
211
+ setStep('2a');
212
+ setIsLoadingContribution(false);
213
+ })
214
+ .catch(console.error)
215
+ .finally(() => setIsLoadingContribution(false));
216
+ }
217
+ }
218
  }, [searchParams]);
219
 
220
  const resetToStep1 = () => {
 
222
  setFile(null);
223
  setPreview(null);
224
  setImageUrl(null);
225
+
226
  setDraft('');
227
  setTitle('');
228
  setScores({ accuracy: 50, context: 50, usability: 50 });
 
237
  const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
238
 
239
 
240
+ const onDrop = (e: DragEvent<HTMLDivElement>) => {
241
  e.preventDefault();
242
  const dropped = e.dataTransfer.files?.[0];
243
  if (dropped) setFile(dropped);
244
+ };
245
 
246
+ const onFileChange = (file: File | undefined, _name: string) => {
247
  if (file) setFile(file);
248
+ };
249
 
250
  useEffect(() => {
251
  if (!file) {
 
317
  );
318
  const capJson = await readJsonSafely(capRes);
319
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
320
+ setUploadedImageId(mapIdVal);
321
+
322
 
323
  const extractedMetadata = capJson.raw_json?.extracted_metadata;
324
  if (extractedMetadata) {
325
+ const metadata = extractedMetadata.metadata || extractedMetadata;
326
+ if (metadata.title) setTitle(metadata.title);
327
+ if (metadata.source) setSource(metadata.source);
328
+ if (metadata.type) setEventType(metadata.type);
329
+ if (metadata.epsg) setEpsg(metadata.epsg);
330
+ if (metadata.countries && Array.isArray(metadata.countries)) {
331
+ setCountries(metadata.countries);
332
  }
333
  }
334
 
 
341
  }
342
  }
343
 
344
+ async function handleGenerateFromUrl() {
345
+ if (!imageUrl) return;
346
+ setIsLoading(true);
347
+ try {
348
+ // 1) Create a NEW image from server-side URL fetch
349
+ const res = await fetch('/api/contribute/from-url', {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({
353
+ url: imageUrl,
354
+ source,
355
+ event_type: eventType,
356
+ epsg,
357
+ image_type: imageType,
358
+ countries,
359
+ }),
360
+ });
361
+ const json = await readJsonSafely(res);
362
+ if (!res.ok) throw new Error(json.error || 'Upload failed');
363
+
364
+ const newId = json.image_id as string;
365
+ setUploadedImageId(newId);
366
+ setImageUrl(json.image_url);
367
+
368
+ const modelName = localStorage.getItem(SELECTED_MODEL_KEY) || undefined;
369
+ const capRes = await fetch(`/api/images/${newId}/caption`, {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
372
+ body: new URLSearchParams({
373
+ title: 'Generated Caption',
374
+ prompt:
375
+ 'Analyze this crisis map and provide a detailed description of the emergency situation, affected areas, and key information shown in the map.',
376
+ ...(modelName && { model_name: modelName }),
377
+ }),
378
+ });
379
+ const capJson = await readJsonSafely(capRes);
380
+ if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
381
+
382
+ const extractedMetadata = capJson.raw_json?.extracted_metadata;
383
+ if (extractedMetadata) {
384
+ const metadata = extractedMetadata.metadata || extractedMetadata;
385
+ if (metadata.title) setTitle(metadata.title);
386
+ if (metadata.source) setSource(metadata.source);
387
+ if (metadata.type) setEventType(metadata.type);
388
+ if (metadata.epsg) setEpsg(metadata.epsg);
389
+ if (metadata.countries && Array.isArray(metadata.countries)) {
390
+ setCountries(metadata.countries);
391
+ }
392
+ }
393
+
394
+ setDraft(capJson.generated || '');
395
+ handleStepChange('2a');
396
+ } catch (err) {
397
+ handleApiError(err, 'Upload');
398
+ } finally {
399
+ setIsLoading(false);
400
+ }
401
+ }
402
+
403
+
404
  async function handleSubmit() {
405
+ console.log('handleSubmit called with:', { uploadedImageId, title, draft });
406
+ if (!uploadedImageId) return alert("No image to submit");
407
 
408
  try {
409
  const metadataBody = {
 
413
  image_type: imageType,
414
  countries: countries,
415
  };
416
+ console.log('Updating metadata:', metadataBody);
417
  const metadataRes = await fetch(`/api/images/${uploadedImageId}`, {
418
  method: "PUT",
419
  headers: { "Content-Type": "application/json" },
 
429
  context: scores.context,
430
  usability: scores.usability,
431
  };
432
+ console.log('Updating caption:', captionBody);
433
+ const captionRes = await fetch(`/api/images/${uploadedImageId}/caption`, {
434
  method: "PUT",
435
  headers: { "Content-Type": "application/json" },
436
  body: JSON.stringify(captionBody),
 
446
  }
447
 
448
  async function handleDelete() {
449
+ console.log('handleDelete called with uploadedImageId:', uploadedImageId);
450
  if (!uploadedImageId) {
451
+ alert('No image to delete. Please try refreshing the page.');
 
452
  return;
453
  }
454
 
455
+ if (confirm("Delete this image? This cannot be undone.")) {
456
  try {
457
+ console.log('Deleting image with ID:', uploadedImageId);
458
+ const res = await fetch(`/api/images/${uploadedImageId}`, {
459
+ method: "DELETE",
460
+ });
461
 
462
+ if (!res.ok) {
463
+ const json = await readJsonSafely(res);
464
+ throw new Error(json.error || `Delete failed with status ${res.status}`);
465
  }
466
 
467
+ // If this was a contribution, navigate to explore page
468
+ if (searchParams.get('isContribution') === 'true') {
469
+ navigate('/explore');
 
 
 
 
 
 
470
  } else {
471
+ resetToStep1();
 
 
 
 
 
 
 
 
472
  }
 
 
473
  } catch (err) {
 
474
  handleApiError(err, 'Delete');
475
  }
476
  }
477
  }
478
 
 
 
 
 
 
 
 
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
 
481
  return (
482
  <PageContainer>
483
+ <Container
484
+ heading="Upload Your Image"
485
+ headingLevel={2}
486
+ withHeaderBorder
487
+ withInternalPadding
488
+ className="max-w-7xl mx-auto"
489
+ >
490
+ <div className={styles.uploadContainer} data-step={step}>
491
+ {/* Drop-zone */}
492
+ {step === 1 && !searchParams.get('step') && (
493
  <div className="space-y-6">
494
  <p className="text-gray-700 leading-relaxed max-w-2xl mx-auto">
495
  This app evaluates how well multimodal AI models turn emergency maps
 
554
  </label>
555
  </div>
556
  </div>
557
+ )}
 
558
 
559
  {/* Loading state */}
560
  {isLoading && (
 
564
  </div>
565
  )}
566
 
567
+ {/* Loading contribution data */}
568
+ {isLoadingContribution && (
569
+ <div className={styles.loadingContainer}>
570
+ <Spinner className="text-ifrcRed" />
571
+ <p className={styles.loadingText}>Loading contribution...</p>
572
+ </div>
573
+ )}
574
+
575
  {/* Generate button */}
576
  {step === 1 && !isLoading && (
577
  <div className={styles.generateButtonContainer}>
578
+ {imageUrl ? (
579
+ <Button
580
+ name="generate-from-url"
581
+ onClick={handleGenerateFromUrl}
582
+ >
583
+ Generate Caption
584
+ </Button>
585
+ ) : (
586
+ <Button
587
+ name="generate"
588
+ disabled={!file}
589
+ onClick={handleGenerate}
590
+ >
591
+ Generate
592
+ </Button>
593
+ )}
594
  </div>
595
  )}
596
 
 
703
  </IconButton>
704
  <Button
705
  name="confirm-metadata"
706
+ onClick={async () => {
707
+ if (imageUrl && !uploadedImageId) {
708
+ await handleGenerateFromUrl();
709
+ } else if (imageUrl && !file) {
710
+ handleStepChange('2b');
711
  } else {
712
  handleStepChange('2b');
713
  }
714
  }}
715
  >
716
+ Next
 
 
 
717
  </Button>
718
  </div>
719
  </Container>
 
868
  </div>
869
  </div>
870
  )}
871
+
872
+ </div>
873
+ </Container>
874
  </PageContainer>
875
  );
876
  }
frontend/src/types.ts DELETED
@@ -1,17 +0,0 @@
1
- export interface MapOut {
2
- map_id: string;
3
- file_key: string;
4
- sha256: string;
5
- source: string;
6
- region: string;
7
- category: string;
8
- caption?: {
9
- cap_id: string;
10
- map_id: string;
11
- generated: string;
12
- edited?: string;
13
- accuracy?: number;
14
- context?: number;
15
- usability?: number;
16
- };
17
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py CHANGED
@@ -42,6 +42,7 @@ def _guess_region(alpha2: str) -> str:
42
  def upgrade():
43
  op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
44
 
 
45
  op.execute("DROP TABLE IF EXISTS captions CASCADE;")
46
  op.execute("DROP TABLE IF EXISTS image_countries CASCADE;")
47
  op.execute("DROP TABLE IF EXISTS images CASCADE;")
@@ -188,7 +189,6 @@ def upgrade():
188
  ('BLIP2_OPT_2_7B','BLIP Image Captioning','custom',true,'{"provider":"huggingface","model_id":"Salesforce/blip-image-captioning-base"}'),
189
  ('VIT_GPT2','Vit gpt2 image captioning','custom',true,'{"provider":"huggingface","model_id":"nlpconnect/vit-gpt2-image-captioning"}')
190
  """)
191
-
192
  op.execute("""
193
  INSERT INTO json_schemas (schema_id,title,schema,version) VALUES
194
  ('[email protected]','Default Caption Schema',
@@ -205,6 +205,7 @@ def upgrade():
205
  )
206
  op.execute("INSERT INTO countries (c_code,label,r_code) VALUES ('XX','Not Applicable','OTHER')")
207
 
 
208
  op.create_table(
209
  'images',
210
  sa.Column('image_id', postgresql.UUID(as_uuid=True),
@@ -218,7 +219,26 @@ def upgrade():
218
  sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
219
  sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
220
  sa.Column('captured_at', sa.TIMESTAMP(timezone=True), nullable=True),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  )
 
222
  op.create_table(
223
  'image_countries',
224
  sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
@@ -227,42 +247,12 @@ def upgrade():
227
  sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
228
  sa.ForeignKeyConstraint(['c_code'], ['countries.c_code'])
229
  )
230
- op.create_table(
231
- 'captions',
232
- sa.Column('cap_id', postgresql.UUID(as_uuid=True),
233
- server_default=sa.text('gen_random_uuid()'),
234
- primary_key=True),
235
- sa.Column('image_id', postgresql.UUID(as_uuid=True),
236
- sa.ForeignKey('images.image_id', ondelete='CASCADE'),
237
- nullable=False),
238
- sa.Column('title', sa.String(), nullable=False),
239
- sa.Column('prompt', sa.String(), nullable=False),
240
- sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=False),
241
- sa.Column('schema_id', sa.String(), sa.ForeignKey('json_schemas.schema_id'), nullable=False),
242
- sa.Column('raw_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
243
- sa.Column('generated', sa.Text(), nullable=False),
244
- sa.Column('edited', sa.Text(), nullable=True),
245
- sa.Column('accuracy', sa.SmallInteger()),
246
- sa.Column('context', sa.SmallInteger()),
247
- sa.Column('usability', sa.SmallInteger()),
248
- sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
249
- sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
250
- sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
251
- sa.CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'),
252
- sa.CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'),
253
- sa.CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability')
254
- )
255
 
256
  op.create_index('ix_images_created_at', 'images', ['created_at'])
257
- op.create_index('ix_captions_created_at', 'captions', ['created_at'])
258
- op.create_index('ix_captions_image_id', 'captions', ['image_id'])
259
 
260
 
261
  def downgrade():
262
- op.drop_index('ix_captions_image_id', table_name='captions')
263
- op.drop_index('ix_captions_created_at', table_name='captions')
264
  op.drop_index('ix_images_created_at', table_name='images')
265
- op.drop_table('captions')
266
  op.drop_table('image_countries')
267
  op.drop_table('images')
268
  op.drop_table('json_schemas')
 
42
  def upgrade():
43
  op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
44
 
45
+ # Drop any old tables if they exist (idempotent for initial setup)
46
  op.execute("DROP TABLE IF EXISTS captions CASCADE;")
47
  op.execute("DROP TABLE IF EXISTS image_countries CASCADE;")
48
  op.execute("DROP TABLE IF EXISTS images CASCADE;")
 
189
  ('BLIP2_OPT_2_7B','BLIP Image Captioning','custom',true,'{"provider":"huggingface","model_id":"Salesforce/blip-image-captioning-base"}'),
190
  ('VIT_GPT2','Vit gpt2 image captioning','custom',true,'{"provider":"huggingface","model_id":"nlpconnect/vit-gpt2-image-captioning"}')
191
  """)
 
192
  op.execute("""
193
  INSERT INTO json_schemas (schema_id,title,schema,version) VALUES
194
  ('[email protected]','Default Caption Schema',
 
205
  )
206
  op.execute("INSERT INTO countries (c_code,label,r_code) VALUES ('XX','Not Applicable','OTHER')")
207
 
208
+ # ---- Images table now includes the single caption/interpretation fields ----
209
  op.create_table(
210
  'images',
211
  sa.Column('image_id', postgresql.UUID(as_uuid=True),
 
219
  sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
220
  sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
221
  sa.Column('captured_at', sa.TIMESTAMP(timezone=True), nullable=True),
222
+
223
+ # --- merged caption fields ---
224
+ sa.Column('title', sa.String(), nullable=True),
225
+ sa.Column('prompt', sa.String(), nullable=True),
226
+ sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=True),
227
+ sa.Column('schema_id', sa.String(), sa.ForeignKey('json_schemas.schema_id'), nullable=True),
228
+ sa.Column('raw_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
229
+ sa.Column('generated', sa.Text(), nullable=True),
230
+ sa.Column('edited', sa.Text(), nullable=True),
231
+ sa.Column('accuracy', sa.SmallInteger()),
232
+ sa.Column('context', sa.SmallInteger()),
233
+ sa.Column('usability', sa.SmallInteger()),
234
+ sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
235
+ sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
236
+
237
+ sa.CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_images_accuracy'),
238
+ sa.CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_images_context'),
239
+ sa.CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_images_usability')
240
  )
241
+
242
  op.create_table(
243
  'image_countries',
244
  sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
 
247
  sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
248
  sa.ForeignKeyConstraint(['c_code'], ['countries.c_code'])
249
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
  op.create_index('ix_images_created_at', 'images', ['created_at'])
 
 
252
 
253
 
254
  def downgrade():
 
 
255
  op.drop_index('ix_images_created_at', table_name='images')
 
256
  op.drop_table('image_countries')
257
  op.drop_table('images')
258
  op.drop_table('json_schemas')
py_backend/app/crud.py CHANGED
@@ -2,6 +2,7 @@ import io, hashlib
2
  from typing import Optional
3
  from sqlalchemy.orm import Session, joinedload
4
  from . import models, schemas
 
5
 
6
  def hash_bytes(data: bytes) -> str:
7
  """Compute SHA-256 hex digest of the data."""
@@ -26,30 +27,26 @@ def create_image(db: Session, src, type_code, key, sha, countries: list[str], ep
26
  return img
27
 
28
  def get_images(db: Session):
29
- """Get all images with their captions"""
30
  return (
31
  db.query(models.Images)
32
  .options(
33
- joinedload(models.Images.captions),
34
  joinedload(models.Images.countries),
35
  )
36
  .all()
37
  )
38
 
39
  def get_image(db: Session, image_id: str):
40
- """Get a single image by ID with its captions"""
41
  return (
42
  db.query(models.Images)
43
  .options(
44
- joinedload(models.Images.captions),
45
  joinedload(models.Images.countries),
46
  )
47
  .filter(models.Images.image_id == image_id)
48
  .first()
49
  )
50
 
51
-
52
-
53
  def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
54
  print(f"Creating caption for image_id: {image_id}")
55
  print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
@@ -59,93 +56,99 @@ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, t
59
  if metadata:
60
  raw_json["extracted_metadata"] = metadata
61
 
62
- c = models.Captions(
63
- image_id=image_id,
64
- title=title,
65
- prompt=prompt,
66
- model=model_code,
67
- schema_id="[email protected]",
68
- raw_json=raw_json,
69
- generated=text,
70
- edited=text
71
- )
72
- db.add(c)
 
73
  print(f"About to commit caption to database...")
74
  db.commit()
75
  print(f"Caption commit successful!")
76
- db.refresh(c)
77
- print(f"Caption created successfully with ID: {c.cap_id}")
78
- return c
79
 
80
- def get_caption(db: Session, cap_id: str):
81
- return db.get(models.Captions, cap_id)
 
82
 
83
  def get_captions_by_image(db: Session, image_id: str):
84
- """Get all captions for a specific image"""
85
- return db.query(models.Captions).filter(models.Captions.image_id == image_id).all()
 
 
 
86
 
87
  def get_all_captions_with_images(db: Session):
88
- """Get all captions with their associated image data"""
89
- results = db.query(
90
- models.Captions,
91
- models.Images.file_key,
92
- models.Images.source,
93
- models.Images.event_type,
94
- models.Images.epsg,
95
- models.Images.image_type
96
- ).join(
97
- models.Images, models.Captions.image_id == models.Images.image_id
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  ).all()
99
 
100
- captions_with_images = []
101
- for caption, file_key, source, event_type, epsg, image_type in results:
102
- caption_dict = {
103
- "cap_id": caption.cap_id,
104
- "image_id": caption.image_id,
105
- "title": caption.title,
106
- "prompt": caption.prompt,
107
- "model": caption.model,
108
- "schema_id": caption.schema_id,
109
- "raw_json": caption.raw_json,
110
- "generated": caption.generated,
111
- "edited": caption.edited,
112
- "accuracy": caption.accuracy,
113
- "context": caption.context,
114
- "usability": caption.usability,
115
- "starred": caption.starred,
116
- "created_at": caption.created_at,
117
- "updated_at": caption.updated_at,
118
- "file_key": file_key,
119
- "image_url": f"/api/images/{caption.image_id}/file",
120
- "source": source,
121
- "event_type": event_type,
122
- "epsg": epsg,
123
- "image_type": image_type,
124
- "countries": []
125
- }
126
- captions_with_images.append(caption_dict)
127
 
128
- return captions_with_images
129
 
130
- def update_caption(db: Session, cap_id: str, update: schemas.CaptionUpdate):
131
- c = get_caption(db, cap_id)
132
- if not c:
 
133
  return None
134
 
135
  for field, value in update.dict(exclude_unset=True).items():
136
- setattr(c, field, value)
137
 
138
  db.commit()
139
- db.refresh(c)
140
- return c
141
 
142
- def delete_caption(db: Session, cap_id: str):
143
- """Delete a caption by ID"""
144
- c = get_caption(db, cap_id)
145
- if not c:
146
  return False
147
 
148
- db.delete(c)
 
 
 
 
 
 
 
 
 
 
 
149
  db.commit()
150
  return True
151
 
 
2
  from typing import Optional
3
  from sqlalchemy.orm import Session, joinedload
4
  from . import models, schemas
5
+ from fastapi import HTTPException
6
 
7
  def hash_bytes(data: bytes) -> str:
8
  """Compute SHA-256 hex digest of the data."""
 
27
  return img
28
 
29
  def get_images(db: Session):
30
+ """Get all images with their countries"""
31
  return (
32
  db.query(models.Images)
33
  .options(
 
34
  joinedload(models.Images.countries),
35
  )
36
  .all()
37
  )
38
 
39
  def get_image(db: Session, image_id: str):
40
+ """Get a single image by ID with its countries"""
41
  return (
42
  db.query(models.Images)
43
  .options(
 
44
  joinedload(models.Images.countries),
45
  )
46
  .filter(models.Images.image_id == image_id)
47
  .first()
48
  )
49
 
 
 
50
  def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
51
  print(f"Creating caption for image_id: {image_id}")
52
  print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
 
56
  if metadata:
57
  raw_json["extracted_metadata"] = metadata
58
 
59
+ img = db.get(models.Images, image_id)
60
+ if not img:
61
+ raise HTTPException(404, "Image not found")
62
+
63
+ img.title = title
64
+ img.prompt = prompt
65
+ img.model = model_code
66
+ img.schema_id = "[email protected]"
67
+ img.raw_json = raw_json
68
+ img.generated = text
69
+ img.edited = text
70
+
71
  print(f"About to commit caption to database...")
72
  db.commit()
73
  print(f"Caption commit successful!")
74
+ db.refresh(img)
75
+ print(f"Caption created successfully for image: {img.image_id}")
76
+ return img
77
 
78
+ def get_caption(db: Session, image_id: str):
79
+ """Get caption data for a specific image"""
80
+ return db.get(models.Images, image_id)
81
 
82
  def get_captions_by_image(db: Session, image_id: str):
83
+ """Get caption data for a specific image (now just returns the image)"""
84
+ img = db.get(models.Images, image_id)
85
+ if img and img.title:
86
+ return [img]
87
+ return []
88
 
89
  def get_all_captions_with_images(db: Session):
90
+ """Get all images that have caption data"""
91
+ print(f"DEBUG: Querying database for images with caption data...")
92
+
93
+ total_images = db.query(models.Images).count()
94
+ print(f"DEBUG: Total images in database: {total_images}")
95
+
96
+ images_with_title = db.query(models.Images).filter(
97
+ models.Images.title.isnot(None)
98
+ ).count()
99
+ print(f"DEBUG: Images with title field: {images_with_title}")
100
+
101
+ images_with_generated = db.query(models.Images).filter(
102
+ models.Images.generated.isnot(None)
103
+ ).count()
104
+ print(f"DEBUG: Images with generated field: {images_with_generated}")
105
+
106
+ images_with_model = db.query(models.Images).filter(
107
+ models.Images.model.isnot(None)
108
+ ).count()
109
+ print(f"DEBUG: Images with model field: {images_with_model}")
110
+
111
+ results = db.query(models.Images).filter(
112
+ models.Images.title.isnot(None)
113
  ).all()
114
 
115
+ print(f"DEBUG: Query returned {len(results)} results")
116
+ for img in results:
117
+ print(f"DEBUG: Image {img.image_id}: title='{img.title}', generated='{img.generated}', model='{img.model}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
+ return results
120
 
121
+ def update_caption(db: Session, image_id: str, update: schemas.CaptionUpdate):
122
+ """Update caption data for an image"""
123
+ img = db.get(models.Images, image_id)
124
+ if not img:
125
  return None
126
 
127
  for field, value in update.dict(exclude_unset=True).items():
128
+ setattr(img, field, value)
129
 
130
  db.commit()
131
+ db.refresh(img)
132
+ return img
133
 
134
+ def delete_caption(db: Session, image_id: str):
135
+ """Delete caption data for an image (sets caption fields to None)"""
136
+ img = db.get(models.Images, image_id)
137
+ if not img:
138
  return False
139
 
140
+ img.title = None
141
+ img.prompt = None
142
+ img.model = None
143
+ img.schema_id = None
144
+ img.raw_json = None
145
+ img.generated = None
146
+ img.edited = None
147
+ img.accuracy = None
148
+ img.context = None
149
+ img.usability = None
150
+ img.starred = False
151
+
152
  db.commit()
153
  return True
154
 
py_backend/app/database.py CHANGED
@@ -7,10 +7,8 @@ from sqlalchemy.orm import sessionmaker, declarative_base
7
  from .config import settings
8
 
9
  raw_db_url = settings.DATABASE_URL
10
- logging.getLogger().warning(f"Raw DATABASE_URL = {raw_db_url!r}")
11
-
12
  clean_db_url = raw_db_url.split("?", 1)[0]
13
- logging.getLogger().warning(f"Using clean URL = {clean_db_url!r}")
14
 
15
  engine = create_engine(
16
  clean_db_url,
 
7
  from .config import settings
8
 
9
  raw_db_url = settings.DATABASE_URL
 
 
10
  clean_db_url = raw_db_url.split("?", 1)[0]
11
+ print(f"database: {clean_db_url.split('@')[-1].split('/')[-1]}")
12
 
13
  engine = create_engine(
14
  clean_db_url,
py_backend/app/images.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/app/schemas/images.py
2
+ from pydantic import BaseModel, AnyHttpUrl, Field
3
+ from typing import List, Optional
4
+
5
+ class CreateImageFromUrlIn(BaseModel):
6
+ url: str
7
+ source: str
8
+ event_type: str
9
+ epsg: str
10
+ image_type: str
11
+ countries: List[str] = Field(default_factory=list)
12
+
13
+ class CreateImageFromUrlOut(BaseModel):
14
+ image_id: str
15
+ image_url: str
py_backend/app/main.py CHANGED
@@ -2,6 +2,9 @@ from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from app.routers import upload, caption, metadata, models
4
  from app.config import settings
 
 
 
5
 
6
  app = FastAPI(title="PromptAid Vision")
7
 
@@ -16,10 +19,14 @@ app.add_middleware(
16
  allow_headers=["*"],
17
  )
18
 
19
- app.include_router(upload.router, prefix="/api/images", tags=["images"])
20
  app.include_router(caption.router, prefix="/api", tags=["captions"])
21
  app.include_router(metadata.router, prefix="/api", tags=["metadata"])
22
  app.include_router(models.router, prefix="/api", tags=["models"])
 
 
 
 
 
23
 
24
 
25
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from app.routers import upload, caption, metadata, models
4
  from app.config import settings
5
+ from app.routers.images import router as images_router
6
+
7
+
8
 
9
  app = FastAPI(title="PromptAid Vision")
10
 
 
19
  allow_headers=["*"],
20
  )
21
 
 
22
  app.include_router(caption.router, prefix="/api", tags=["captions"])
23
  app.include_router(metadata.router, prefix="/api", tags=["metadata"])
24
  app.include_router(models.router, prefix="/api", tags=["models"])
25
+ app.include_router(upload.router, prefix="/api/images", tags=["images"])
26
+ app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
27
+
28
+ print("🚀 PromptAid Vision API server ready")
29
+ print("📊 Available endpoints: /api/images, /api/captions, /api/metadata, /api/models")
30
 
31
 
32
 
py_backend/app/models.py CHANGED
@@ -74,44 +74,35 @@ class JSONSchema(Base):
74
 
75
  class Images(Base):
76
  __tablename__ = "images"
77
-
78
- image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
79
- file_key = Column(String, nullable=False)
80
- sha256 = Column(String, nullable=False)
81
- source = Column(String, ForeignKey("sources.s_code"), nullable=False)
82
- event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
83
- epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=True)
84
- image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
85
- created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
86
- captured_at = Column(TIMESTAMP(timezone=True))
87
-
88
- countries = relationship("Country", secondary=image_countries, backref="images")
89
- captions = relationship("Captions", back_populates="image", cascade="all, delete-orphan")
90
-
91
- class Captions(Base):
92
- __tablename__ = "captions"
93
  __table_args__ = (
94
- CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'),
95
- CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'),
96
- CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability'),
97
  )
98
 
99
- cap_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
100
- image_id = Column(UUID(as_uuid=True), ForeignKey("images.image_id", ondelete="CASCADE"), nullable=False)
101
- title = Column(String, nullable=False)
102
- prompt = Column(String, nullable=False)
103
- model = Column(String, ForeignKey("models.m_code"), nullable=False)
104
- schema_id = Column(String, ForeignKey("json_schemas.schema_id"), nullable=False)
105
- raw_json = Column(JSONB, nullable=False)
106
- generated = Column(Text, nullable=False)
 
 
 
 
 
 
 
 
107
  edited = Column(Text)
108
  accuracy = Column(SmallInteger)
109
  context = Column(SmallInteger)
110
  usability = Column(SmallInteger)
111
  starred = Column(Boolean, default=False)
112
- created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
113
  updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
114
 
115
- image = relationship("Images", back_populates="captions")
116
- schema = relationship("JSONSchema")
117
- model_r = relationship("Models", foreign_keys=[model])
 
74
 
75
  class Images(Base):
76
  __tablename__ = "images"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  __table_args__ = (
78
+ CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_images_accuracy'),
79
+ CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_images_context'),
80
+ CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_images_usability'),
81
  )
82
 
83
+ image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
84
+ file_key = Column(String, nullable=False)
85
+ sha256 = Column(String, nullable=False)
86
+ source = Column(String, ForeignKey("sources.s_code"), nullable=False)
87
+ event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
88
+ epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
89
+ image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
90
+ created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
91
+ captured_at = Column(TIMESTAMP(timezone=True))
92
+
93
+ title = Column(String, nullable=True)
94
+ prompt = Column(String, nullable=True)
95
+ model = Column(String, ForeignKey("models.m_code"), nullable=True)
96
+ schema_id = Column(String, ForeignKey("json_schemas.schema_id"), nullable=True)
97
+ raw_json = Column(JSONB, nullable=True)
98
+ generated = Column(Text, nullable=True)
99
  edited = Column(Text)
100
  accuracy = Column(SmallInteger)
101
  context = Column(SmallInteger)
102
  usability = Column(SmallInteger)
103
  starred = Column(Boolean, default=False)
 
104
  updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
105
 
106
+ countries = relationship("Country", secondary=image_countries, backref="images")
107
+ schema = relationship("JSONSchema")
108
+ model_r = relationship("Models", foreign_keys=[model])
py_backend/app/routers/caption.py CHANGED
@@ -17,47 +17,43 @@ if settings.OPENAI_API_KEY:
17
  try:
18
  gpt4v_service = GPT4VService(settings.OPENAI_API_KEY)
19
  vlm_manager.register_service(gpt4v_service)
20
- print(f"DEBUG: Registered GPT-4 Vision service: {gpt4v_service.model_name}")
21
  except Exception as e:
22
- print(f"DEBUG: Failed to register GPT-4 Vision service: {e}")
23
  else:
24
- print("DEBUG: No OpenAI API key found")
25
 
26
  if settings.GOOGLE_API_KEY:
27
  try:
28
  gemini_service = GeminiService(settings.GOOGLE_API_KEY)
29
  vlm_manager.register_service(gemini_service)
30
- print(f"DEBUG: Registered Gemini service: {gemini_service.model_name}")
31
  except Exception as e:
32
- print(f"DEBUG: Failed to register Gemini service: {e}")
33
  else:
34
- print("DEBUG: No Google API key found")
35
 
36
  if settings.HF_API_KEY:
37
- print(f"DEBUG: Hugging Face API key found: {settings.HF_API_KEY[:10]}...")
38
  try:
39
  llava_service = LLaVAService(settings.HF_API_KEY)
40
  vlm_manager.register_service(llava_service)
41
- print(f"DEBUG: Registered LLaVA service: {llava_service.model_name}")
42
 
43
  blip2_service = BLIP2Service(settings.HF_API_KEY)
44
  vlm_manager.register_service(blip2_service)
45
- print(f"DEBUG: Registered BLIP2 service: {blip2_service.model_name}")
46
 
47
  instructblip_service = InstructBLIPService(settings.HF_API_KEY)
48
  vlm_manager.register_service(instructblip_service)
49
- print(f"DEBUG: Registered InstructBLIP service: {instructblip_service.model_name}")
50
 
51
- print("DEBUG: Successfully registered Hugging Face services")
52
  except Exception as e:
53
- print(f"DEBUG: Failed to register Hugging Face services: {e}")
54
  import traceback
55
  traceback.print_exc()
56
  else:
57
- print("DEBUG: No Hugging Face API key found")
58
 
59
- print(f"DEBUG: Registered services: {list(vlm_manager.services.keys())}")
60
- print(f"DEBUG: Available models: {vlm_manager.get_available_models()}")
61
 
62
  router = APIRouter()
63
 
@@ -70,7 +66,7 @@ def get_db():
70
 
71
  @router.post(
72
  "/images/{image_id}/caption",
73
- response_model=schemas.CaptionOut,
74
  )
75
  async def create_caption(
76
  image_id: str,
@@ -98,20 +94,17 @@ async def create_caption(
98
 
99
  metadata = {}
100
  try:
101
- print(f"DEBUG: calling VLM with model={model_name}")
102
  result = await vlm_manager.generate_caption(
103
  image_bytes=img_bytes,
104
  prompt=prompt,
105
  model_name=model_name,
106
  )
107
  text = result.get("caption", "")
108
- used_model = result.get("model", "UNKNOWN_MODEL")
109
  raw = result.get("raw_response", {})
110
  metadata = result.get("metadata", {})
111
- print(f"DEBUG: got caption: {text[:100]}… (model={used_model})")
112
- print(f"DEBUG: extracted metadata: {metadata}")
113
  except Exception as e:
114
- print(f"DEBUG: VLM error, falling back: {e}")
115
  text = "This is a fallback caption due to VLM service error."
116
  used_model = "STUB_MODEL"
117
  raw = {"error": str(e), "fallback": True}
@@ -127,67 +120,130 @@ async def create_caption(
127
  text=text,
128
  metadata=metadata,
129
  )
130
- return c
 
 
 
 
 
 
 
 
 
 
131
 
132
  @router.get(
133
- "/captions/{cap_id}",
134
- response_model=schemas.CaptionOut,
135
  )
136
  def get_caption(
137
- cap_id: str,
138
  db: Session = Depends(get_db),
139
  ):
140
- caption = crud.get_caption(db, cap_id)
141
- if not caption:
142
  raise HTTPException(404, "caption not found")
143
- return caption
 
 
 
 
 
 
 
 
 
 
144
 
145
  @router.get(
146
  "/images/{image_id}/captions",
147
- response_model=List[schemas.CaptionOut],
148
  )
149
  def get_captions_by_image(
150
  image_id: str,
151
  db: Session = Depends(get_db),
152
  ):
153
- """Get all captions for a specific image"""
154
  captions = crud.get_captions_by_image(db, image_id)
155
- return captions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  @router.get(
158
  "/captions",
159
- response_model=List[schemas.CaptionWithImageOut],
160
  )
161
  def get_all_captions_with_images(
162
  db: Session = Depends(get_db),
163
  ):
164
- """Get all captions with their associated image data"""
 
165
  captions = crud.get_all_captions_with_images(db)
166
- return captions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  @router.put(
169
- "/captions/{cap_id}",
170
- response_model=schemas.CaptionOut,
171
  )
172
  def update_caption(
173
- cap_id: str,
174
  update: schemas.CaptionUpdate,
175
  db: Session = Depends(get_db),
176
  ):
177
- caption = crud.update_caption(db, cap_id, update)
178
  if not caption:
179
  raise HTTPException(404, "caption not found")
180
- return caption
 
 
 
 
 
 
 
 
 
 
181
 
182
  @router.delete(
183
- "/captions/{cap_id}",
184
  )
185
  def delete_caption(
186
- cap_id: str,
187
  db: Session = Depends(get_db),
188
  ):
189
- """Delete a caption by ID"""
190
- success = crud.delete_caption(db, cap_id)
191
  if not success:
192
  raise HTTPException(404, "caption not found")
193
  return {"message": "Caption deleted successfully"}
 
17
  try:
18
  gpt4v_service = GPT4VService(settings.OPENAI_API_KEY)
19
  vlm_manager.register_service(gpt4v_service)
20
+ print(f" GPT-4 Vision service registered")
21
  except Exception as e:
22
+ print(f" GPT-4 Vision service failed: {e}")
23
  else:
24
+ print(" GPT-4 Vision service not configured")
25
 
26
  if settings.GOOGLE_API_KEY:
27
  try:
28
  gemini_service = GeminiService(settings.GOOGLE_API_KEY)
29
  vlm_manager.register_service(gemini_service)
30
+ print(f" Gemini service registered")
31
  except Exception as e:
32
+ print(f" Gemini service failed: {e}")
33
  else:
34
+ print(" Gemini service not configured")
35
 
36
  if settings.HF_API_KEY:
 
37
  try:
38
  llava_service = LLaVAService(settings.HF_API_KEY)
39
  vlm_manager.register_service(llava_service)
 
40
 
41
  blip2_service = BLIP2Service(settings.HF_API_KEY)
42
  vlm_manager.register_service(blip2_service)
 
43
 
44
  instructblip_service = InstructBLIPService(settings.HF_API_KEY)
45
  vlm_manager.register_service(instructblip_service)
 
46
 
47
+ print(f" Hugging Face services registered (LLaVA, BLIP2, InstructBLIP)")
48
  except Exception as e:
49
+ print(f" Hugging Face services failed: {e}")
50
  import traceback
51
  traceback.print_exc()
52
  else:
53
+ print(" Hugging Face services not configured")
54
 
55
+ print(f" Available models: {', '.join(vlm_manager.get_available_models())}")
56
+ print(f" Total services: {len(vlm_manager.services)}")
57
 
58
  router = APIRouter()
59
 
 
66
 
67
  @router.post(
68
  "/images/{image_id}/caption",
69
+ response_model=schemas.ImageOut,
70
  )
71
  async def create_caption(
72
  image_id: str,
 
94
 
95
  metadata = {}
96
  try:
 
97
  result = await vlm_manager.generate_caption(
98
  image_bytes=img_bytes,
99
  prompt=prompt,
100
  model_name=model_name,
101
  )
102
  text = result.get("caption", "")
103
+ used_model = model_name or "STUB_MODEL"
104
  raw = result.get("raw_response", {})
105
  metadata = result.get("metadata", {})
 
 
106
  except Exception as e:
107
+ print(f"VLM error, using fallback: {e}")
108
  text = "This is a fallback caption due to VLM service error."
109
  used_model = "STUB_MODEL"
110
  raw = {"error": str(e), "fallback": True}
 
120
  text=text,
121
  metadata=metadata,
122
  )
123
+
124
+ db.refresh(c)
125
+
126
+ from .upload import convert_image_to_dict
127
+ try:
128
+ url = storage.generate_presigned_url(c.file_key, expires_in=3600)
129
+ except Exception:
130
+ url = f"/api/images/{c.image_id}/file"
131
+
132
+ img_dict = convert_image_to_dict(c, url)
133
+ return schemas.ImageOut(**img_dict)
134
 
135
  @router.get(
136
+ "/images/{image_id}/caption",
137
+ response_model=schemas.ImageOut,
138
  )
139
  def get_caption(
140
+ image_id: str,
141
  db: Session = Depends(get_db),
142
  ):
143
+ caption = crud.get_caption(db, image_id)
144
+ if not caption or not caption.title:
145
  raise HTTPException(404, "caption not found")
146
+
147
+ db.refresh(caption)
148
+
149
+ from .upload import convert_image_to_dict
150
+ try:
151
+ url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
152
+ except Exception:
153
+ url = f"/api/images/{caption.image_id}/file"
154
+
155
+ img_dict = convert_image_to_dict(caption, url)
156
+ return schemas.ImageOut(**img_dict)
157
 
158
  @router.get(
159
  "/images/{image_id}/captions",
160
+ response_model=List[schemas.ImageOut],
161
  )
162
  def get_captions_by_image(
163
  image_id: str,
164
  db: Session = Depends(get_db),
165
  ):
166
+ """Get caption data for a specific image"""
167
  captions = crud.get_captions_by_image(db, image_id)
168
+
169
+ from .upload import convert_image_to_dict
170
+ result = []
171
+ for caption in captions:
172
+ db.refresh(caption)
173
+
174
+ try:
175
+ url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
176
+ except Exception:
177
+ url = f"/api/images/{caption.image_id}/file"
178
+
179
+ img_dict = convert_image_to_dict(caption, url)
180
+ result.append(schemas.ImageOut(**img_dict))
181
+
182
+ return result
183
 
184
  @router.get(
185
  "/captions",
186
+ response_model=List[schemas.ImageOut],
187
  )
188
  def get_all_captions_with_images(
189
  db: Session = Depends(get_db),
190
  ):
191
+ """Get all images that have caption data"""
192
+ print(f"DEBUG: Fetching all captions with images...")
193
  captions = crud.get_all_captions_with_images(db)
194
+ print(f"DEBUG: Found {len(captions)} images with caption data")
195
+
196
+ from .upload import convert_image_to_dict
197
+ result = []
198
+ for caption in captions:
199
+ print(f"DEBUG: Processing image {caption.image_id}, title: {caption.title}, generated: {caption.generated}, model: {caption.model}")
200
+
201
+ db.refresh(caption)
202
+
203
+ try:
204
+ url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
205
+ except Exception:
206
+ url = f"/api/images/{caption.image_id}/file"
207
+
208
+ img_dict = convert_image_to_dict(caption, url)
209
+ result.append(schemas.ImageOut(**img_dict))
210
+
211
+ print(f"DEBUG: Returning {len(result)} formatted results")
212
+ return result
213
 
214
  @router.put(
215
+ "/images/{image_id}/caption",
216
+ response_model=schemas.ImageOut,
217
  )
218
  def update_caption(
219
+ image_id: str,
220
  update: schemas.CaptionUpdate,
221
  db: Session = Depends(get_db),
222
  ):
223
+ caption = crud.update_caption(db, image_id, update)
224
  if not caption:
225
  raise HTTPException(404, "caption not found")
226
+
227
+ db.refresh(caption)
228
+
229
+ from .upload import convert_image_to_dict
230
+ try:
231
+ url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
232
+ except Exception:
233
+ url = f"/api/images/{caption.image_id}/file"
234
+
235
+ img_dict = convert_image_to_dict(caption, url)
236
+ return schemas.ImageOut(**img_dict)
237
 
238
  @router.delete(
239
+ "/images/{image_id}/caption",
240
  )
241
  def delete_caption(
242
+ image_id: str,
243
  db: Session = Depends(get_db),
244
  ):
245
+ """Delete caption data for an image"""
246
+ success = crud.delete_caption(db, image_id)
247
  if not success:
248
  raise HTTPException(404, "caption not found")
249
  return {"message": "Caption deleted successfully"}
py_backend/app/routers/images.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import mimetypes
3
+ from fastapi import APIRouter, HTTPException, Depends
4
+ from sqlalchemy.orm import Session
5
+
6
+ from ..database import SessionLocal
7
+ from ..models import Images, image_countries
8
+ from ..images import CreateImageFromUrlIn, CreateImageFromUrlOut
9
+ from .. import storage
10
+ from ..storage import upload_bytes, get_object_url
11
+
12
+ router = APIRouter()
13
+
14
+ def get_db():
15
+ db = SessionLocal()
16
+ try:
17
+ yield db
18
+ finally:
19
+ db.close()
20
+
21
+ @router.post("/from-url", response_model=CreateImageFromUrlOut)
22
+ async def create_image_from_url(payload: CreateImageFromUrlIn, db: Session = Depends(get_db)):
23
+ print(f"DEBUG: Received payload: {payload}")
24
+ try:
25
+ if payload.url.startswith('/api/images/') and '/file' in payload.url:
26
+ image_id = payload.url.split('/api/images/')[1].split('/file')[0]
27
+ print(f"DEBUG: Extracted image_id: {image_id}")
28
+ else:
29
+ raise HTTPException(status_code=400, detail="Invalid image URL format")
30
+
31
+ existing_image = db.query(Images).filter(Images.image_id == image_id).first()
32
+ if not existing_image:
33
+ raise HTTPException(status_code=404, detail="Source image not found")
34
+ print(f"DEBUG: Found existing image: {existing_image.image_id}")
35
+
36
+ try:
37
+ response = storage.s3.get_object(
38
+ Bucket=storage.settings.S3_BUCKET,
39
+ Key=existing_image.file_key,
40
+ )
41
+ data = response["Body"].read()
42
+ content_type = "image/jpeg"
43
+ print(f"DEBUG: Downloaded image data: {len(data)} bytes")
44
+ except Exception as e:
45
+ raise HTTPException(status_code=400, detail=f"Failed to fetch image from storage: {e}")
46
+
47
+ if len(data) > 25 * 1024 * 1024:
48
+ raise HTTPException(status_code=413, detail="Image too large")
49
+
50
+ ext = mimetypes.guess_extension(content_type) or ".jpg"
51
+ key = upload_bytes(data, filename=f"contributed{ext}", content_type=content_type)
52
+ image_url = get_object_url(key, expires_in=86400)
53
+ print(f"DEBUG: Uploaded new image with key: {key}")
54
+
55
+ sha = hashlib.sha256(data).hexdigest()
56
+ print(f"DEBUG: Creating new Images object...")
57
+
58
+ img = Images(
59
+ file_key=key,
60
+ sha256=sha,
61
+ source=payload.source,
62
+ event_type=payload.event_type,
63
+ epsg=payload.epsg,
64
+ image_type=payload.image_type,
65
+ title="no title",
66
+ prompt="",
67
+ model="STUB_MODEL",
68
+ schema_id="[email protected]",
69
+ raw_json={},
70
+ generated="",
71
+ edited="",
72
+ accuracy=50,
73
+ context=50,
74
+ usability=50,
75
+ starred=False
76
+ )
77
+ print(f"DEBUG: Images object created: {img}")
78
+ db.add(img)
79
+ db.flush() # get image_id
80
+ print(f"DEBUG: New image_id: {img.image_id}")
81
+
82
+ for c in payload.countries:
83
+ db.execute(image_countries.insert().values(image_id=img.image_id, c_code=c))
84
+
85
+ db.commit()
86
+ print(f"DEBUG: Database commit successful")
87
+
88
+ result = CreateImageFromUrlOut(image_id=str(img.image_id), image_url=image_url)
89
+ print(f"DEBUG: Returning result: {result}")
90
+ return result
91
+
92
+ except Exception as e:
93
+ print(f"DEBUG: Exception occurred: {type(e).__name__}: {str(e)}")
94
+ db.rollback()
95
+ raise HTTPException(status_code=500, detail=f"Failed to create image: {str(e)}")
py_backend/app/routers/metadata.py CHANGED
@@ -12,13 +12,13 @@ def get_db():
12
  finally:
13
  db.close()
14
 
15
- @router.put("/maps/{map_id}/metadata", response_model=schemas.CaptionOut)
16
  def update_metadata(
17
  map_id: str,
18
  update: schemas.CaptionUpdate,
19
  db: Session = Depends(get_db)
20
  ):
21
- c = crud.update_caption(db, map_id, **update.model_dump(exclude_unset=True))
22
  if not c:
23
  raise HTTPException(404, "caption not found")
24
  return c
 
12
  finally:
13
  db.close()
14
 
15
+ @router.put("/maps/{map_id}/metadata", response_model=schemas.ImageOut)
16
  def update_metadata(
17
  map_id: str,
18
  update: schemas.CaptionUpdate,
19
  db: Session = Depends(get_db)
20
  ):
21
+ c = crud.update_caption(db, map_id, update)
22
  if not c:
23
  raise HTTPException(404, "caption not found")
24
  return c
py_backend/app/routers/models.py CHANGED
@@ -57,7 +57,7 @@ def get_model_info(model_code: str, db: Session = Depends(get_db)):
57
  async def test_model(model_code: str, db: Session = Depends(get_db)):
58
  """Test a specific model with a sample image"""
59
  try:
60
- service = vlm_manager.get_service(model_code)
61
  if not service:
62
  raise HTTPException(404, "Model service not found")
63
 
 
57
  async def test_model(model_code: str, db: Session = Depends(get_db)):
58
  """Test a specific model with a sample image"""
59
  try:
60
+ service = vlm_manager.services.get(model_code)
61
  if not service:
62
  raise HTTPException(404, "Model service not found")
63
 
py_backend/app/routers/upload.py CHANGED
@@ -1,12 +1,22 @@
1
  from fastapi import APIRouter, UploadFile, Form, Depends, HTTPException, Response
 
2
  import io
3
  from sqlalchemy.orm import Session
4
  from .. import crud, schemas, storage, database
5
  from typing import List
6
  import boto3
 
7
 
8
  router = APIRouter()
9
 
 
 
 
 
 
 
 
 
10
  def get_db():
11
  db = database.SessionLocal()
12
  try:
@@ -17,6 +27,14 @@ def get_db():
17
 
18
  def convert_image_to_dict(img, image_url):
19
  """Helper function to convert SQLAlchemy image model to dict for Pydantic"""
 
 
 
 
 
 
 
 
20
  img_dict = {
21
  "image_id": img.image_id,
22
  "file_key": img.file_key,
@@ -26,35 +44,28 @@ def convert_image_to_dict(img, image_url):
26
  "epsg": img.epsg,
27
  "image_type": img.image_type,
28
  "image_url": image_url,
29
- "countries": [{"c_code": c.c_code, "label": c.label, "r_code": c.r_code} for c in img.countries],
30
- "captions": []
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
 
33
- if img.captions and len(img.captions) > 0:
34
- img_dict["captions"] = [{
35
- "cap_id": caption.cap_id,
36
- "image_id": caption.image_id,
37
- "title": caption.title,
38
- "prompt": caption.prompt,
39
- "model": caption.model,
40
- "schema_id": caption.schema_id,
41
- "raw_json": caption.raw_json,
42
- "generated": caption.generated,
43
- "edited": caption.edited,
44
- "accuracy": caption.accuracy,
45
- "context": caption.context,
46
- "usability": caption.usability,
47
- "starred": caption.starred,
48
- "created_at": caption.created_at,
49
- "updated_at": caption.updated_at
50
- } for caption in img.captions]
51
-
52
  return img_dict
53
 
54
 
55
  @router.get("/", response_model=List[schemas.ImageOut])
56
  def list_images(db: Session = Depends(get_db)):
57
- """Get all images with their captions"""
58
  images = crud.get_images(db)
59
  result = []
60
  for img in images:
@@ -115,6 +126,51 @@ async def upload_image(
115
  result = schemas.ImageOut(**img_dict)
116
  return result
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @router.get("/{image_id}/file")
119
  async def get_image_file(image_id: str, db: Session = Depends(get_db)):
120
  """Serve the actual image file"""
@@ -142,10 +198,16 @@ def update_image_metadata(
142
  db: Session = Depends(get_db)
143
  ):
144
  """Update image metadata (source, type, epsg, image_type, countries)"""
 
 
 
145
  img = crud.get_image(db, image_id)
146
  if not img:
 
147
  raise HTTPException(404, "Image not found")
148
 
 
 
149
  try:
150
  if metadata.source is not None:
151
  img.source = metadata.source
@@ -155,22 +217,36 @@ def update_image_metadata(
155
  img.epsg = metadata.epsg
156
  if metadata.image_type is not None:
157
  img.image_type = metadata.image_type
 
158
  if metadata.countries is not None:
159
- img.countries = []
 
160
  for country_code in metadata.countries:
161
  country = crud.get_country(db, country_code)
162
  if country:
163
  img.countries.append(country)
 
164
 
165
  db.commit()
166
- return {"message": "Image metadata updated successfully"}
 
 
 
 
 
 
 
 
 
 
167
  except Exception as e:
168
  db.rollback()
 
169
  raise HTTPException(500, f"Failed to update image metadata: {str(e)}")
170
 
171
  @router.delete("/{image_id}")
172
  def delete_image(image_id: str, db: Session = Depends(get_db)):
173
- """Delete an image and its associated caption"""
174
  img = crud.get_image(db, image_id)
175
  if not img:
176
  raise HTTPException(404, "Image not found")
 
1
  from fastapi import APIRouter, UploadFile, Form, Depends, HTTPException, Response
2
+ from pydantic import BaseModel
3
  import io
4
  from sqlalchemy.orm import Session
5
  from .. import crud, schemas, storage, database
6
  from typing import List
7
  import boto3
8
+ import time
9
 
10
  router = APIRouter()
11
 
12
+ class CopyImageRequest(BaseModel):
13
+ source_image_id: str
14
+ source: str
15
+ event_type: str
16
+ countries: str = ""
17
+ epsg: str = ""
18
+ image_type: str = "crisis_map"
19
+
20
  def get_db():
21
  db = database.SessionLocal()
22
  try:
 
27
 
28
  def convert_image_to_dict(img, image_url):
29
  """Helper function to convert SQLAlchemy image model to dict for Pydantic"""
30
+ countries_list = []
31
+ if hasattr(img, 'countries') and img.countries is not None:
32
+ try:
33
+ countries_list = [{"c_code": c.c_code, "label": c.label, "r_code": c.r_code} for c in img.countries]
34
+ except Exception as e:
35
+ print(f"Warning: Error processing countries for image {img.image_id}: {e}")
36
+ countries_list = []
37
+
38
  img_dict = {
39
  "image_id": img.image_id,
40
  "file_key": img.file_key,
 
44
  "epsg": img.epsg,
45
  "image_type": img.image_type,
46
  "image_url": image_url,
47
+ "countries": countries_list,
48
+ "title": img.title,
49
+ "prompt": img.prompt,
50
+ "model": img.model,
51
+ "schema_id": img.schema_id,
52
+ "raw_json": img.raw_json,
53
+ "generated": img.generated,
54
+ "edited": img.edited,
55
+ "accuracy": img.accuracy,
56
+ "context": img.context,
57
+ "usability": img.usability,
58
+ "starred": img.starred if img.starred is not None else False,
59
+ "created_at": img.created_at,
60
+ "updated_at": img.updated_at
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  return img_dict
64
 
65
 
66
  @router.get("/", response_model=List[schemas.ImageOut])
67
  def list_images(db: Session = Depends(get_db)):
68
+ """Get all images with their caption data"""
69
  images = crud.get_images(db)
70
  result = []
71
  for img in images:
 
126
  result = schemas.ImageOut(**img_dict)
127
  return result
128
 
129
+ @router.post("/copy", response_model=schemas.ImageOut)
130
+ async def copy_image_for_contribution(
131
+ request: CopyImageRequest,
132
+ db: Session = Depends(get_db)
133
+ ):
134
+ """Copy an existing image for contribution purposes, creating a new image_id"""
135
+ source_img = crud.get_image(db, request.source_image_id)
136
+ if not source_img:
137
+ raise HTTPException(404, "Source image not found")
138
+
139
+ try:
140
+ response = storage.s3.get_object(
141
+ Bucket=storage.settings.S3_BUCKET,
142
+ Key=source_img.file_key,
143
+ )
144
+ image_content = response["Body"].read()
145
+
146
+ new_filename = f"contribution_{request.source_image_id}_{int(time.time())}.jpg"
147
+ new_key = storage.upload_fileobj(io.BytesIO(image_content), new_filename)
148
+
149
+ countries_list = [c.strip() for c in request.countries.split(',') if c.strip()] if request.countries else []
150
+
151
+ new_img = crud.create_image(
152
+ db,
153
+ request.source,
154
+ request.event_type,
155
+ new_key,
156
+ source_img.sha256,
157
+ countries_list,
158
+ request.epsg,
159
+ request.image_type
160
+ )
161
+
162
+ try:
163
+ url = storage.generate_presigned_url(new_key, expires_in=3600)
164
+ except Exception as e:
165
+ url = f"/api/images/{new_img.image_id}/file"
166
+
167
+ img_dict = convert_image_to_dict(new_img, url)
168
+ result = schemas.ImageOut(**img_dict)
169
+ return result
170
+
171
+ except Exception as e:
172
+ raise HTTPException(500, f"Failed to copy image: {str(e)}")
173
+
174
  @router.get("/{image_id}/file")
175
  async def get_image_file(image_id: str, db: Session = Depends(get_db)):
176
  """Serve the actual image file"""
 
198
  db: Session = Depends(get_db)
199
  ):
200
  """Update image metadata (source, type, epsg, image_type, countries)"""
201
+ print(f"DEBUG: Updating metadata for image {image_id}")
202
+ print(f"DEBUG: Metadata received: {metadata}")
203
+
204
  img = crud.get_image(db, image_id)
205
  if not img:
206
+ print(f"DEBUG: Image {image_id} not found in database")
207
  raise HTTPException(404, "Image not found")
208
 
209
+ print(f"DEBUG: Found image {image_id} in database")
210
+
211
  try:
212
  if metadata.source is not None:
213
  img.source = metadata.source
 
217
  img.epsg = metadata.epsg
218
  if metadata.image_type is not None:
219
  img.image_type = metadata.image_type
220
+
221
  if metadata.countries is not None:
222
+ print(f"DEBUG: Updating countries to: {metadata.countries}")
223
+ img.countries.clear()
224
  for country_code in metadata.countries:
225
  country = crud.get_country(db, country_code)
226
  if country:
227
  img.countries.append(country)
228
+ print(f"DEBUG: Added country: {country_code}")
229
 
230
  db.commit()
231
+ db.refresh(img)
232
+ print(f"DEBUG: Metadata update successful for image {image_id}")
233
+
234
+ try:
235
+ url = storage.generate_presigned_url(img.file_key, expires_in=3600)
236
+ except Exception:
237
+ url = f"/api/images/{img.image_id}/file"
238
+
239
+ img_dict = convert_image_to_dict(img, url)
240
+ return schemas.ImageOut(**img_dict)
241
+
242
  except Exception as e:
243
  db.rollback()
244
+ print(f"DEBUG: Metadata update failed for image {image_id}: {str(e)}")
245
  raise HTTPException(500, f"Failed to update image metadata: {str(e)}")
246
 
247
  @router.delete("/{image_id}")
248
  def delete_image(image_id: str, db: Session = Depends(get_db)):
249
+ """Delete an image and its associated caption data"""
250
  img = crud.get_image(db, image_id)
251
  if not img:
252
  raise HTTPException(404, "Image not found")
py_backend/app/schemas.py CHANGED
@@ -27,40 +27,12 @@ class ImageOut(BaseModel):
27
  image_type: str
28
  image_url: str
29
  countries: List["CountryOut"] = []
30
- captions: List["CaptionOut"] = []
31
-
32
- class Config:
33
- from_attributes = True
34
-
35
- class CaptionOut(BaseModel):
36
- cap_id: UUID
37
- image_id: UUID
38
- title: str
39
- prompt: str
40
- model: str
41
- schema_id: str
42
- raw_json: dict
43
- generated: str
44
- edited: Optional[str] = None
45
- accuracy: Optional[int] = None
46
- context: Optional[int] = None
47
- usability: Optional[int] = None
48
- starred: bool = False
49
- created_at: Optional[datetime] = None
50
- updated_at: Optional[datetime] = None
51
-
52
- class Config:
53
- from_attributes = True
54
-
55
- class CaptionWithImageOut(BaseModel):
56
- cap_id: UUID
57
- image_id: UUID
58
- title: str
59
- prompt: str
60
- model: str
61
- schema_id: str
62
- raw_json: dict
63
- generated: str
64
  edited: Optional[str] = None
65
  accuracy: Optional[int] = None
66
  context: Optional[int] = None
@@ -68,19 +40,17 @@ class CaptionWithImageOut(BaseModel):
68
  starred: bool = False
69
  created_at: Optional[datetime] = None
70
  updated_at: Optional[datetime] = None
71
- file_key: str
72
- image_url: str
73
- source: str
74
- event_type: str
75
- epsg: str
76
- image_type: str
77
- countries: List["CountryOut"] = []
78
 
79
  class Config:
80
  from_attributes = True
81
 
82
  class CaptionUpdate(BaseModel):
83
  title: Optional[str] = None
 
 
 
 
 
84
  edited: Optional[str] = None
85
  accuracy: Optional[int] = None
86
  context: Optional[int] = None
 
27
  image_type: str
28
  image_url: str
29
  countries: List["CountryOut"] = []
30
+ title: Optional[str] = None
31
+ prompt: Optional[str] = None
32
+ model: Optional[str] = None
33
+ schema_id: Optional[str] = None
34
+ raw_json: Optional[dict] = None
35
+ generated: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  edited: Optional[str] = None
37
  accuracy: Optional[int] = None
38
  context: Optional[int] = None
 
40
  starred: bool = False
41
  created_at: Optional[datetime] = None
42
  updated_at: Optional[datetime] = None
 
 
 
 
 
 
 
43
 
44
  class Config:
45
  from_attributes = True
46
 
47
  class CaptionUpdate(BaseModel):
48
  title: Optional[str] = None
49
+ prompt: Optional[str] = None
50
+ model: Optional[str] = None
51
+ schema_id: Optional[str] = None
52
+ raw_json: Optional[dict] = None
53
+ generated: Optional[str] = None
54
  edited: Optional[str] = None
55
  accuracy: Optional[int] = None
56
  context: Optional[int] = None
py_backend/app/services/gpt4v_service.py CHANGED
@@ -2,9 +2,8 @@ from .vlm_service import VLMService, ModelType
2
  from typing import Dict, Any
3
  import openai
4
  import base64
5
- import io
6
  import asyncio
7
- from PIL import Image
8
 
9
  class GPT4VService(VLMService):
10
  """GPT-4 Vision service implementation"""
@@ -56,45 +55,44 @@ class GPT4VService(VLMService):
56
  )
57
 
58
  content = response.choices[0].message.content
59
- print(f"DEBUG: Raw AI response: {content[:200]}...")
60
 
61
- import json
62
- import re
63
-
64
- cleaned_content = content
65
- if content.startswith('```json'):
66
- cleaned_content = re.sub(r'^```json\s*', '', content)
67
- cleaned_content = re.sub(r'\s*```$', '', cleaned_content)
68
- print(f"DEBUG: Cleaned content: {cleaned_content[:200]}...")
69
 
 
70
  try:
71
- parsed = json.loads(cleaned_content)
72
- caption = parsed.get("analysis", content)
73
- metadata = parsed.get("metadata", {})
74
-
75
- if metadata.get("epsg"):
76
- epsg_value = metadata["epsg"]
77
- allowed_epsg = ["4326", "3857", "32617", "32633", "32634", "OTHER"]
78
- if epsg_value not in allowed_epsg:
79
- print(f"DEBUG: Invalid EPSG value '{epsg_value}', setting to 'OTHER'")
80
- metadata["epsg"] = "OTHER"
81
-
82
- print(f"DEBUG: Successfully parsed JSON, metadata: {metadata}")
83
- except json.JSONDecodeError as e:
84
- print(f"DEBUG: JSON parse error: {e}")
85
- caption = content
86
- metadata = {}
 
 
 
87
 
88
  return {
89
- "caption": caption,
90
- "metadata": metadata,
91
- "confidence": 0.9,
92
- "processing_time": 0.0,
93
  "raw_response": {
94
- "model": "gpt-4o",
95
- "usage": response.usage.dict() if response.usage else None,
96
- "finish_reason": response.choices[0].finish_reason
97
- }
 
98
  }
99
 
100
  except Exception as e:
 
2
  from typing import Dict, Any
3
  import openai
4
  import base64
 
5
  import asyncio
6
+ import json
7
 
8
  class GPT4VService(VLMService):
9
  """GPT-4 Vision service implementation"""
 
55
  )
56
 
57
  content = response.choices[0].message.content
 
58
 
59
+ cleaned_content = content.strip()
60
+ if cleaned_content.startswith("```json"):
61
+ cleaned_content = cleaned_content[7:]
62
+ if cleaned_content.endswith("```"):
63
+ cleaned_content = cleaned_content[:-3]
64
+ cleaned_content = cleaned_content.strip()
 
 
65
 
66
+ metadata = {}
67
  try:
68
+ metadata = json.loads(cleaned_content)
69
+ except json.JSONDecodeError:
70
+ if "```json" in content:
71
+ json_start = content.find("```json") + 7
72
+ json_end = content.find("```", json_start)
73
+ if json_end > json_start:
74
+ json_str = content[json_start:json_end].strip()
75
+ try:
76
+ metadata = json.loads(json_str)
77
+ except json.JSONDecodeError as e:
78
+ print(f"JSON parse error: {e}")
79
+ else:
80
+ import re
81
+ json_match = re.search(r'\{[^{}]*"metadata"[^{}]*\{[^{}]*\}', content)
82
+ if json_match:
83
+ try:
84
+ metadata = json.loads(json_match.group())
85
+ except json.JSONDecodeError:
86
+ pass
87
 
88
  return {
89
+ "caption": cleaned_content,
 
 
 
90
  "raw_response": {
91
+ "content": content,
92
+ "metadata": metadata,
93
+ "extracted_metadata": metadata
94
+ },
95
+ "metadata": metadata
96
  }
97
 
98
  except Exception as e:
py_backend/app/services/stub_vlm_service.py CHANGED
@@ -8,24 +8,12 @@ class StubVLMService(VLMService):
8
  def __init__(self):
9
  super().__init__("STUB_MODEL", ModelType.CUSTOM)
10
 
11
- async def generate_caption(self, image_bytes: bytes, prompt: str) -> Dict[str, Any]:
12
- """Generate a stub caption"""
13
- print(f"DEBUG: StubVLMService: Generating stub caption")
14
- print(f"DEBUG: StubVLMService: Image size: {len(image_bytes)}")
15
- print(f"DEBUG: StubVLMService: Prompt: {prompt[:100]}...")
16
-
17
- await asyncio.sleep(0.1)
18
-
19
- caption = "This is a stub caption generated for testing purposes. The image appears to contain geographic or crisis-related information."
20
- print(f"DEBUG: StubVLMService: Generated caption: {caption}")
21
 
22
  return {
23
  "caption": caption,
24
- "confidence": 0.85,
25
- "processing_time": 0.1,
26
- "raw_response": {
27
- "stub": True,
28
- "prompt": prompt,
29
- "image_size": len(image_bytes)
30
- }
31
  }
 
8
  def __init__(self):
9
  super().__init__("STUB_MODEL", ModelType.CUSTOM)
10
 
11
+ async def generate_caption(self, image_bytes: bytes, prompt: str) -> dict:
12
+ """Generate a stub caption for testing purposes."""
13
+ caption = f"This is a stub caption for testing. Image size: {len(image_bytes)} bytes. Prompt: {prompt[:50]}..."
 
 
 
 
 
 
 
14
 
15
  return {
16
  "caption": caption,
17
+ "raw_response": {"stub": True, "caption": caption},
18
+ "metadata": {}
 
 
 
 
 
19
  }
py_backend/app/services/vlm_service.py CHANGED
@@ -58,45 +58,35 @@ class VLMServiceManager:
58
  return self.services.get(self.default_service)
59
  return None
60
 
61
- def get_available_models(self) -> Dict[str, Dict[str, Any]]:
62
- """Get information about all available models"""
63
- return {
64
- name: service.get_model_info()
65
- for name, service in self.services.items()
66
- }
67
 
68
- async def generate_caption(self, image_bytes: bytes, prompt: str, model_name: str = None) -> Dict[str, Any]:
69
- """Generate caption using specified or default model"""
70
- print(f"DEBUG: VLM Manager: Looking for model_name: {model_name}")
71
- print(f"DEBUG: VLM Manager: Available services: {list(self.services.keys())}")
72
 
 
73
  service = None
74
  if model_name:
75
- service = self.get_service(model_name)
76
- print(f"DEBUG: VLM Manager: Found service for {model_name}: {service is not None}")
77
- if not service:
78
- raise ValueError(f"Model {model_name} not found")
79
- else:
80
- service = self.get_default_service()
81
  if not service:
82
- raise ValueError("No default model available")
83
 
84
- if not service.is_available:
85
- raise ValueError(f"Model {service.model_name} is not available")
 
 
86
 
87
- print(f"DEBUG: VLM Manager: Using service: {service.model_name}")
 
88
 
89
  try:
90
- print(f"DEBUG: VLM Manager: Calling generate_caption on {service.model_name}")
91
  result = await service.generate_caption(image_bytes, prompt)
92
- result["model"] = service.model_name
93
- print(f"DEBUG: VLM Manager: Successfully generated caption with {service.model_name}")
94
  return result
95
  except Exception as e:
96
- print(f"DEBUG: VLM Manager: Error generating caption with {service.model_name}: {str(e)}")
97
- import traceback
98
- traceback.print_exc()
99
- logger.error(f"Error generating caption with {service.model_name}: {str(e)}")
100
  raise
101
 
102
  vlm_manager = VLMServiceManager()
 
58
  return self.services.get(self.default_service)
59
  return None
60
 
61
+ def get_available_models(self) -> list:
62
+ """Get list of available model names"""
63
+ return list(self.services.keys())
 
 
 
64
 
65
+ async def generate_caption(self, image_bytes: bytes, prompt: str, model_name: str | None = None) -> dict:
66
+ """Generate caption using the specified model or fallback to available service."""
 
 
67
 
68
+ # Find appropriate service
69
  service = None
70
  if model_name:
71
+ service = self.services.get(model_name)
 
 
 
 
 
72
  if not service:
73
+ print(f"Model '{model_name}' not found, using fallback")
74
 
75
+ # Fallback to first available service
76
+ if not service and self.services:
77
+ service = next(iter(self.services.values()))
78
+ print(f"Using fallback service: {service.model_name}")
79
 
80
+ if not service:
81
+ raise ValueError("No VLM services available")
82
 
83
  try:
 
84
  result = await service.generate_caption(image_bytes, prompt)
85
+ if isinstance(result, dict):
86
+ result["model"] = service.model_name
87
  return result
88
  except Exception as e:
89
+ print(f"Error generating caption: {str(e)}")
 
 
 
90
  raise
91
 
92
  vlm_manager = VLMServiceManager()
py_backend/app/storage.py CHANGED
@@ -1,8 +1,11 @@
1
  import io
 
 
 
 
2
  import boto3
3
  import botocore
4
- from uuid import uuid4
5
- from typing import BinaryIO
6
  from .config import settings
7
 
8
  s3 = boto3.client(
@@ -10,25 +13,39 @@ s3 = boto3.client(
10
  endpoint_url=settings.S3_ENDPOINT,
11
  aws_access_key_id=settings.S3_ACCESS_KEY,
12
  aws_secret_access_key=settings.S3_SECRET_KEY,
 
13
  )
14
 
15
- def upload_fileobj(fileobj: BinaryIO, filename: str) -> str:
 
 
 
 
16
  """
17
- Uploads a file-like object to the configured S3 bucket,
18
- automatically creating the bucket if it doesn't exist.
19
- Returns the object key.
20
  """
21
- key = f"maps/{uuid4()}_{filename}"
22
-
23
  try:
24
  s3.head_bucket(Bucket=settings.S3_BUCKET)
25
  except botocore.exceptions.ClientError as e:
26
- s3.create_bucket(Bucket=settings.S3_BUCKET)
 
 
 
 
 
 
27
 
28
- fileobj.seek(0)
29
- s3.upload_fileobj(fileobj, settings.S3_BUCKET, key)
30
 
31
- return key
 
 
 
 
 
 
 
 
 
32
 
33
  def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
34
  """
@@ -39,3 +56,95 @@ def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
39
  Params={"Bucket": settings.S3_BUCKET, "Key": key},
40
  ExpiresIn=expires_in,
41
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import io
2
+ import mimetypes
3
+ from uuid import uuid4
4
+ from typing import BinaryIO, Optional
5
+
6
  import boto3
7
  import botocore
8
+
 
9
  from .config import settings
10
 
11
  s3 = boto3.client(
 
13
  endpoint_url=settings.S3_ENDPOINT,
14
  aws_access_key_id=settings.S3_ACCESS_KEY,
15
  aws_secret_access_key=settings.S3_SECRET_KEY,
16
+ region_name=getattr(settings, "S3_REGION", None),
17
  )
18
 
19
+ # Optional settings you can add to your config:
20
+ # - S3_PUBLIC_URL_BASE: str | None (e.g. "https://cdn.example.com" or bucket website endpoint)
21
+ # - S3_PUBLIC_READ: bool (True if the bucket/objects are world-readable)
22
+
23
+ def _ensure_bucket() -> None:
24
  """
25
+ Make sure the bucket exists. Safe to call on every upload.
 
 
26
  """
 
 
27
  try:
28
  s3.head_bucket(Bucket=settings.S3_BUCKET)
29
  except botocore.exceptions.ClientError as e:
30
+ # Create bucket. Some providers need LocationConstraint; MinIO typically doesn't.
31
+ create_kwargs = {"Bucket": settings.S3_BUCKET}
32
+ region = getattr(settings, "S3_REGION", None)
33
+ # For AWS S3 outside us-east-1 you must pass LocationConstraint.
34
+ if region and (settings.S3_ENDPOINT is None or "amazonaws.com" in str(settings.S3_ENDPOINT).lower()):
35
+ create_kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region}
36
+ s3.create_bucket(**create_kwargs)
37
 
 
 
38
 
39
+ def get_object_url(key: str, *, expires_in: int = 3600) -> str:
40
+ """
41
+ Return a browser-usable URL for the object.
42
+ If S3_PUBLIC_URL_BASE is set, return a public URL. Otherwise, return a presigned URL.
43
+ """
44
+ public_base = getattr(settings, "S3_PUBLIC_URL_BASE", None)
45
+ if public_base:
46
+ return f"{public_base.rstrip('/')}/{key}"
47
+ return generate_presigned_url(key, expires_in=expires_in)
48
+
49
 
50
  def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
51
  """
 
56
  Params={"Bucket": settings.S3_BUCKET, "Key": key},
57
  ExpiresIn=expires_in,
58
  )
59
+
60
+
61
+ def upload_fileobj(
62
+ fileobj: BinaryIO,
63
+ filename: str,
64
+ *,
65
+ content_type: Optional[str] = None,
66
+ cache_control: Optional[str] = "public, max-age=31536000, immutable",
67
+ ) -> str:
68
+ """
69
+ Upload a file-like object to the configured S3 bucket.
70
+ Returns the object key (not the URL).
71
+ """
72
+ _ensure_bucket()
73
+
74
+ # Build a namespaced key; keep original filename tail if helpful
75
+ safe_name = filename or "upload.bin"
76
+ key = f"maps/{uuid4()}_{safe_name}"
77
+
78
+ # Guess content type if not provided
79
+ ct = content_type or (mimetypes.guess_type(safe_name)[0] or "application/octet-stream")
80
+
81
+ # Make sure we read from the start
82
+ try:
83
+ fileobj.seek(0)
84
+ except Exception:
85
+ pass
86
+
87
+ extra_args = {"ContentType": ct}
88
+ if cache_control:
89
+ extra_args["CacheControl"] = cache_control
90
+ if getattr(settings, "S3_PUBLIC_READ", False):
91
+ # Only set this if your bucket policy allows public read
92
+ extra_args["ACL"] = "public-read"
93
+
94
+ s3.upload_fileobj(fileobj, settings.S3_BUCKET, key, ExtraArgs=extra_args)
95
+ return key
96
+
97
+
98
+ def upload_bytes(
99
+ data: bytes,
100
+ filename: str,
101
+ *,
102
+ content_type: Optional[str] = None,
103
+ cache_control: Optional[str] = "public, max-age=31536000, immutable",
104
+ ) -> str:
105
+ """
106
+ Convenience helper to upload raw bytes (e.g., after server-side URL download).
107
+ """
108
+ buf = io.BytesIO(data)
109
+ return upload_fileobj(buf, filename, content_type=content_type, cache_control=cache_control)
110
+
111
+
112
+ def copy_object(
113
+ src_key: str,
114
+ *,
115
+ new_filename: Optional[str] = None,
116
+ cache_control: Optional[str] = "public, max-age=31536000, immutable",
117
+ ) -> str:
118
+ """
119
+ Server-side copy within the same bucket (no download/upload round-trip).
120
+ Useful for 'duplicate' endpoints if you already know the source key.
121
+ Returns the NEW object key.
122
+ """
123
+ _ensure_bucket()
124
+ tail = new_filename or src_key.split("/")[-1]
125
+ dest_key = f"maps/{uuid4()}_{tail}"
126
+
127
+ extra_args = {}
128
+ if cache_control:
129
+ extra_args["CacheControl"] = cache_control
130
+ if getattr(settings, "S3_PUBLIC_READ", False):
131
+ extra_args["ACL"] = "public-read"
132
+
133
+ s3.copy(
134
+ {"Bucket": settings.S3_BUCKET, "Key": src_key},
135
+ settings.S3_BUCKET,
136
+ dest_key,
137
+ ExtraArgs=extra_args or None,
138
+ )
139
+ return dest_key
140
+
141
+
142
+ def delete_object(key: str) -> None:
143
+ """
144
+ Delete an object (best-effort).
145
+ """
146
+ try:
147
+ s3.delete_object(Bucket=settings.S3_BUCKET, Key=key)
148
+ except botocore.exceptions.ClientError:
149
+ # Swallow to keep deletes idempotent for callers
150
+ pass
py_backend/tests/test_explore_page.py CHANGED
@@ -176,9 +176,9 @@ def test_database_consistency():
176
  images = db.query(models.Images).all()
177
  print(f" + Total images in database: {len(images)}")
178
 
179
- # Check if captions exist
180
- captions = db.query(models.Captions).all()
181
- print(f" + Total captions in database: {len(captions)}")
182
 
183
  # Check metadata tables
184
  sources = db.query(models.Source).all()
@@ -191,9 +191,6 @@ def test_database_consistency():
191
  print(f" + Total countries: {len(countries)}")
192
 
193
  # Check relationships
194
- images_with_captions = db.query(models.Images).join(models.Captions).all()
195
- print(f" + Images with captions: {len(images_with_captions)}")
196
-
197
  images_with_countries = db.query(models.Images).join(models.Images.countries).all()
198
  print(f" + Images with countries: {len(images_with_countries)}")
199
 
@@ -234,7 +231,7 @@ def create_test_data():
234
  raw_json={"test": True},
235
  text="This is a test caption for the explore page."
236
  )
237
- print(f" + Created test caption: {caption.cap_id}")
238
 
239
  return img.image_id
240
 
 
176
  images = db.query(models.Images).all()
177
  print(f" + Total images in database: {len(images)}")
178
 
179
+ # Check if images have caption data
180
+ images_with_captions = db.query(models.Images).filter(models.Images.title.isnot(None)).all()
181
+ print(f" + Images with caption data: {len(images_with_captions)}")
182
 
183
  # Check metadata tables
184
  sources = db.query(models.Source).all()
 
191
  print(f" + Total countries: {len(countries)}")
192
 
193
  # Check relationships
 
 
 
194
  images_with_countries = db.query(models.Images).join(models.Images.countries).all()
195
  print(f" + Images with countries: {len(images_with_countries)}")
196
 
 
231
  raw_json={"test": True},
232
  text="This is a test caption for the explore page."
233
  )
234
+ print(f" + Created test caption: {caption.image_id}")
235
 
236
  return img.image_id
237
 
py_backend/tests/test_upload_flow.py CHANGED
@@ -14,39 +14,34 @@ from app.database import SessionLocal
14
  from app import crud, models
15
 
16
  def test_database_connection():
17
- """Test if we can connect to database and create records"""
18
  db = SessionLocal()
19
  try:
20
  print("Testing database connection...")
21
 
22
- # Test creating an image
 
 
 
 
23
  test_img = models.Images(
24
- source="OTHER",
25
- type="OTHER",
26
  file_key="test_key",
27
- sha256="test_sha256",
 
 
28
  epsg="4326",
29
- image_type="crisis_map"
30
- )
31
-
32
- db.add(test_img)
33
- db.flush()
34
- print(f"Created test image with ID: {test_img.image_id}")
35
-
36
- # Create a test caption
37
- test_caption = models.Captions(
38
- image_id=test_img.image_id,
39
- title="Test Caption",
40
  prompt="Test prompt",
41
  model="STUB_MODEL",
 
42
  raw_json={"test": True},
43
- generated="This is a test caption",
44
- edited="This is a test caption"
45
  )
46
 
47
- db.add(test_caption)
48
  db.commit()
49
- print(f"Created test caption with ID: {test_caption.cap_id}")
50
 
51
  # Clean up
52
  db.delete(test_img)
@@ -88,7 +83,7 @@ def test_crud_functions():
88
  raw_json={"test_crud": True},
89
  text="This is a test CRUD caption"
90
  )
91
- print(f"CRUD create_caption successful: {caption.cap_id}")
92
 
93
  # Clean up
94
  db.delete(img)
@@ -144,20 +139,21 @@ def test_complete_upload_flow():
144
 
145
  if caption_response.status_code == 200:
146
  caption_result = caption_response.json()
147
- caption_id = caption_result['cap_id']
148
- print(f"Caption created! Caption ID: {caption_id}")
149
 
150
  # Step 3: Submit caption via API
151
  print("3. Submitting caption via API...")
152
  submit_data = {
153
- 'edited': 'This is a test caption for the upload flow.',
154
- 'accuracy': 75,
155
- 'context': 80,
156
- 'usability': 70
 
157
  }
158
 
159
  submit_response = requests.put(
160
- f'http://localhost:8080/api/captions/{caption_id}',
161
  json=submit_data
162
  )
163
  print(f"Submit response status: {submit_response.status_code}")
@@ -165,113 +161,64 @@ def test_complete_upload_flow():
165
  if submit_response.status_code == 200:
166
  print("Caption submitted successfully!")
167
 
168
- # Step 4: Check if image appears in list
169
- print("4. Checking if image appears in list...")
170
- time.sleep(1) # Give a moment for database to settle
171
-
172
- list_response = requests.get('http://localhost:8080/api/images/')
173
- print(f"List response status: {list_response.status_code}")
174
-
175
- if list_response.status_code == 200:
176
- images = list_response.json()
177
- print(f"Total images in list: {len(images)}")
178
-
179
- # Check if our uploaded image is in the list
180
- uploaded_image = next((img for img in images if img['image_id'] == image_id), None)
181
- if uploaded_image:
182
- print("SUCCESS: Uploaded image found in list!")
183
- print(f"Image details: {uploaded_image}")
184
- else:
185
- print("ERROR: Uploaded image NOT found in list!")
186
- print("Available image IDs:", [img['image_id'] for img in images])
187
-
188
- # Step 5: Check database directly
189
- print("5. Checking database directly...")
190
  db = SessionLocal()
191
  try:
192
- # Check images table
193
- db_image = db.query(models.Images).filter(models.Images.image_id == image_id).first()
194
- if db_image:
195
- print(f"SUCCESS: Image found in database: {db_image.image_id}")
196
- print(f"Image source: {db_image.source}, event_type: {db_image.event_type}")
197
- else:
198
- print("ERROR: Image NOT found in database!")
199
-
200
- # Check captions table
201
- db_caption = db.query(models.Captions).filter(models.Captions.image_id == image_id).first()
202
  if db_caption:
203
- print(f"SUCCESS: Caption found in database: {db_caption.cap_id}")
 
 
 
204
  else:
205
- print("ERROR: Caption NOT found in database!")
206
-
207
- # Check total counts
208
- total_images = db.query(models.Images).count()
209
- total_captions = db.query(models.Captions).count()
210
- print(f"Total images in database: {total_images}")
211
- print(f"Total captions in database: {total_captions}")
212
-
213
  finally:
214
  db.close()
215
-
216
- # Clean up
217
- print("6. Cleaning up...")
218
- delete_response = requests.delete(f'http://localhost:8080/api/images/{image_id}')
219
- print(f"Delete status: {delete_response.status_code}")
220
-
221
  else:
222
- print(f"ERROR: Caption submission failed: {submit_response.text}")
223
  else:
224
- print(f"ERROR: Caption creation failed: {caption_response.text}")
225
  else:
226
- print(f"ERROR: Upload failed: {response.text}")
227
 
228
  except Exception as e:
229
- print(f"ERROR: Upload flow test failed: {e}")
 
 
230
 
231
  def test_deletion_logic():
232
- """Test that images are only deleted when appropriate"""
233
- print("\n=== Testing Deletion Logic ===")
234
 
235
- # Test 1: Upload and verify image exists
236
- print("1. Uploading test image...")
237
- test_content = b"deletion test image data"
238
- files = {'file': ('deletion_test.jpg', test_content, 'image/jpeg')}
 
 
 
239
  data = {
240
  'source': 'OTHER',
241
- 'type': 'OTHER',
242
  'countries': ['US'],
243
  'epsg': '4326',
244
  'image_type': 'crisis_map'
245
  }
246
 
247
  try:
248
- upload_response = requests.post('http://localhost:8080/api/images/', files=files, data=data, timeout=30)
249
- print(f" Upload status: {upload_response.status_code}")
250
 
251
- if upload_response.status_code == 200:
252
- upload_result = upload_response.json()
253
- image_id = upload_result.get('image_id')
254
- print(f"Upload successful! Image ID: {image_id}")
255
-
256
- # Test 2: Verify image exists in database
257
- print("2. Verifying image exists in database...")
258
- time.sleep(0.5)
259
-
260
- list_response = requests.get('http://localhost:8080/api/images/', timeout=10)
261
- if list_response.status_code == 200:
262
- images = list_response.json()
263
- uploaded_image = next((img for img in images if img.get('image_id') == image_id), None)
264
-
265
- if uploaded_image:
266
- print(" ✓ Image found in database!")
267
- else:
268
- print(" ✗ ERROR: Image NOT found in database!")
269
- return False
270
 
271
- # Test 3: Create caption
272
- print("3. Creating caption...")
273
  caption_data = {
274
- 'title': 'Deletion Test Caption',
275
  'prompt': 'Describe this test image'
276
  }
277
 
@@ -279,66 +226,43 @@ def test_deletion_logic():
279
  f'http://localhost:8080/api/images/{image_id}/caption',
280
  data=caption_data
281
  )
282
- print(f" Caption creation status: {caption_response.status_code}")
283
 
284
  if caption_response.status_code == 200:
285
- caption_result = caption_response.json()
286
- caption_id = caption_result.get('cap_id')
287
- print(f" ✓ Caption created! Caption ID: {caption_id}")
288
 
289
- # Test 4: Submit caption (simulating successful submission)
290
- print("4. Submitting caption (simulating successful submission)...")
291
- submit_data = {
292
- 'edited': 'This is a test caption for deletion logic.',
293
- 'accuracy': 85,
294
- 'context': 90,
295
- 'usability': 80
296
- }
297
-
298
- submit_response = requests.put(
299
- f'http://localhost:8080/api/captions/{caption_id}',
300
- json=submit_data
301
  )
302
- print(f" Submit status: {submit_response.status_code}")
303
 
304
- if submit_response.status_code == 200:
305
- print(" Caption submitted successfully!")
306
-
307
- # Test 5: Verify image still exists after submission (should NOT be deleted)
308
- print("5. Verifying image still exists after submission...")
309
- time.sleep(1)
310
 
311
- list_response = requests.get('http://localhost:8080/api/images/', timeout=10)
312
- if list_response.status_code == 200:
313
- images = list_response.json()
314
- submitted_image = next((img for img in images if img.get('image_id') == image_id), None)
315
-
316
- if submitted_image:
317
- print(" ✓ SUCCESS: Image still exists after submission!")
318
- caption = submitted_image.get('caption')
319
- if caption:
320
- print(f" ✓ Caption edited: {caption.get('edited', 'N/A')}")
321
- print(f" ✓ Accuracy: {caption.get('accuracy', 'N/A')}")
322
- else:
323
- print(" ✗ ERROR: Caption not found in submitted image!")
324
  else:
325
- print(" ✗ ERROR: Image was deleted after submission!")
326
- return False
327
-
328
- # Clean up
329
- print("6. Cleaning up...")
330
- delete_response = requests.delete(f'http://localhost:8080/api/images/{image_id}', timeout=10)
331
- print(f" Delete status: {delete_response.status_code}")
332
-
333
  else:
334
- print(f" ERROR: Caption submission failed: {submit_response.text}")
335
  else:
336
- print(f" ✗ ERROR: Caption creation failed: {caption_response.text}")
337
  else:
338
- print(f" ✗ ERROR: Upload failed: {upload_response.text}")
339
 
340
  except Exception as e:
341
- print(f" ✗ ERROR: Deletion logic test failed: {e}")
 
 
342
 
343
  if __name__ == "__main__":
344
  test_database_connection()
 
14
  from app import crud, models
15
 
16
  def test_database_connection():
17
+ """Test basic database connectivity and table creation"""
18
  db = SessionLocal()
19
  try:
20
  print("Testing database connection...")
21
 
22
+ # Test basic query
23
+ sources = db.query(models.Source).all()
24
+ print(f"Found {len(sources)} sources in database")
25
+
26
+ # Test image creation
27
  test_img = models.Images(
 
 
28
  file_key="test_key",
29
+ sha256="test_sha",
30
+ source="OTHER",
31
+ event_type="OTHER",
32
  epsg="4326",
33
+ image_type="crisis_map",
34
+ title="Test Title",
 
 
 
 
 
 
 
 
 
35
  prompt="Test prompt",
36
  model="STUB_MODEL",
37
+ schema_id="[email protected]",
38
  raw_json={"test": True},
39
+ generated="This is a test caption"
 
40
  )
41
 
42
+ db.add(test_img)
43
  db.commit()
44
+ print(f"Created test image with ID: {test_img.image_id}")
45
 
46
  # Clean up
47
  db.delete(test_img)
 
83
  raw_json={"test_crud": True},
84
  text="This is a test CRUD caption"
85
  )
86
+ print(f"CRUD create_caption successful for image: {caption.image_id}")
87
 
88
  # Clean up
89
  db.delete(img)
 
139
 
140
  if caption_response.status_code == 200:
141
  caption_result = caption_response.json()
142
+ caption_id = caption_result['image_id'] # Now using image_id instead of cap_id
143
+ print(f"Caption created successfully! Caption ID: {caption_id}")
144
 
145
  # Step 3: Submit caption via API
146
  print("3. Submitting caption via API...")
147
  submit_data = {
148
+ 'title': 'Test Caption',
149
+ 'edited': 'This is an edited test caption',
150
+ 'accuracy': 85,
151
+ 'context': 90,
152
+ 'usability': 80
153
  }
154
 
155
  submit_response = requests.put(
156
+ f'http://localhost:8080/api/images/{image_id}/caption',
157
  json=submit_data
158
  )
159
  print(f"Submit response status: {submit_response.status_code}")
 
161
  if submit_response.status_code == 200:
162
  print("Caption submitted successfully!")
163
 
164
+ # Verify in database
165
+ print("4. Verifying in database...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  db = SessionLocal()
167
  try:
168
+ db_caption = crud.get_caption(db, image_id)
 
 
 
 
 
 
 
 
 
169
  if db_caption:
170
+ print(f"SUCCESS: Caption found in database for image: {db_caption.image_id}")
171
+ print(f"Title: {db_caption.title}")
172
+ print(f"Edited: {db_caption.edited}")
173
+ print(f"Accuracy: {db_caption.accuracy}")
174
  else:
175
+ print("ERROR: Caption not found in database")
 
 
 
 
 
 
 
176
  finally:
177
  db.close()
 
 
 
 
 
 
178
  else:
179
+ print(f"Caption submission failed: {submit_response.text}")
180
  else:
181
+ print(f"Caption creation failed: {caption_response.text}")
182
  else:
183
+ print(f"Upload failed: {response.text}")
184
 
185
  except Exception as e:
186
+ print(f"Upload flow test failed: {e}")
187
+ import traceback
188
+ traceback.print_exc()
189
 
190
  def test_deletion_logic():
191
+ """Test the deletion logic for images"""
192
+ print("=== Testing Deletion Logic ===")
193
 
194
+ # Create test image data
195
+ test_content = b"test image data for deletion test"
196
+ test_filename = "test_deletion.jpg"
197
+
198
+ # Step 1: Upload image via API
199
+ print("1. Uploading image via API...")
200
+ files = {'file': (test_filename, io.BytesIO(test_content), 'image/jpeg')}
201
  data = {
202
  'source': 'OTHER',
203
+ 'type': 'OTHER',
204
  'countries': ['US'],
205
  'epsg': '4326',
206
  'image_type': 'crisis_map'
207
  }
208
 
209
  try:
210
+ response = requests.post('http://localhost:8080/api/images/', files=files, data=data)
211
+ print(f"Upload response status: {response.status_code}")
212
 
213
+ if response.status_code == 200:
214
+ upload_result = response.json()
215
+ image_id = upload_result['image_id']
216
+ print(f"Upload successful! Image ID: {image_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
+ # Step 2: Create caption via API
219
+ print("2. Creating caption via API...")
220
  caption_data = {
221
+ 'title': 'Test Caption for Deletion',
222
  'prompt': 'Describe this test image'
223
  }
224
 
 
226
  f'http://localhost:8080/api/images/{image_id}/caption',
227
  data=caption_data
228
  )
229
+ print(f"Caption response status: {caption_response.status_code}")
230
 
231
  if caption_response.status_code == 200:
232
+ print("Caption created successfully!")
 
 
233
 
234
+ # Step 3: Test image deletion
235
+ print("3. Testing image deletion...")
236
+ delete_response = requests.delete(
237
+ f'http://localhost:8080/api/images/{image_id}'
 
 
 
 
 
 
 
 
238
  )
239
+ print(f"Delete response status: {delete_response.status_code}")
240
 
241
+ if delete_response.status_code == 200:
242
+ print("Image deleted successfully!")
 
 
 
 
243
 
244
+ # Verify image is completely removed
245
+ print("4. Verifying image deletion...")
246
+ db = SessionLocal()
247
+ try:
248
+ db_image = crud.get_image(db, image_id)
249
+ if db_image:
250
+ print("ERROR: Image still exists when it should have been deleted")
 
 
 
 
 
 
251
  else:
252
+ print("SUCCESS: Image completely removed from database")
253
+ finally:
254
+ db.close()
 
 
 
 
 
255
  else:
256
+ print(f"Image deletion failed: {delete_response.text}")
257
  else:
258
+ print(f"Caption creation failed: {caption_response.text}")
259
  else:
260
+ print(f"Upload failed: {response.text}")
261
 
262
  except Exception as e:
263
+ print(f"Deletion logic test failed: {e}")
264
+ import traceback
265
+ traceback.print_exc()
266
 
267
  if __name__ == "__main__":
268
  test_database_connection()