SCGR commited on
Commit
d25db6b
Β·
1 Parent(s): 050368b

database update

Browse files
frontend/src/App.css CHANGED
@@ -1,8 +1,39 @@
 
 
 
 
 
 
 
1
  #root {
2
- max-width: 1280px;
3
  margin: 0 auto;
4
- padding: 2rem;
5
  text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
7
 
8
  .logo {
 
1
+ html, body {
2
+ margin: 0;
3
+ padding: 0;
4
+ overflow-x: hidden;
5
+ width: 100%;
6
+ }
7
+
8
  #root {
9
+ max-width: 100%;
10
  margin: 0 auto;
11
+ padding: 1rem;
12
  text-align: center;
13
+ min-height: 100vh;
14
+ box-sizing: border-box;
15
+ overflow-x: hidden;
16
+ transform: scale(0.8);
17
+ transform-origin: top center;
18
+ }
19
+
20
+ /* Responsive adjustments for different screen sizes */
21
+ @media (min-width: 640px) {
22
+ #root {
23
+ padding: 1.5rem;
24
+ }
25
+ }
26
+
27
+ @media (min-width: 1024px) {
28
+ #root {
29
+ padding: 2rem;
30
+ }
31
+ }
32
+
33
+ @media (min-width: 1280px) {
34
+ #root {
35
+ max-width: 1280px;
36
+ }
37
  }
38
 
39
  .logo {
frontend/src/App.tsx CHANGED
@@ -1,10 +1,10 @@
1
  import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2
  import RootLayout from './layouts/RootLayout';
3
  import UploadPage from './pages/UploadPage';
4
- import ReviewPage from './pages/ReviewPage';
5
  import AnalyticsPage from './pages/AnalyticsPage';
6
  import ExplorePage from './pages/ExplorePage';
7
  import HelpPage from './pages/HelpPage';
 
8
 
9
  const router = createBrowserRouter([
10
  {
@@ -12,10 +12,10 @@ const router = createBrowserRouter([
12
  children: [
13
  { path: '/', element: <UploadPage /> },
14
  { path: '/upload', element: <UploadPage /> },
15
- { path: '/review/:id', element: <ReviewPage /> },
16
  { path: '/analytics', element: <AnalyticsPage /> },
17
  { path: '/explore', element: <ExplorePage /> },
18
  { path: '/help', element: <HelpPage /> },
 
19
  ],
20
  },
21
  ]);
 
1
  import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2
  import RootLayout from './layouts/RootLayout';
3
  import UploadPage from './pages/UploadPage';
 
4
  import AnalyticsPage from './pages/AnalyticsPage';
5
  import ExplorePage from './pages/ExplorePage';
6
  import HelpPage from './pages/HelpPage';
7
+ import MapDetailPage from './pages/MapDetailPage';
8
 
9
  const router = createBrowserRouter([
10
  {
 
12
  children: [
13
  { path: '/', element: <UploadPage /> },
14
  { path: '/upload', element: <UploadPage /> },
 
15
  { path: '/analytics', element: <AnalyticsPage /> },
16
  { path: '/explore', element: <ExplorePage /> },
17
  { path: '/help', element: <HelpPage /> },
18
+ { path: '/map/:mapId', element: <MapDetailPage /> },
19
  ],
20
  },
21
  ]);
frontend/src/components/HeaderNav.tsx CHANGED
@@ -4,13 +4,13 @@ import {
4
  AnalysisIcon,
5
  SearchLineIcon,
6
  QuestionLineIcon,
 
7
  } from "@ifrc-go/icons";
8
 
9
  /* Style helper for active vs. inactive nav links */
10
  const navLink = ({ isActive }: { isActive: boolean }) =>
11
- `flex items-center gap-1 px-3 py-2 text-sm transition-colors ${
12
- isActive ? "text-ifrcRed font-semibold" : "text-gray-600 hover:text-ifrcRed"
13
- }`;
14
 
15
  /* Put page info in one list so it’s easy to extend */
16
  const navItems = [
@@ -36,19 +36,19 @@ export default function HeaderNav() {
36
 
37
  return (
38
  <header className="bg-white border-b border-ifrcRed/40">
39
- <div className="flex items-center justify-between px-6 py-3">
40
 
41
  {/* ── Logo + title ─────────────────────────── */}
42
- <NavLink to="/" className="flex items-center gap-2" onClick={(e) => handleNavigation(e, "/")}>
43
- <img src="/ifrc-logo.svg" alt="IFRC logo" className="h-6" />
44
- <span className="font-semibold">PromptAid Vision</span>
45
  </NavLink>
46
 
47
  {/* ── Centre nav links ─────────────────────── */}
48
- <nav className="flex gap-6">
49
  {navItems.map(({ to, label, Icon }) => (
50
  <NavLink key={to} to={to} className={navLink} onClick={(e) => handleNavigation(e, to)}>
51
- <Icon className="w-4 h-4" /> {label}
52
  </NavLink>
53
  ))}
54
  </nav>
 
4
  AnalysisIcon,
5
  SearchLineIcon,
6
  QuestionLineIcon,
7
+ GoMainIcon,
8
  } from "@ifrc-go/icons";
9
 
10
  /* Style helper for active vs. inactive nav links */
11
  const navLink = ({ isActive }: { isActive: boolean }) =>
12
+ `flex items-center gap-1 px-4 sm:px-6 py-2 text-xs sm:text-sm transition-colors whitespace-nowrap mx-4 sm:mx-6
13
+ ${isActive ? "text-ifrcRed font-semibold" : "text-gray-600 hover:text-ifrcRed"}`;
 
14
 
15
  /* Put page info in one list so it’s easy to extend */
16
  const navItems = [
 
36
 
37
  return (
38
  <header className="bg-white border-b border-ifrcRed/40">
39
+ <div className="flex items-center justify-between px-2 sm:px-4 py-3 max-w-full overflow-hidden">
40
 
41
  {/* ── Logo + title ─────────────────────────── */}
42
+ <NavLink to="/" className="flex items-center gap-2 min-w-0" onClick={(e) => handleNavigation(e, "/")}>
43
+ <GoMainIcon className="h-6 w-6 flex-shrink-0 text-ifrcRed" />
44
+ <span className="font-semibold text-sm sm:text-base truncate">PromptAid Vision</span>
45
  </NavLink>
46
 
47
  {/* ── Centre nav links ─────────────────────── */}
48
+ <nav className="flex flex-wrap justify-center gap-6">
49
  {navItems.map(({ to, label, Icon }) => (
50
  <NavLink key={to} to={to} className={navLink} onClick={(e) => handleNavigation(e, to)}>
51
+ <Icon className="w-4 h-4" /> <span className="inline">{label}</span>
52
  </NavLink>
53
  ))}
54
  </nav>
frontend/src/pages/ExplorePage.tsx CHANGED
@@ -1,9 +1,126 @@
1
- import { PageContainer, Heading } from '@ifrc-go/ui';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  export default function ExplorePage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  return (
5
- <PageContainer className="py-10 text-center">
6
- <Heading level={2}>Explore Dataset</Heading>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  </PageContainer>
8
  );
9
  }
 
1
+ import { PageContainer, Heading, TextInput, SearchSelectInput, SearchMultiSelectInput } from '@ifrc-go/ui';
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { useNavigate } from 'react-router-dom';
4
+
5
+ interface MapOut {
6
+ image_id: string;
7
+ file_key: string;
8
+ image_url: string;
9
+ source: string;
10
+ type: string;
11
+ epsg: string;
12
+ image_type: string;
13
+ caption?: {
14
+ generated: string;
15
+ };
16
+ }
17
 
18
  export default function ExplorePage() {
19
+ const navigate = useNavigate();
20
+ const [maps, setMaps] = useState<MapOut[]>([]);
21
+ const [search, setSearch] = useState('');
22
+ const [srcFilter, setSrcFilter] = useState('');
23
+ const [catFilter, setCatFilter] = useState('');
24
+ const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
25
+ const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
26
+
27
+ useEffect(() => {
28
+ // Fetch maps
29
+ fetch('/api/images/').then(r => r.json()).then(setMaps);
30
+
31
+ // Fetch lookup data
32
+ fetch('/api/sources').then(r => r.json()).then(setSources);
33
+ fetch('/api/types').then(r => r.json()).then(setTypes);
34
+ }, []);
35
+
36
+ const filtered = useMemo(() => {
37
+ return maps.filter(m => {
38
+ return (
39
+ m.file_key.toLowerCase().includes(search.toLowerCase()) &&
40
+ (!srcFilter || m.source === srcFilter) &&
41
+ (!catFilter || m.type === catFilter)
42
+ );
43
+ });
44
+ }, [maps, search, srcFilter, catFilter]);
45
+
46
+ const sourceOptions = sources.map(s => ({ value: s.s_code, label: s.label }));
47
+ const typeOptions = types.map(t => ({ value: t.t_code, label: t.label }));
48
+
49
  return (
50
+ <PageContainer>
51
+ <Heading level={2}>Explore Examples</Heading>
52
+
53
+ {/* ── Filters Bar ──────────────────────────────── */}
54
+ <div className="mt-4 flex flex-wrap gap-4 items-center">
55
+ <TextInput
56
+ name="search"
57
+ placeholder="Search by filename…"
58
+ value={search}
59
+ onChange={(e) => setSearch(e || '')}
60
+ className="flex-1 min-w-[12rem]"
61
+ />
62
+
63
+ <SearchSelectInput
64
+ name="source"
65
+ placeholder="All Sources"
66
+ options={sourceOptions}
67
+ value={srcFilter || null}
68
+ onChange={(v) => setSrcFilter(v as string || '')}
69
+ keySelector={(o) => o.value}
70
+ labelSelector={(o) => o.label}
71
+ selectedOnTop={false}
72
+ />
73
+
74
+ <SearchMultiSelectInput
75
+ name="type"
76
+ placeholder="All Types"
77
+ options={typeOptions}
78
+ value={catFilter ? [catFilter] : []}
79
+ onChange={(v) => setCatFilter((v as string[])[0] || '')}
80
+ keySelector={(o) => o.value}
81
+ labelSelector={(o) => o.label}
82
+ selectedOnTop={false}
83
+ />
84
+ </div>
85
+
86
+ {/* ── List ─────────────────────────────────────── */}
87
+ <div className="mt-6 space-y-4">
88
+ {filtered.map(m => (
89
+ <div key={m.image_id} className="border rounded-lg p-4 flex gap-4 cursor-pointer" onClick={() => navigate(`/map/${m.image_id}`)}>
90
+ <div className="bg-gray-100 flex items-center justify-center text-gray-400 text-xs overflow-hidden rounded" style={{ width: '120px', height: '80px' }}>
91
+ {m.image_url ? (
92
+ <img
93
+ src={m.image_url}
94
+ alt={m.file_key}
95
+ className="w-full h-full object-cover"
96
+ style={{ imageRendering: 'pixelated' }}
97
+ onError={(e) => {
98
+ // Fallback to placeholder if image fails to load
99
+ const target = e.target as HTMLImageElement;
100
+ target.style.display = 'none';
101
+ target.parentElement!.innerHTML = 'Img';
102
+ }}
103
+ />
104
+ ) : (
105
+ 'Img'
106
+ )}
107
+ </div>
108
+ <div className="flex-1 min-w-0">
109
+ <div className="flex flex-wrap gap-2">
110
+ <span className="px-2 py-1 bg-ifrcRed/10 text-ifrcRed text-xs rounded">{m.source}</span>
111
+ <span className="px-2 py-1 bg-ifrcRed/10 text-ifrcRed text-xs rounded">{m.type}</span>
112
+ </div>
113
+ <p className="mt-2 text-sm text-gray-700 line-clamp-2">
114
+ {m.caption?.generated || 'β€” no caption yet β€”'}
115
+ </p>
116
+ </div>
117
+ </div>
118
+ ))}
119
+
120
+ {!filtered.length && (
121
+ <p className="text-center text-gray-500">No examples found.</p>
122
+ )}
123
+ </div>
124
  </PageContainer>
125
  );
126
  }
frontend/src/pages/MapDetailPage.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PageContainer, Heading, Button } from '@ifrc-go/ui';
2
+ import { useState, useEffect } from 'react';
3
+ import { useParams, useNavigate } from 'react-router-dom';
4
+
5
+ interface MapOut {
6
+ image_id: string;
7
+ file_key: string;
8
+ image_url: string;
9
+ source: string;
10
+ type: string;
11
+ epsg: string;
12
+ image_type: string;
13
+ caption?: {
14
+ generated: string;
15
+ };
16
+ }
17
+
18
+ export default function MapDetailPage() {
19
+ const { mapId } = useParams<{ mapId: string }>();
20
+ const navigate = useNavigate();
21
+ const [map, setMap] = useState<MapOut | null>(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [contributing, setContributing] = useState(false);
25
+
26
+ useEffect(() => {
27
+ if (!mapId) {
28
+ setError('Map ID is required');
29
+ setLoading(false);
30
+ return;
31
+ }
32
+
33
+ // Fetch the specific map
34
+ fetch(`/api/images/${mapId}`)
35
+ .then(response => {
36
+ if (!response.ok) {
37
+ throw new Error('Map not found');
38
+ }
39
+ return response.json();
40
+ })
41
+ .then(data => {
42
+ setMap(data);
43
+ setLoading(false);
44
+ })
45
+ .catch(err => {
46
+ setError(err.message);
47
+ setLoading(false);
48
+ });
49
+ }, [mapId]);
50
+
51
+ const handleContribute = async () => {
52
+ if (!map) return;
53
+
54
+ setContributing(true);
55
+ try {
56
+ // Simulate uploading the current image by creating a new map entry
57
+ const formData = new FormData();
58
+ formData.append('source', map.source);
59
+ formData.append('type', map.type);
60
+ formData.append('epsg', map.epsg);
61
+ formData.append('image_type', map.image_type);
62
+
63
+ // We'll need to fetch the image and create a file from it
64
+ const imageResponse = await fetch(map.image_url);
65
+ const imageBlob = await imageResponse.blob();
66
+ const file = new File([imageBlob], map.file_key, { type: 'image/jpeg' });
67
+ formData.append('file', file);
68
+
69
+ const response = await fetch('/api/images/', {
70
+ method: 'POST',
71
+ body: formData,
72
+ });
73
+
74
+ if (!response.ok) {
75
+ throw new Error('Failed to contribute image');
76
+ }
77
+
78
+ const result = await response.json();
79
+
80
+ // Navigate to the upload page with the new map ID and step 2
81
+ navigate(`/upload?mapId=${result.image_id}&step=2`);
82
+ } catch (err) {
83
+ console.error('Contribution failed:', err);
84
+ alert('Failed to contribute image. Please try again.');
85
+ } finally {
86
+ setContributing(false);
87
+ }
88
+ };
89
+
90
+ if (loading) {
91
+ return (
92
+ <PageContainer>
93
+ <div className="flex items-center justify-center min-h-[400px]">
94
+ <div className="text-gray-500">Loading...</div>
95
+ </div>
96
+ </PageContainer>
97
+ );
98
+ }
99
+
100
+ if (error || !map) {
101
+ return (
102
+ <PageContainer>
103
+ <div className="flex items-center justify-center min-h-[400px]">
104
+ <div className="text-red-500">{error || 'Map not found'}</div>
105
+ </div>
106
+ </PageContainer>
107
+ );
108
+ }
109
+
110
+ return (
111
+ <PageContainer>
112
+ <div className="mb-4">
113
+ <button
114
+ onClick={() => navigate('/explore')}
115
+ className="text-ifrcRed hover:text-ifrcRed/80 mb-4 flex items-center gap-2"
116
+ >
117
+ ← Back to Explore
118
+ </button>
119
+ <Heading level={2}>Map Details</Heading>
120
+ </div>
121
+
122
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
123
+ {/* Image Section */}
124
+ <div className="space-y-4">
125
+ <div className="bg-gray-100 rounded-lg overflow-hidden">
126
+ {map.image_url ? (
127
+ <img
128
+ src={map.image_url}
129
+ alt={map.file_key}
130
+ className="w-full h-auto object-contain"
131
+ style={{ imageRendering: 'pixelated' }}
132
+ />
133
+ ) : (
134
+ <div className="w-full h-64 bg-gray-200 flex items-center justify-center text-gray-400">
135
+ No image available
136
+ </div>
137
+ )}
138
+ </div>
139
+ </div>
140
+
141
+ {/* Details Section */}
142
+ <div className="space-y-6">
143
+ <div>
144
+ <h3 className="text-lg font-semibold mb-2">File Information</h3>
145
+ <div className="space-y-2 text-sm">
146
+ <div><span className="font-medium">File:</span> {map.file_key}</div>
147
+ <div><span className="font-medium">ID:</span> {map.image_id}</div>
148
+ </div>
149
+ </div>
150
+
151
+ <div>
152
+ <h3 className="text-lg font-semibold mb-2">Metadata</h3>
153
+ <div className="flex flex-wrap gap-2">
154
+ <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
155
+ {map.source}
156
+ </span>
157
+ <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
158
+ {map.type}
159
+ </span>
160
+ <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
161
+ {map.epsg}
162
+ </span>
163
+ <span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
164
+ {map.image_type}
165
+ </span>
166
+ </div>
167
+ </div>
168
+
169
+ <div>
170
+ <h3 className="text-lg font-semibold mb-2">Generated Caption</h3>
171
+ <div className="bg-gray-50 p-4 rounded-lg">
172
+ <p className="text-gray-700">
173
+ {map.caption?.generated || 'β€” no caption yet β€”'}
174
+ </p>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ {/* Contribute Section */}
181
+ <div className="mt-8 pt-6 border-t border-gray-200">
182
+ <div className="text-center">
183
+ <p className="text-gray-600 mb-4">
184
+ Want to contribute to this map? Use this image as a starting point for your own analysis.
185
+ </p>
186
+ <Button
187
+ name="contribute"
188
+ onClick={handleContribute}
189
+ disabled={contributing}
190
+ className="bg-ifrcRed hover:bg-ifrcRed/90 text-white px-6 py-2 rounded-lg"
191
+ >
192
+ {contributing ? 'Contributing...' : 'Contribute'}
193
+ </Button>
194
+ </div>
195
+ </div>
196
+ </PageContainer>
197
+ );
198
+ }
frontend/src/pages/ReviewPage.tsx DELETED
@@ -1,33 +0,0 @@
1
- // src/pages/ReviewPage.tsx
2
- import { useParams } from "react-router-dom";
3
- import { useEffect, useState } from "react";
4
-
5
- export default function ReviewPage() {
6
- const { id } = useParams(); // captionId
7
- const [data, setData] = useState<any>(null);
8
- const [draft, setDraft] = useState("");
9
-
10
- useEffect(() => {
11
- (async () => {
12
- const res = await fetch(`/api/captions/${id}`);
13
- const json = await res.json();
14
- setData(json);
15
- setDraft(json.generated || "");
16
- })();
17
- }, [id]);
18
-
19
- if (!data) return <p className="p-6">Loading…</p>;
20
-
21
- return (
22
- <main className="p-6 space-y-6">
23
- <img src={data.imageUrl} className="max-w-full rounded-xl shadow" />
24
- <textarea
25
- value={draft}
26
- onChange={e => setDraft(e.target.value)}
27
- className="w-full border rounded p-3 font-mono" rows={5}
28
- />
29
- {/* sliders for accuracy/context/usability */}
30
- {/* Save button calls PUT /api/captions/{id} */}
31
- </main>
32
- );
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/pages/UploadPage.tsx CHANGED
@@ -9,39 +9,97 @@ import {
9
  UploadCloudLineIcon,
10
  ArrowRightLineIcon,
11
  } from '@ifrc-go/icons';
12
- import { Link, useNavigate } from 'react-router-dom';
13
 
14
  export default function UploadPage() {
15
  const navigate = useNavigate();
 
16
  const [step, setStep] = useState<1 | 2 | 3>(1);
17
  const [preview, setPreview] = useState<string | null>(null);
18
  /* ---------------- local state ----------------- */
19
 
20
- const PH_SOURCE = "_TBD_SOURCE";
21
- const PH_REGION = "_TBD_REGION";
22
- const PH_CATEGORY = "_TBD_CATEGORY";
 
23
 
24
  const [file, setFile] = useState<File | null>(null);
25
  //const [source, setSource] = useState('');
26
- //const [region, setRegion] = useState('');
27
- //const [category, setCategory] = useState('');
28
  const [source, setSource] = useState(PH_SOURCE);
29
- const [region, setRegion] = useState(PH_REGION);
30
- const [category, setCategory] = useState(PH_CATEGORY);
 
31
  const [countries, setCountries] = useState<string[]>([]);
32
 
 
 
 
 
 
 
33
  // Wrapper functions to handle OptionKey to string conversion
34
  const handleSourceChange = (value: any) => setSource(String(value));
35
- const handleRegionChange = (value: any) => setRegion(String(value));
36
- const handleCategoryChange = (value: any) => setCategory(String(value));
 
37
  const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
38
 
 
 
 
 
 
 
 
 
39
  const [captionId, setCaptionId] = useState<string | null>(null);
40
 
41
  const [imageUrl, setImageUrl] = useState<string|null>(null);
42
 
43
  const [draft, setDraft] = useState('');
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  // Handle navigation with confirmation
46
  const handleNavigation = () => {
47
  if (step === 2) {
@@ -111,25 +169,35 @@ export default function UploadPage() {
111
  const fd = new FormData();
112
  fd.append('file', file);
113
  fd.append('source', source);
114
- fd.append('region', region);
115
- fd.append('category', category);
 
116
  countries.forEach((c) => fd.append('countries', c));
117
 
118
  try {
119
  /* 1) upload */
120
- const mapRes = await fetch('/api/maps/', { method: 'POST', body: fd });
121
  const mapJson = await readJsonSafely(mapRes);
122
  if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
123
  setImageUrl(mapJson.image_url);
124
 
125
- const mapIdVal = mapJson.map_id;
126
- if (!mapIdVal) throw new Error('Upload failed: map_id not found');
127
  // setMapId(mapIdVal);
128
 
129
  /* 2) caption */
130
  const capRes = await fetch(
131
- `/api/maps/${mapIdVal}/caption`,
132
- { method: 'POST' },
 
 
 
 
 
 
 
 
 
133
  );
134
  const capJson = await readJsonSafely(capRes);
135
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
@@ -147,7 +215,10 @@ export default function UploadPage() {
147
  /* ------------------------------------------------------------------- */
148
  return (
149
  <PageContainer>
150
- <div className="mx-auto max-w-3xl text-center px-4 py-10" data-step={step}>
 
 
 
151
  {/* Title & intro copy */}
152
  {step === 1 && <>
153
  <Heading level={2}>Upload Your Crisis Map</Heading>
@@ -157,7 +228,7 @@ export default function UploadPage() {
157
  description, then review and rate the result based on your expertise.
158
  </p>
159
  {/* β€œMore »” link */}
160
- <div className="mt-2">
161
  <Link
162
  to="/help"
163
  className="text-ifrcRed text-xs hover:underline flex items-center gap-1"
@@ -172,7 +243,7 @@ export default function UploadPage() {
172
  {/* Drop-zone */}
173
  {step === 1 && (
174
  <div
175
- className="mt-10 border-2 border-dashed border-gray-300 bg-gray-50 rounded-xl p-10 flex flex-col items-center gap-4 hover:bg-gray-100 transition-colors"
176
  onDragOver={(e) => e.preventDefault()}
177
  onDrop={onDrop}
178
  >
@@ -183,36 +254,44 @@ export default function UploadPage() {
183
  Selected file: {file.name}
184
  </p>
185
  ) : (
186
- <>
187
- <p className="text-sm text-gray-600">Drag &amp; Drop a file here</p>
188
- <p className="text-xs text-gray-500">or</p>
189
-
190
- {/* File-picker button */}
191
- <RawFileInput name="file" accept="image/*" onChange={onFileChange}>
192
- <Button name="upload" size={1}>
193
- Upload
194
- </Button>
195
- </RawFileInput>
196
- </>
197
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  </div>
199
  )}
200
 
201
  {/* Generate button */}
202
  {step === 1 && (
203
- <Button
204
- name="generate"
205
- className="mt-8"
206
- disabled={!file}
207
- onClick={handleGenerate}
208
- >
209
- Generate
210
- </Button>
 
211
  )}
212
 
213
  {step === 2 && imageUrl && (
214
  <div className="mt-6 flex justify-center">
215
- <div className="w-full max-w-3xl max-h-80 overflow-hidden bg-red-50">
216
  <img
217
  src={preview || undefined}
218
  alt="Uploaded map preview"
@@ -225,47 +304,44 @@ export default function UploadPage() {
225
  {step === 2 && (
226
  <div className="space-y-10">
227
  {/* ────── METADATA FORM ────── */}
228
- <div className="grid gap-4 text-left sm:grid-cols-2">
229
  <SelectInput
230
  label="Source"
231
  name="source"
232
  value={source}
233
  onChange={handleSourceChange}
234
- options={[
235
- { value: 'UNOSAT', label: 'UNOSAT' },
236
- { value: 'FIELD', label: 'FieldΒ HQ' },
237
- ]}
238
- keySelector={(o) => o.value}
239
  labelSelector={(o) => o.label}
240
  required
241
  />
242
  <SelectInput
243
- label="Region"
244
- name="region"
245
- value={region}
246
- onChange={handleRegionChange}
247
- options={[
248
- { value: 'AFR', label: 'Africa' },
249
- { value: 'AMR', label: 'Americas' },
250
- { value: 'APA', label: 'Asia‑Pacific' },
251
- { value: 'EUR', label: 'Europe' },
252
- { value: 'MENA', label: 'MiddleΒ EastΒ &Β NΒ Africa' },
253
- ]}
254
- keySelector={(o) => o.value}
255
  labelSelector={(o) => o.label}
256
  required
257
  />
258
  <SelectInput
259
- label="Category"
260
- name="category"
261
- value={category}
262
- onChange={handleCategoryChange}
263
- options={[
264
- { value: 'FLOOD', label: 'Flood' },
265
- { value: 'WILDFIRE', label: 'Wildfire' },
266
- { value: 'EARTHQUAKE', label: 'Earthquake' },
267
- ]}
268
- keySelector={(o) => o.value}
 
 
 
 
 
 
269
  labelSelector={(o) => o.label}
270
  required
271
  />
@@ -289,8 +365,8 @@ export default function UploadPage() {
289
  <div className="text-left">
290
  <Heading level={3}>How well did the AI perform on the task?</Heading>
291
  {(['accuracy', 'context', 'usability'] as const).map((k) => (
292
- <div key={k} className="mt-6 flex items-center gap-4">
293
- <label className="block text-sm font-medium capitalize w-28">{k}</label>
294
  <input
295
  type="range"
296
  min={0}
@@ -301,7 +377,7 @@ export default function UploadPage() {
301
  }
302
  className="w-full accent-ifrcRed"
303
  />
304
- <span className="ml-2 w-10 text-right tabular-nums">{scores[k]}</span>
305
  </div>
306
  ))}
307
  </div>
@@ -310,7 +386,7 @@ export default function UploadPage() {
310
  <div className="text-left">
311
  <Heading level={3}>AI‑Generated Caption</Heading>
312
  <textarea
313
- className="w-full border rounded p-3 font-mono mt-2"
314
  rows={5}
315
  value={draft}
316
  onChange={(e) => setDraft(e.target.value)}
@@ -318,29 +394,30 @@ export default function UploadPage() {
318
  </div>
319
 
320
  {/* ────── SUBMIT BUTTON ────── */}
321
- <Button
322
- name="submit"
323
- className="mt-10"
324
- onClick={async () => {
325
- if (!captionId) return alert("No caption to submit");
326
- const body = {
327
- edited: draft,
328
- accuracy: scores.accuracy,
329
- context: scores.context,
330
- usability: scores.usability,
331
- };
332
- const res = await fetch(`/api/captions/${captionId}`, {
333
- method: "PUT",
334
- headers: { "Content-Type": "application/json" },
335
- body: JSON.stringify(body),
336
- });
337
- const json = await readJsonSafely(res);
338
- if (!res.ok) return alert(json.error || "Save failed");
339
- setStep(3);
340
- }}
341
- >
342
- Submit
343
- </Button>
 
344
  </div>
345
  )}
346
 
@@ -349,13 +426,14 @@ export default function UploadPage() {
349
  <div className="text-center space-y-6">
350
  <Heading level={2}>Saved!</Heading>
351
  <p className="text-gray-700">Your caption has been successfully saved.</p>
352
- <Button
353
- name="upload-another"
354
- onClick={resetToStep1}
355
- className="mt-6"
356
- >
357
- Upload Another
358
- </Button>
 
359
  </div>
360
  )}
361
 
 
9
  UploadCloudLineIcon,
10
  ArrowRightLineIcon,
11
  } from '@ifrc-go/icons';
12
+ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
13
 
14
  export default function UploadPage() {
15
  const navigate = useNavigate();
16
+ const [searchParams] = useSearchParams();
17
  const [step, setStep] = useState<1 | 2 | 3>(1);
18
  const [preview, setPreview] = useState<string | null>(null);
19
  /* ---------------- local state ----------------- */
20
 
21
+ const PH_SOURCE = "OTHER";
22
+ const PH_TYPE = "OTHER";
23
+ const PH_EPSG = "OTHER";
24
+ const PH_IMAGE_TYPE = "crisis_map";
25
 
26
  const [file, setFile] = useState<File | null>(null);
27
  //const [source, setSource] = useState('');
28
+ //const [type, setType] = useState('');
 
29
  const [source, setSource] = useState(PH_SOURCE);
30
+ const [type, setType] = useState(PH_TYPE);
31
+ const [epsg, setEpsg] = useState(PH_EPSG);
32
+ const [imageType, setImageType] = useState(PH_IMAGE_TYPE);
33
  const [countries, setCountries] = useState<string[]>([]);
34
 
35
+ // Metadata options from database
36
+ const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
37
+ const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
38
+ const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
39
+ const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
40
+
41
  // Wrapper functions to handle OptionKey to string conversion
42
  const handleSourceChange = (value: any) => setSource(String(value));
43
+ const handleTypeChange = (value: any) => setType(String(value));
44
+ const handleEpsgChange = (value: any) => setEpsg(String(value));
45
+ const handleImageTypeChange = (value: any) => setImageType(String(value));
46
  const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
47
 
48
+ // Fetch metadata options on component mount
49
+ useEffect(() => {
50
+ fetch('/api/sources').then(r => r.json()).then(setSources);
51
+ fetch('/api/types').then(r => r.json()).then(setTypes);
52
+ fetch('/api/spatial-references').then(r => r.json()).then(setSpatialReferences);
53
+ fetch('/api/image-types').then(r => r.json()).then(setImageTypes);
54
+ }, []);
55
+
56
  const [captionId, setCaptionId] = useState<string | null>(null);
57
 
58
  const [imageUrl, setImageUrl] = useState<string|null>(null);
59
 
60
  const [draft, setDraft] = useState('');
61
 
62
+ // Handle URL parameters for direct step 2 navigation
63
+ useEffect(() => {
64
+ const mapId = searchParams.get('mapId');
65
+ const stepParam = searchParams.get('step');
66
+
67
+ if (mapId && stepParam === '2') {
68
+ // Load the map data and start at step 2
69
+ fetch(`/api/images/${mapId}`)
70
+ .then(response => response.json())
71
+ .then(mapData => {
72
+ setImageUrl(mapData.image_url);
73
+ setSource(mapData.source);
74
+ setType(mapData.type);
75
+ setEpsg(mapData.epsg);
76
+ setImageType(mapData.image_type);
77
+
78
+ // Generate caption for the existing map
79
+ return fetch(`/api/images/${mapId}/caption`, {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ },
84
+ body: new URLSearchParams({
85
+ title: 'Generated Caption',
86
+ prompt: 'Describe this crisis map in detail'
87
+ })
88
+ });
89
+ })
90
+ .then(capResponse => capResponse.json())
91
+ .then(capData => {
92
+ setCaptionId(capData.cap_id);
93
+ setDraft(capData.generated);
94
+ setStep(2);
95
+ })
96
+ .catch(err => {
97
+ console.error('Failed to load map data:', err);
98
+ alert('Failed to load map data. Please try again.');
99
+ });
100
+ }
101
+ }, [searchParams]);
102
+
103
  // Handle navigation with confirmation
104
  const handleNavigation = () => {
105
  if (step === 2) {
 
169
  const fd = new FormData();
170
  fd.append('file', file);
171
  fd.append('source', source);
172
+ fd.append('type', type);
173
+ fd.append('epsg', epsg);
174
+ fd.append('image_type', imageType);
175
  countries.forEach((c) => fd.append('countries', c));
176
 
177
  try {
178
  /* 1) upload */
179
+ const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
180
  const mapJson = await readJsonSafely(mapRes);
181
  if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
182
  setImageUrl(mapJson.image_url);
183
 
184
+ const mapIdVal = mapJson.image_id;
185
+ if (!mapIdVal) throw new Error('Upload failed: image_id not found');
186
  // setMapId(mapIdVal);
187
 
188
  /* 2) caption */
189
  const capRes = await fetch(
190
+ `/api/images/${mapIdVal}/caption`,
191
+ {
192
+ method: 'POST',
193
+ headers: {
194
+ 'Content-Type': 'application/x-www-form-urlencoded',
195
+ },
196
+ body: new URLSearchParams({
197
+ title: 'Generated Caption',
198
+ prompt: 'Describe this crisis map in detail'
199
+ })
200
+ },
201
  );
202
  const capJson = await readJsonSafely(capRes);
203
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
 
215
  /* ------------------------------------------------------------------- */
216
  return (
217
  <PageContainer>
218
+ <div
219
+ className="mx-auto max-w-screen-lg text-center px-2 sm:px-4 py-6 sm:py-10 overflow-x-hidden"
220
+ data-step={step}
221
+ >
222
  {/* Title & intro copy */}
223
  {step === 1 && <>
224
  <Heading level={2}>Upload Your Crisis Map</Heading>
 
228
  description, then review and rate the result based on your expertise.
229
  </p>
230
  {/* β€œMore »” link */}
231
+ <div className="mt-2 flex justify-center">
232
  <Link
233
  to="/help"
234
  className="text-ifrcRed text-xs hover:underline flex items-center gap-1"
 
243
  {/* Drop-zone */}
244
  {step === 1 && (
245
  <div
246
+ className="mt-6 sm:mt-10 border-2 border-dashed border-gray-300 bg-gray-50 rounded-xl py-12 px-8 flex flex-col items-center gap-6 hover:bg-gray-100 transition-colors max-w-md mx-auto min-h-[300px] justify-center"
247
  onDragOver={(e) => e.preventDefault()}
248
  onDrop={onDrop}
249
  >
 
254
  Selected file: {file.name}
255
  </p>
256
  ) : (
257
+ <p className="text-sm text-gray-600">Drag &amp; Drop a file here</p>
 
 
 
 
 
 
 
 
 
 
258
  )}
259
+
260
+ {/* File-picker button - always visible */}
261
+ <label className="inline-block cursor-pointer">
262
+ <input
263
+ type="file"
264
+ accept="image/*"
265
+ className="sr-only"
266
+ onChange={e => onFileChange(e.target.files?.[0], "file")}
267
+ />
268
+ <Button
269
+ name="upload"
270
+ size={1}
271
+ onClick={() => (document.querySelector('input[type="file"]') as HTMLInputElement)?.click()}
272
+ >
273
+ {file ? 'Change File' : 'Upload'}
274
+ </Button>
275
+ </label>
276
  </div>
277
  )}
278
 
279
  {/* Generate button */}
280
  {step === 1 && (
281
+ <div className="flex justify-center mt-12">
282
+ <Button
283
+ name="generate"
284
+ disabled={!file}
285
+ onClick={handleGenerate}
286
+ >
287
+ Generate
288
+ </Button>
289
+ </div>
290
  )}
291
 
292
  {step === 2 && imageUrl && (
293
  <div className="mt-6 flex justify-center">
294
+ <div className="w-full max-w-screen-lg max-h-80 overflow-hidden bg-red-50">
295
  <img
296
  src={preview || undefined}
297
  alt="Uploaded map preview"
 
304
  {step === 2 && (
305
  <div className="space-y-10">
306
  {/* ────── METADATA FORM ────── */}
307
+ <div className="grid gap-4 text-left grid-cols-1 lg:grid-cols-2">
308
  <SelectInput
309
  label="Source"
310
  name="source"
311
  value={source}
312
  onChange={handleSourceChange}
313
+ options={sources}
314
+ keySelector={(o) => o.s_code}
 
 
 
315
  labelSelector={(o) => o.label}
316
  required
317
  />
318
  <SelectInput
319
+ label="Type"
320
+ name="type"
321
+ value={type}
322
+ onChange={handleTypeChange}
323
+ options={types}
324
+ keySelector={(o) => o.t_code}
 
 
 
 
 
 
325
  labelSelector={(o) => o.label}
326
  required
327
  />
328
  <SelectInput
329
+ label="EPSG"
330
+ name="epsg"
331
+ value={epsg}
332
+ onChange={handleEpsgChange}
333
+ options={spatialReferences}
334
+ keySelector={(o) => o.epsg}
335
+ labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
336
+ required
337
+ />
338
+ <SelectInput
339
+ label="Image Type"
340
+ name="image_type"
341
+ value={imageType}
342
+ onChange={handleImageTypeChange}
343
+ options={imageTypes}
344
+ keySelector={(o) => o.image_type}
345
  labelSelector={(o) => o.label}
346
  required
347
  />
 
365
  <div className="text-left">
366
  <Heading level={3}>How well did the AI perform on the task?</Heading>
367
  {(['accuracy', 'context', 'usability'] as const).map((k) => (
368
+ <div key={k} className="mt-6 flex items-center gap-2 sm:gap-4">
369
+ <label className="block text-sm font-medium capitalize w-20 sm:w-28 flex-shrink-0">{k}</label>
370
  <input
371
  type="range"
372
  min={0}
 
377
  }
378
  className="w-full accent-ifrcRed"
379
  />
380
+ <span className="ml-2 w-8 sm:w-10 text-right tabular-nums flex-shrink-0">{scores[k]}</span>
381
  </div>
382
  ))}
383
  </div>
 
386
  <div className="text-left">
387
  <Heading level={3}>AI‑Generated Caption</Heading>
388
  <textarea
389
+ className="w-full border rounded p-2 sm:p-3 font-mono mt-2"
390
  rows={5}
391
  value={draft}
392
  onChange={(e) => setDraft(e.target.value)}
 
394
  </div>
395
 
396
  {/* ────── SUBMIT BUTTON ────── */}
397
+ <div className="flex justify-center mt-10">
398
+ <Button
399
+ name="submit"
400
+ onClick={async () => {
401
+ if (!captionId) return alert("No caption to submit");
402
+ const body = {
403
+ edited: draft,
404
+ accuracy: scores.accuracy,
405
+ context: scores.context,
406
+ usability: scores.usability,
407
+ };
408
+ const res = await fetch(`/api/captions/${captionId}`, {
409
+ method: "PUT",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify(body),
412
+ });
413
+ const json = await readJsonSafely(res);
414
+ if (!res.ok) return alert(json.error || "Save failed");
415
+ setStep(3);
416
+ }}
417
+ >
418
+ Submit
419
+ </Button>
420
+ </div>
421
  </div>
422
  )}
423
 
 
426
  <div className="text-center space-y-6">
427
  <Heading level={2}>Saved!</Heading>
428
  <p className="text-gray-700">Your caption has been successfully saved.</p>
429
+ <div className="flex justify-center mt-6">
430
+ <Button
431
+ name="upload-another"
432
+ onClick={resetToStep1}
433
+ >
434
+ Upload Another
435
+ </Button>
436
+ </div>
437
  </div>
438
  )}
439
 
frontend/src/types.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface MapOut {
2
+ map_id: string; // UUID as string
3
+ file_key: string;
4
+ sha256: string;
5
+ source: string;
6
+ region: string;
7
+ category: string;
8
+ caption?: {
9
+ cap_id: string; // UUID as string
10
+ map_id: string; // UUID as string
11
+ generated: string;
12
+ edited?: string;
13
+ accuracy?: number;
14
+ context?: number;
15
+ usability?: number;
16
+ };
17
+ }
package-lock.json DELETED
@@ -1,108 +0,0 @@
1
- {
2
- "name": "PromptAid-Vision",
3
- "lockfileVersion": 3,
4
- "requires": true,
5
- "packages": {
6
- "": {
7
- "devDependencies": {
8
- "prisma": "^6.12.0"
9
- }
10
- },
11
- "node_modules/@prisma/config": {
12
- "version": "6.12.0",
13
- "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.12.0.tgz",
14
- "integrity": "sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg==",
15
- "dev": true,
16
- "license": "Apache-2.0",
17
- "dependencies": {
18
- "jiti": "2.4.2"
19
- }
20
- },
21
- "node_modules/@prisma/debug": {
22
- "version": "6.12.0",
23
- "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.12.0.tgz",
24
- "integrity": "sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ==",
25
- "dev": true,
26
- "license": "Apache-2.0"
27
- },
28
- "node_modules/@prisma/engines": {
29
- "version": "6.12.0",
30
- "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.12.0.tgz",
31
- "integrity": "sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA==",
32
- "dev": true,
33
- "hasInstallScript": true,
34
- "license": "Apache-2.0",
35
- "dependencies": {
36
- "@prisma/debug": "6.12.0",
37
- "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
38
- "@prisma/fetch-engine": "6.12.0",
39
- "@prisma/get-platform": "6.12.0"
40
- }
41
- },
42
- "node_modules/@prisma/engines-version": {
43
- "version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
44
- "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc.tgz",
45
- "integrity": "sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ==",
46
- "dev": true,
47
- "license": "Apache-2.0"
48
- },
49
- "node_modules/@prisma/fetch-engine": {
50
- "version": "6.12.0",
51
- "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.12.0.tgz",
52
- "integrity": "sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag==",
53
- "dev": true,
54
- "license": "Apache-2.0",
55
- "dependencies": {
56
- "@prisma/debug": "6.12.0",
57
- "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
58
- "@prisma/get-platform": "6.12.0"
59
- }
60
- },
61
- "node_modules/@prisma/get-platform": {
62
- "version": "6.12.0",
63
- "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.12.0.tgz",
64
- "integrity": "sha512-nRerTGhTlgyvcBlyWgt8OLNIV7QgJS2XYXMJD1hysorMCuLAjuDDuoxmVt7C2nLxbuxbWPp7OuFRHC23HqD9dA==",
65
- "dev": true,
66
- "license": "Apache-2.0",
67
- "dependencies": {
68
- "@prisma/debug": "6.12.0"
69
- }
70
- },
71
- "node_modules/jiti": {
72
- "version": "2.4.2",
73
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
74
- "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
75
- "dev": true,
76
- "license": "MIT",
77
- "bin": {
78
- "jiti": "lib/jiti-cli.mjs"
79
- }
80
- },
81
- "node_modules/prisma": {
82
- "version": "6.12.0",
83
- "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.12.0.tgz",
84
- "integrity": "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==",
85
- "dev": true,
86
- "hasInstallScript": true,
87
- "license": "Apache-2.0",
88
- "dependencies": {
89
- "@prisma/config": "6.12.0",
90
- "@prisma/engines": "6.12.0"
91
- },
92
- "bin": {
93
- "prisma": "build/index.js"
94
- },
95
- "engines": {
96
- "node": ">=18.18"
97
- },
98
- "peerDependencies": {
99
- "typescript": ">=5.1.0"
100
- },
101
- "peerDependenciesMeta": {
102
- "typescript": {
103
- "optional": true
104
- }
105
- }
106
- }
107
- }
108
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "devDependencies": {
3
- "prisma": "^6.12.0"
4
- }
5
- }
 
 
 
 
 
 
prisma/schema.prisma DELETED
@@ -1,87 +0,0 @@
1
- generator client {
2
- provider = "prisma-client-js"
3
- output = "../generated/prisma"
4
- }
5
-
6
- datasource db {
7
- provider = "postgresql"
8
- url = env("DATABASE_URL")
9
- }
10
-
11
- /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
12
- model captions {
13
- cap_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
14
- map_id String? @unique @db.Uuid
15
- model String
16
- raw_json Json
17
- generated String
18
- edited String?
19
- accuracy Int? @db.SmallInt
20
- context Int? @db.SmallInt
21
- usability Int? @db.SmallInt
22
- created_at DateTime @default(now()) @db.Timestamptz(6)
23
- updated_at DateTime? @db.Timestamptz(6)
24
- maps maps? @relation(fields: [map_id], references: [map_id], onDelete: Cascade, onUpdate: NoAction)
25
- model_captions_modelTomodel model @relation("captions_modelTomodel", fields: [model], references: [m_code], onDelete: NoAction, onUpdate: NoAction)
26
- }
27
-
28
- model category {
29
- cat_code String @id
30
- label String
31
- maps_maps_categoryTocategory maps[] @relation("maps_categoryTocategory")
32
- }
33
-
34
- model country {
35
- c_code String @id @db.Char(2)
36
- label String
37
- map_countries map_countries[]
38
- }
39
-
40
- model goose_db_version {
41
- id Int @id @default(autoincrement())
42
- version_id BigInt
43
- is_applied Boolean
44
- tstamp DateTime @default(now()) @db.Timestamp(6)
45
- }
46
-
47
- model map_countries {
48
- map_id String @db.Uuid
49
- c_code String @db.Char(2)
50
- country country @relation(fields: [c_code], references: [c_code], onDelete: NoAction, onUpdate: NoAction)
51
- maps maps @relation(fields: [map_id], references: [map_id], onDelete: Cascade, onUpdate: NoAction)
52
-
53
- @@id([map_id, c_code])
54
- }
55
-
56
- model maps {
57
- map_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
58
- file_key String
59
- sha256 String
60
- created_at DateTime @default(now()) @db.Timestamptz(6)
61
- source String
62
- region String
63
- category String
64
- captions captions?
65
- map_countries map_countries[]
66
- category_maps_categoryTocategory category @relation("maps_categoryTocategory", fields: [category], references: [cat_code], onDelete: NoAction, onUpdate: NoAction)
67
- region_maps_regionToregion region @relation("maps_regionToregion", fields: [region], references: [r_code], onDelete: NoAction, onUpdate: NoAction)
68
- sources sources @relation(fields: [source], references: [s_code], onDelete: NoAction, onUpdate: NoAction)
69
- }
70
-
71
- model model {
72
- m_code String @id
73
- label String
74
- captions_captions_modelTomodel captions[] @relation("captions_modelTomodel")
75
- }
76
-
77
- model region {
78
- r_code String @id
79
- label String
80
- maps_maps_regionToregion maps[] @relation("maps_regionToregion")
81
- }
82
-
83
- model sources {
84
- s_code String @id
85
- label String
86
- maps maps[]
87
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
py_backend/alembic/versions/0002_seed.py DELETED
@@ -1,87 +0,0 @@
1
- # py_backend/alembic/versions/0002_seed_lookups.py
2
-
3
- """seed lookup tables
4
-
5
- Revision ID: 0002seed
6
- Revises: ad38fd571716
7
- Create Date: 2025-07-25 14:00:00.000000
8
- """
9
-
10
- from alembic import op
11
-
12
- # revision identifiers, used by Alembic.
13
- revision = '0002seed'
14
- down_revision = 'ad38fd571716'
15
- branch_labels = None
16
- depends_on = None
17
-
18
-
19
- def upgrade():
20
- # 1) sources
21
- op.execute("""
22
- INSERT INTO sources (s_code, label) VALUES
23
- ('WFP_ADAM', 'WFP ADAM – Automated Disaster Analysis & Mapping'),
24
- ('PDC', 'Pacific Disaster Center (PDC)'),
25
- ('GDACS', 'GDACS – Global Disaster Alert & Coordination System'),
26
- ('GFL_HUB', 'Google Flood Hub'),
27
- ('GFL_GENCAST', 'Google GenCast'),
28
- ('USGS', 'USGS – United States Geological Survey'),
29
- ('_TBD_SOURCE','TBD placeholder')
30
- ON CONFLICT (s_code) DO NOTHING;
31
- """)
32
-
33
- # 2) region
34
- op.execute("""
35
- INSERT INTO region (r_code, label) VALUES
36
- ('AFR','Africa'),
37
- ('AMR','Americas'),
38
- ('APA','Asia‑Pacific'),
39
- ('EUR','Europe'),
40
- ('MENA','Middleβ€―East & Northβ€―Africa'),
41
- ('_TBD_REGION','TBD placeholder')
42
- ON CONFLICT (r_code) DO NOTHING;
43
- """)
44
-
45
- # 3) category
46
- op.execute("""
47
- INSERT INTO category (cat_code, label) VALUES
48
- ('FLOOD','Flood'),
49
- ('WILDFIRE','Wildfire'),
50
- ('EARTHQUAKE','Earthquake'),
51
- ('CYCLONE','Cyclone'),
52
- ('DROUGHT','Drought'),
53
- ('LANDSLIDE','Landslide'),
54
- ('TORNADO','Tornado'),
55
- ('VOLCANO','Volcano'),
56
- ('OTHER','Other'),
57
- ('_TBD_CATEGORY','TBD placeholder')
58
- ON CONFLICT (cat_code) DO NOTHING;
59
- """)
60
-
61
- # 4) model
62
- op.execute("""
63
- INSERT INTO model (m_code, label) VALUES
64
- ('GPT-4O', 'GPT‑4o Vision'),
65
- ('GEMINI15', 'GeminiΒ 1.5 Pro'),
66
- ('CLAUDE3', 'ClaudeΒ 3 Sonnet'),
67
- ('STUB_MODEL','Stub Captioner')
68
- ON CONFLICT (m_code) DO NOTHING;
69
- """)
70
-
71
- # 5) country (example set; add more ISO‑2 codes as needed)
72
- op.execute("""
73
- INSERT INTO country (c_code, label) VALUES
74
- ('PH','Philippines'),
75
- ('ID','Indonesia'),
76
- ('VN','Vietnam')
77
- ON CONFLICT (c_code) DO NOTHING;
78
- """)
79
-
80
-
81
- def downgrade():
82
- # reverse in roughly the opposite order
83
- op.execute("DELETE FROM country WHERE c_code IN ('PH','ID','VN');")
84
- op.execute("DELETE FROM model WHERE m_code IN ('GPT-4O','GEMINI15','CLAUDE3','STUB_MODEL');")
85
- op.execute("DELETE FROM category WHERE cat_code IN ('FLOOD','WILDFIRE','EARTHQUAKE','CYCLONE','DROUGHT','LANDSLIDE','TORNADO','VOLCANO','OTHER','_TBD_CATEGORY');")
86
- op.execute("DELETE FROM region WHERE r_code IN ('AFR','AMR','APA','EUR','MENA','_TBD_REGION');")
87
- op.execute("DELETE FROM sources WHERE s_code IN ('WFP_ADAM','PDC','GDACS','GFL_HUB','GFL_GENCAST','USGS','_TBD_SOURCE');")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
py_backend/alembic/versions/ad38fd571716_init_schema.py DELETED
@@ -1,113 +0,0 @@
1
- """init schema
2
-
3
- Revision ID: ad38fd571716
4
- Revises:
5
- Create Date: 2025-07-24 15:30:00.000000
6
-
7
- """
8
- from alembic import op
9
- import sqlalchemy as sa
10
-
11
- # revision identifiers, used by Alembic.
12
- revision = 'ad38fd571716'
13
- down_revision = None
14
- branch_labels = None
15
- depends_on = None
16
-
17
-
18
- def upgrade():
19
- # 1) Enable pgcrypto extension for gen_random_uuid()
20
- op.execute('CREATE EXTENSION IF NOT EXISTS "pgcrypto";')
21
-
22
- # 2) Lookup tables
23
- op.create_table(
24
- 'sources',
25
- sa.Column('s_code', sa.Text(), primary_key=True),
26
- sa.Column('label', sa.Text(), nullable=False),
27
- )
28
- op.create_table(
29
- 'region',
30
- sa.Column('r_code', sa.Text(), primary_key=True),
31
- sa.Column('label', sa.Text(), nullable=False),
32
- )
33
- op.create_table(
34
- 'category',
35
- sa.Column('cat_code', sa.Text(), primary_key=True),
36
- sa.Column('label', sa.Text(), nullable=False),
37
- )
38
- op.create_table(
39
- 'country',
40
- sa.Column('c_code', sa.CHAR(length=2), primary_key=True),
41
- sa.Column('label', sa.Text(), nullable=False),
42
- )
43
- op.create_table(
44
- 'model',
45
- sa.Column('m_code', sa.Text(), primary_key=True),
46
- sa.Column('label', sa.Text(), nullable=False),
47
- )
48
-
49
- # 3) maps table
50
- op.create_table(
51
- 'maps',
52
- sa.Column('map_id', sa.UUID(),
53
- server_default=sa.text('gen_random_uuid()'),
54
- primary_key=True),
55
- sa.Column('file_key', sa.Text(), nullable=False),
56
- sa.Column('sha256', sa.Text(), nullable=False),
57
- sa.Column('source', sa.Text(), nullable=False),
58
- sa.Column('region', sa.Text(), nullable=False),
59
- sa.Column('category', sa.Text(), nullable=False),
60
- sa.Column('created_at',sa.TIMESTAMP(timezone=True),
61
- server_default=sa.text('NOW()'),
62
- nullable=False),
63
- sa.ForeignKeyConstraint(['source'], ['sources.s_code']),
64
- sa.ForeignKeyConstraint(['region'], ['region.r_code']),
65
- sa.ForeignKeyConstraint(['category'], ['category.cat_code']),
66
- )
67
-
68
- # 4) map_countries join table
69
- op.create_table(
70
- 'map_countries',
71
- sa.Column('map_id', sa.UUID(), nullable=False),
72
- sa.Column('c_code', sa.CHAR(length=2), nullable=False),
73
- sa.PrimaryKeyConstraint('map_id', 'c_code'),
74
- sa.ForeignKeyConstraint(['map_id'], ['maps.map_id'], ondelete='CASCADE'),
75
- sa.ForeignKeyConstraint(['c_code'], ['country.c_code']),
76
- )
77
-
78
- # 5) captions table
79
- op.create_table(
80
- 'captions',
81
- sa.Column('cap_id', sa.UUID(),
82
- server_default=sa.text('gen_random_uuid()'),
83
- primary_key=True),
84
- sa.Column('map_id', sa.UUID(), nullable=False, unique=True),
85
- sa.Column('model', sa.Text(), nullable=False),
86
- sa.Column('raw_json', sa.JSON(), nullable=False),
87
- sa.Column('generated', sa.Text(), nullable=False),
88
- sa.Column('edited', sa.Text(), nullable=True),
89
- sa.Column('accuracy', sa.SmallInteger(),
90
- sa.CheckConstraint('accuracy BETWEEN 0 AND 100')),
91
- sa.Column('context', sa.SmallInteger(),
92
- sa.CheckConstraint('context BETWEEN 0 AND 100')),
93
- sa.Column('usability', sa.SmallInteger(),
94
- sa.CheckConstraint('usability BETWEEN 0 AND 100')),
95
- sa.Column('created_at',sa.TIMESTAMP(timezone=True),
96
- server_default=sa.text('NOW()'),
97
- nullable=False),
98
- sa.Column('updated_at',sa.TIMESTAMP(timezone=True), nullable=True),
99
- sa.ForeignKeyConstraint(['map_id'], ['maps.map_id'], ondelete='CASCADE'),
100
- sa.ForeignKeyConstraint(['model'], ['model.m_code']),
101
- )
102
-
103
-
104
- def downgrade():
105
- # drop in reverse order to respect FKs
106
- op.drop_table('captions')
107
- op.drop_table('map_countries')
108
- op.drop_table('maps')
109
- op.drop_table('model')
110
- op.drop_table('country')
111
- op.drop_table('category')
112
- op.drop_table('region')
113
- op.drop_table('sources')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema + full dynamic country seed
2
+
3
+ Revision ID: 0001_initial_schema_and_seed
4
+ Revises:
5
+ Create Date: 2025-08-01 20:00:00.000000
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.dialects import postgresql
11
+ import pycountry
12
+
13
+ # revision identifiers, used by Alembic.
14
+ revision = '0001_initial_schema_and_seed'
15
+ down_revision = None
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+ def _guess_region(alpha2: str) -> str:
20
+ # Rough continent→region buckets; tweak as needed
21
+ AFR = {'DZ','AO','BJ','BW','BF','BI','CM','CV','CF','TD','KM','CG','CD','CI','DJ','EG',
22
+ 'GQ','ER','SZ','ET','GA','GM','GH','GN','GW','KE','LS','LR','LY','MG','MW','ML',
23
+ 'MR','MU','YT','MA','MZ','NA','NE','NG','RE','RW','SH','ST','SN','SC','SL','SO',
24
+ 'ZA','SS','SD','TZ','TG','TN','UG','EH','ZM','ZW'}
25
+ AMR = {'US','CA','MX','BR','AR','CO','PE','VE','CL','EC','GT','CU','BO','DO','HT','HN',
26
+ 'PY','NI','SV','CR','PA','UY','JM','TT','GY','SR','BZ','KY','AG','BS','BB','BM',
27
+ 'DM','GD','GP','HT','MQ','MS','PR','KN','LC','VC','SX','TC','VI'}
28
+ EUR = {'AL','AD','AT','BY','BE','BA','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GI',
29
+ 'GR','HU','IS','IE','IT','XK','LV','LI','LT','LU','MT','MD','MC','ME','NL','MK',
30
+ 'NO','PL','PT','RO','RU','SM','RS','SK','SI','ES','SE','CH','TR','UA','GB','VA'}
31
+ MENA = {'DZ','BH','EG','IR','IQ','IL','JO','KW','LB','LY','MA','OM','QA','SA','SY','TN',
32
+ 'AE','YE','PS','SD','EH'}
33
+ # If it’s in MENA, call it MENA; else AFR-only if in AFR set; else AMR, EUR, else APA
34
+ if alpha2 in MENA:
35
+ return 'MENA'
36
+ if alpha2 in (AFR - MENA):
37
+ return 'AFR'
38
+ if alpha2 in AMR:
39
+ return 'AMR'
40
+ if alpha2 in EUR:
41
+ return 'EUR'
42
+ return 'APA'
43
+
44
+
45
+ def upgrade():
46
+ # allow uuid generation
47
+ op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
48
+
49
+ #
50
+ # 1) lookup tables
51
+ #
52
+ op.create_table(
53
+ 'sources',
54
+ sa.Column('s_code', sa.String(), primary_key=True),
55
+ sa.Column('label', sa.String(), nullable=False),
56
+ )
57
+ op.create_table(
58
+ 'regions',
59
+ sa.Column('r_code', sa.String(), primary_key=True),
60
+ sa.Column('label', sa.String(), nullable=False),
61
+ )
62
+ op.create_table(
63
+ 'types',
64
+ sa.Column('t_code', sa.String(), primary_key=True),
65
+ sa.Column('label', sa.String(), nullable=False),
66
+ )
67
+ op.create_table(
68
+ 'countries',
69
+ sa.Column('c_code', sa.CHAR(length=2), primary_key=True),
70
+ sa.Column('label', sa.String(), nullable=False),
71
+ sa.Column('r_code', sa.String(), sa.ForeignKey('regions.r_code'), nullable=False),
72
+ )
73
+ op.create_table(
74
+ 'spatial_references',
75
+ sa.Column('epsg', sa.String(), primary_key=True),
76
+ sa.Column('srid', sa.String(), nullable=False),
77
+ sa.Column('proj4', sa.String(), nullable=False),
78
+ sa.Column('wkt', sa.String(), nullable=False),
79
+ )
80
+ op.create_table(
81
+ 'image_types',
82
+ sa.Column('image_type', sa.String(), primary_key=True),
83
+ sa.Column('label', sa.String(), nullable=False),
84
+ )
85
+ op.create_table(
86
+ 'models',
87
+ sa.Column('m_code', sa.String(), primary_key=True),
88
+ sa.Column('label', sa.String(), nullable=False),
89
+ )
90
+
91
+ #
92
+ # 2) seed lookup tables
93
+ #
94
+ # β€” sources β€”
95
+ op.execute("""
96
+ INSERT INTO sources (s_code,label) VALUES
97
+ ('PDC','PDC'),
98
+ ('GDACS','GDACS'),
99
+ ('WFP','WFP ADAM'),
100
+ ('GFH','Google Flood Hub'),
101
+ ('GGC','Google GenCast'),
102
+ ('USGS','USGS'),
103
+ ('OTHER','Other')
104
+ """)
105
+ # β€” region β€”
106
+ op.execute("""
107
+ INSERT INTO regions (r_code,label) VALUES
108
+ ('AFR','Africa'),
109
+ ('AMR','Americas'),
110
+ ('APA','Asia-Pacific'),
111
+ ('EUR','Europe'),
112
+ ('MENA','Middle East & N Africa')
113
+ """)
114
+ # β€” Type β€”
115
+ op.execute("""
116
+ INSERT INTO types (t_code,label) VALUES
117
+ ('FLOOD','Flood'),
118
+ ('FIRE','Fire'),
119
+ ('EARTHQUAKE','Earthquake'),
120
+ ('CYCLONE','Cyclone'),
121
+ ('TSUNAMI','Tsunami'),
122
+ ('POPULATION_MOVEMENT','Population Movement'),
123
+ ('EPIDEMIC','Epidemic'),
124
+ ('PLUVIAL','Pluvial'),
125
+ ('STORM','Storm'),
126
+ ('LANDSLIDE','Landslide'),
127
+ ('COLD_WAVE','Cold Wave'),
128
+ ('BIOLOGICAL_EMERGENCY','Biological Emergency'),
129
+ ('CHEMICAL_EMERGENCY','Chemical Emergency'),
130
+ ('CIVIL_UNREST','Civil Unrest'),
131
+ ('COMPLEX_EMERGENCY','Complex Emergency'),
132
+ ('DROUGHT','Drought'),
133
+ ('FLOOD_INSECURITY','Flood Insecurity'),
134
+ ('HEAT_WAVE','Heat Wave'),
135
+ ('INSECT_INFESTATION','Insect Infestation'),
136
+ ('RADIOLOGICAL_EMERGENCY','Radiological Emergency'),
137
+ ('TRANSPORTATION_EMERGENCY','Transportation Emergency'),
138
+ ('VOLCANIC_ERUPTION','Volcanic Eruption'),
139
+ ('OTHER','Other')
140
+ """)
141
+
142
+ # seed only the most common CRSs + an οΏ½οΏ½οΏ½Other” placeholder
143
+ op.execute("""
144
+ INSERT INTO spatial_references (epsg, srid, proj4, wkt) VALUES
145
+ ('4326','4326',
146
+ '+proj=longlat +datum=WGS84 +no_defs',
147
+ 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]'
148
+ ),
149
+ ('3857','3857',
150
+ '+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs',
151
+ 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]]'
152
+ ),
153
+ ('32633','32633',
154
+ '+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs',
155
+ 'PROJCS["WGS 84 / UTM zone 33N",GEOGCS["WGS 84",…]]'
156
+ ),
157
+ ('32634','32634',
158
+ '+proj=utm +zone=34 +datum=WGS84 +units=m +no_defs',
159
+ 'PROJCS["WGS 84 / UTM zone 34N",GEOGCS["WGS 84",…]]'
160
+ ),
161
+ ('OTHER','OTHER',
162
+ '',
163
+ 'Other'
164
+ );
165
+ """)
166
+ # β€” image_type β€”
167
+ op.execute("""
168
+ INSERT INTO image_types (image_type,label) VALUES
169
+ ('crisis_map','Crisis Map'),
170
+ ('drone_image','Drone Image')
171
+ """)
172
+ # β€” model β€”
173
+ op.execute("""
174
+ INSERT INTO models (m_code,label) VALUES
175
+ ('GPT-4O','GPT-4O'),
176
+ ('GEMINI15','Gemini 1.5'),
177
+ ('CLAUDE3','Claude 3'),
178
+ ('STUB_MODEL','<stub>')
179
+ """)
180
+
181
+ # β€” country: full ISO-3166 seed via pycountry + auto region guess β€”
182
+ for c in pycountry.countries:
183
+ code = c.alpha_2
184
+ name = c.name.replace("'", "''")
185
+ region = _guess_region(code)
186
+ op.execute(
187
+ f"INSERT INTO countries (c_code,label,r_code) "
188
+ f"VALUES ('{code}','{name}','{region}')"
189
+ )
190
+
191
+ #
192
+ # 3) core tables
193
+ #
194
+ op.create_table(
195
+ 'images',
196
+ sa.Column('image_id', postgresql.UUID(as_uuid=True),
197
+ server_default=sa.text('gen_random_uuid()'),
198
+ primary_key=True),
199
+ sa.Column('file_key', sa.String(), nullable=False),
200
+ sa.Column('sha256', sa.String(), nullable=False),
201
+ sa.Column('source', sa.String(), sa.ForeignKey('sources.s_code'), nullable=False),
202
+ sa.Column('type', sa.String(), sa.ForeignKey('types.t_code'), nullable=False),
203
+ sa.Column('epsg', sa.String(), sa.ForeignKey('spatial_references.epsg'), nullable=False),
204
+ sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
205
+ sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
206
+ )
207
+ op.create_table(
208
+ 'image_countries',
209
+ sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
210
+ sa.Column('c_code', sa.CHAR(length=2), nullable=False),
211
+ sa.PrimaryKeyConstraint('image_id','c_code'),
212
+ sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
213
+ sa.ForeignKeyConstraint(['c_code'], ['countries.c_code']),
214
+ )
215
+ op.create_table(
216
+ 'captions',
217
+ sa.Column('cap_id', postgresql.UUID(as_uuid=True),
218
+ server_default=sa.text('gen_random_uuid()'),
219
+ primary_key=True),
220
+ sa.Column('image_id', postgresql.UUID(as_uuid=True),
221
+ sa.ForeignKey('images.image_id', ondelete='CASCADE'),
222
+ unique=True, nullable=False),
223
+ sa.Column('title', sa.String(), nullable=False),
224
+ sa.Column('prompt', sa.String(), nullable=False),
225
+ sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=False),
226
+ sa.Column('raw_json', sa.JSON(), nullable=False),
227
+ sa.Column('generated', sa.String(), nullable=False),
228
+ sa.Column('edited', sa.String(), nullable=True),
229
+ sa.Column('accuracy', sa.SmallInteger()),
230
+ sa.Column('context', sa.SmallInteger()),
231
+ sa.Column('usability', sa.SmallInteger()),
232
+ sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
233
+ sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
234
+ sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
235
+ )
236
+
237
+
238
+ def downgrade():
239
+ op.drop_table('captions')
240
+ op.drop_table('image_countries')
241
+ op.drop_table('images')
242
+ op.drop_table('models')
243
+ op.drop_table('image_types')
244
+ op.drop_table('spatial_references')
245
+ op.drop_table('countries')
246
+ op.drop_table('types')
247
+ op.drop_table('regions')
248
+ op.drop_table('sources')
py_backend/app/config.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseSettings
2
 
3
  class Settings(BaseSettings):
4
  DATABASE_URL: str
 
1
+ from pydantic_settings import BaseSettings
2
 
3
  class Settings(BaseSettings):
4
  DATABASE_URL: str
py_backend/app/crud.py CHANGED
@@ -1,36 +1,47 @@
1
  import io, hashlib
2
- from sqlalchemy.orm import Session
3
  from . import models
4
 
5
  def hash_bytes(data: bytes) -> str:
6
  """Compute SHA‑256 hex digest of the data."""
7
  return hashlib.sha256(data).hexdigest()
8
 
9
- def create_map(db: Session, src, reg, cat, key, sha, countries: list[str]):
10
- """Insert into maps and map_countries."""
11
- m = models.Map(
12
- source=src, region=reg, category=cat,
13
- file_key=key, sha256=sha
14
  )
15
- db.add(m)
16
- db.flush() # assign m.map_id
17
 
18
  # link countries
19
  for c in countries:
20
  country = db.get(models.Country, c)
21
  if country:
22
- m.countries.append(country)
23
 
24
  db.commit()
25
- db.refresh(m)
26
- return m
27
 
28
- def get_map(db: Session, map_id):
29
- return db.get(models.Map, map_id)
 
 
 
30
 
31
- def create_caption(db: Session, map_id, model_code, raw_json, text):
32
- c = models.Caption(
33
- map_id=map_id,
 
 
 
 
 
 
 
 
34
  model=model_code,
35
  raw_json=raw_json,
36
  generated=text
@@ -41,16 +52,44 @@ def create_caption(db: Session, map_id, model_code, raw_json, text):
41
  return c
42
 
43
  def get_caption(db: Session, cap_id):
44
- return db.get(models.Caption, cap_id)
45
 
46
- def update_caption(db: Session, cap_id, edited, accuracy, context, usability):
47
  c = get_caption(db, cap_id)
48
  if not c:
49
  return None
50
- c.edited = edited
51
- c.accuracy = accuracy
52
- c.context = context
53
- c.usability = usability
 
 
 
 
 
 
 
 
54
  db.commit()
55
  db.refresh(c)
56
  return c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import io, hashlib
2
+ from sqlalchemy.orm import Session, joinedload
3
  from . import models
4
 
5
  def hash_bytes(data: bytes) -> str:
6
  """Compute SHA‑256 hex digest of the data."""
7
  return hashlib.sha256(data).hexdigest()
8
 
9
+ def create_image(db: Session, src, type_code, key, sha, countries: list[str], epsg: str, image_type: str):
10
+ """Insert into images and image_countries."""
11
+ img = models.Images(
12
+ source=src, type=type_code,
13
+ file_key=key, sha256=sha, epsg=epsg, image_type=image_type
14
  )
15
+ db.add(img)
16
+ db.flush() # assign img.image_id
17
 
18
  # link countries
19
  for c in countries:
20
  country = db.get(models.Country, c)
21
  if country:
22
+ img.countries.append(country)
23
 
24
  db.commit()
25
+ db.refresh(img)
26
+ return img
27
 
28
+ def get_images(db: Session):
29
+ """Get all images with their captions"""
30
+ return db.query(models.Images).options(
31
+ joinedload(models.Images.caption)
32
+ ).all()
33
 
34
+ def get_image(db: Session, image_id: str):
35
+ """Get a single image by ID with its caption"""
36
+ return db.query(models.Images).options(
37
+ joinedload(models.Images.caption)
38
+ ).filter(models.Images.image_id == image_id).first()
39
+
40
+ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text):
41
+ c = models.Captions(
42
+ image_id=image_id,
43
+ title=title,
44
+ prompt=prompt,
45
  model=model_code,
46
  raw_json=raw_json,
47
  generated=text
 
52
  return c
53
 
54
  def get_caption(db: Session, cap_id):
55
+ return db.get(models.Captions, cap_id)
56
 
57
+ def update_caption(db: Session, cap_id, edited=None, accuracy=None, context=None, usability=None, starred=None):
58
  c = get_caption(db, cap_id)
59
  if not c:
60
  return None
61
+
62
+ if edited is not None:
63
+ c.edited = edited
64
+ if accuracy is not None:
65
+ c.accuracy = accuracy
66
+ if context is not None:
67
+ c.context = context
68
+ if usability is not None:
69
+ c.usability = usability
70
+ if starred is not None:
71
+ c.starred = starred
72
+
73
  db.commit()
74
  db.refresh(c)
75
  return c
76
+
77
+ def get_sources(db: Session):
78
+ """Get all sources for lookup"""
79
+ return db.query(models.Source).all()
80
+
81
+ def get_regions(db: Session):
82
+ """Get all regions for lookup"""
83
+ return db.query(models.Region).all()
84
+
85
+ def get_types(db: Session):
86
+ """Get all types for lookup"""
87
+ return db.query(models.Type).all()
88
+
89
+ def get_spatial_references(db: Session):
90
+ """Get all spatial references for lookup"""
91
+ return db.query(models.SpatialReference).all()
92
+
93
+ def get_image_types(db: Session):
94
+ """Get all image types for lookup"""
95
+ return db.query(models.ImageTypes).all()
py_backend/app/main.py CHANGED
@@ -21,7 +21,7 @@ app.add_middleware(
21
  )
22
 
23
  # Mount routers
24
- app.include_router(upload.router, prefix="/api/maps", tags=["maps"])
25
  app.include_router(caption.router, prefix="/api", tags=["captions"])
26
  app.include_router(metadata.router, prefix="/api", tags=["metadata"])
27
 
 
21
  )
22
 
23
  # Mount routers
24
+ app.include_router(upload.router, prefix="/api/images", tags=["images"])
25
  app.include_router(caption.router, prefix="/api", tags=["captions"])
26
  app.include_router(metadata.router, prefix="/api", tags=["metadata"])
27
 
py_backend/app/models.py CHANGED
@@ -1,16 +1,25 @@
1
  from sqlalchemy import (
2
- Column, String, DateTime, JSON, SmallInteger, Table, ForeignKey
3
  )
4
  from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR
5
  from sqlalchemy.orm import relationship
6
  import datetime, uuid
7
  from .database import Base
8
 
9
- # association table maps ↔ countries
10
- map_countries = Table(
11
- "map_countries", Base.metadata,
12
- Column("map_id", UUID(as_uuid=True), ForeignKey("maps.map_id", ondelete="CASCADE")),
13
- Column("c_code", CHAR(2), ForeignKey("country.c_code")),
 
 
 
 
 
 
 
 
 
14
  )
15
 
16
  class Source(Base):
@@ -19,50 +28,67 @@ class Source(Base):
19
  label = Column(String, nullable=False)
20
 
21
  class Region(Base):
22
- __tablename__ = "region"
23
  r_code = Column(String, primary_key=True)
24
  label = Column(String, nullable=False)
25
 
26
- class Category(Base):
27
- __tablename__ = "category"
28
- cat_code = Column(String, primary_key=True)
29
  label = Column(String, nullable=False)
30
 
31
  class Country(Base):
32
- __tablename__ = "country"
33
  c_code = Column(CHAR(2), primary_key=True)
34
  label = Column(String, nullable=False)
 
35
 
36
- class ModelLookup(Base):
37
- __tablename__ = "model"
 
 
 
 
 
 
 
 
 
 
 
 
38
  m_code = Column(String, primary_key=True)
39
  label = Column(String, nullable=False)
40
 
41
- class Map(Base):
42
- __tablename__ = "maps"
43
- map_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
44
  file_key = Column(String, nullable=False)
45
  sha256 = Column(String, nullable=False)
46
  source = Column(String, ForeignKey("sources.s_code"), nullable=False)
47
- region = Column(String, ForeignKey("region.r_code"), nullable=False)
48
- category = Column(String, ForeignKey("category.cat_code"), nullable=False)
49
  created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
 
50
 
51
- countries = relationship("Country", secondary=map_countries)
52
- caption = relationship("Caption", uselist=False, back_populates="map")
53
 
54
- class Caption(Base):
55
  __tablename__ = "captions"
56
  cap_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
57
- map_id = Column(UUID(as_uuid=True), ForeignKey("maps.map_id", ondelete="CASCADE"), unique=True)
58
- model = Column(String, ForeignKey("model.m_code"), nullable=False)
 
 
59
  raw_json = Column(JSON, nullable=False)
60
  generated = Column(String, nullable=False)
61
  edited = Column(String)
62
  accuracy = Column(SmallInteger)
63
  context = Column(SmallInteger)
64
  usability = Column(SmallInteger)
 
65
  created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
66
  updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
67
 
68
- map = relationship("Map", back_populates="caption")
 
1
  from sqlalchemy import (
2
+ Column, String, DateTime, JSON, SmallInteger, Table, ForeignKey, Boolean
3
  )
4
  from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR
5
  from sqlalchemy.orm import relationship
6
  import datetime, uuid
7
  from .database import Base
8
 
9
+ image_countries = Table(
10
+ "image_countries", Base.metadata,
11
+ Column(
12
+ "image_id",
13
+ UUID(as_uuid=True),
14
+ ForeignKey("images.image_id", ondelete="CASCADE"),
15
+ primary_key=True,
16
+ ),
17
+ Column(
18
+ "c_code",
19
+ CHAR(2),
20
+ ForeignKey("countries.c_code"),
21
+ primary_key=True,
22
+ ),
23
  )
24
 
25
  class Source(Base):
 
28
  label = Column(String, nullable=False)
29
 
30
  class Region(Base):
31
+ __tablename__ = "regions"
32
  r_code = Column(String, primary_key=True)
33
  label = Column(String, nullable=False)
34
 
35
+ class Type(Base):
36
+ __tablename__ = "types"
37
+ t_code = Column(String, primary_key=True)
38
  label = Column(String, nullable=False)
39
 
40
  class Country(Base):
41
+ __tablename__ = "countries"
42
  c_code = Column(CHAR(2), primary_key=True)
43
  label = Column(String, nullable=False)
44
+ r_code = Column(String, ForeignKey("regions.r_code"), nullable=False)
45
 
46
+ class SpatialReference(Base):
47
+ __tablename__ = "spatial_references"
48
+ epsg = Column(String, primary_key=True)
49
+ srid = Column(String, nullable=False)
50
+ proj4 = Column(String, nullable=False)
51
+ wkt = Column(String, nullable=False)
52
+
53
+ class ImageTypes(Base):
54
+ __tablename__ = "image_types"
55
+ image_type = Column(String, primary_key=True)
56
+ label = Column(String, nullable=False)
57
+
58
+ class Models(Base):
59
+ __tablename__ = "models"
60
  m_code = Column(String, primary_key=True)
61
  label = Column(String, nullable=False)
62
 
63
+ class Images(Base):
64
+ __tablename__ = "images"
65
+ image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
66
  file_key = Column(String, nullable=False)
67
  sha256 = Column(String, nullable=False)
68
  source = Column(String, ForeignKey("sources.s_code"), nullable=False)
69
+ type = Column(String, ForeignKey("types.t_code"), nullable=False)
70
+ epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
71
  created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
72
+ image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
73
 
74
+ countries = relationship("Country", secondary=image_countries, backref="images")
75
+ caption = relationship("Captions", uselist=False, back_populates="image")
76
 
77
+ class Captions(Base):
78
  __tablename__ = "captions"
79
  cap_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
80
+ image_id = Column(UUID(as_uuid=True), ForeignKey("images.image_id", ondelete="CASCADE"), unique=True)
81
+ title = Column(String, nullable=False)
82
+ prompt = Column(String, nullable=False)
83
+ model = Column(String, ForeignKey("models.m_code"), nullable=False)
84
  raw_json = Column(JSON, nullable=False)
85
  generated = Column(String, nullable=False)
86
  edited = Column(String)
87
  accuracy = Column(SmallInteger)
88
  context = Column(SmallInteger)
89
  usability = Column(SmallInteger)
90
+ starred = Column(Boolean, default=False)
91
  created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
92
  updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
93
 
94
+ image = relationship("Images", back_populates="caption")
py_backend/app/routers/caption.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import APIRouter, HTTPException, Depends
2
  from sqlalchemy.orm import Session
3
  from .. import crud, database, schemas, storage
4
  import io
@@ -22,25 +22,44 @@ class CaptionerStub:
22
 
23
  cap = CaptionerStub()
24
 
25
- @router.post("/maps/{map_id}/caption", response_model=schemas.CaptionOut)
26
- def create_caption(map_id: str, db: Session = Depends(get_db)):
27
- m = crud.get_map(db, map_id)
28
- if not m:
29
- raise HTTPException(404, "map not found")
 
 
 
 
 
30
 
31
- # fetch image bytes from S3
32
- url = storage.generate_presigned_url(m.file_key)
33
- # (in real code, you might stream from S3 directly)
34
- import requests
35
- resp = requests.get(url)
36
- img_bytes = resp.content
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- # generate caption
39
- text, model, raw = cap.generate(img_bytes)
40
 
41
- # insert into DB
42
- c = crud.create_caption(db, map_id, model, raw, text)
43
- return c
 
 
 
44
 
45
  @router.get("/captions/{cap_id}", response_model=schemas.CaptionOut)
46
  def get_caption(cap_id: str, db: Session = Depends(get_db)):
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Form
2
  from sqlalchemy.orm import Session
3
  from .. import crud, database, schemas, storage
4
  import io
 
22
 
23
  cap = CaptionerStub()
24
 
25
+ @router.post("/images/{image_id}/caption", response_model=schemas.CaptionOut)
26
+ def create_caption(
27
+ image_id: str,
28
+ title: str = Form(...),
29
+ prompt: str = Form(...),
30
+ db: Session = Depends(get_db)
31
+ ):
32
+ img = crud.get_image(db, image_id)
33
+ if not img:
34
+ raise HTTPException(404, "image not found")
35
 
36
+ try:
37
+ # fetch image bytes from S3
38
+ url = storage.generate_presigned_url(img.file_key)
39
+
40
+ # Try requests first, fallback to httpx
41
+ try:
42
+ import requests
43
+ resp = requests.get(url)
44
+ resp.raise_for_status()
45
+ img_bytes = resp.content
46
+ except ImportError:
47
+ # Fallback to httpx if requests is not available
48
+ import httpx
49
+ with httpx.Client() as client:
50
+ resp = client.get(url)
51
+ resp.raise_for_status()
52
+ img_bytes = resp.content
53
 
54
+ # generate caption
55
+ text, model, raw = cap.generate(img_bytes)
56
 
57
+ # insert into DB
58
+ c = crud.create_caption(db, image_id, title, prompt, model, raw, text)
59
+ return c
60
+
61
+ except Exception as e:
62
+ raise HTTPException(500, f"Failed to generate caption: {str(e)}")
63
 
64
  @router.get("/captions/{cap_id}", response_model=schemas.CaptionOut)
65
  def get_caption(cap_id: str, db: Session = Depends(get_db)):
py_backend/app/routers/metadata.py CHANGED
@@ -1,5 +1,6 @@
1
  from fastapi import APIRouter, HTTPException, Depends
2
  from sqlalchemy.orm import Session
 
3
  from .. import crud, database, schemas
4
 
5
  router = APIRouter()
@@ -22,3 +23,28 @@ def update_metadata(
22
  if not c:
23
  raise HTTPException(404, "caption not found")
24
  return c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter, HTTPException, Depends
2
  from sqlalchemy.orm import Session
3
+ from typing import List
4
  from .. import crud, database, schemas
5
 
6
  router = APIRouter()
 
23
  if not c:
24
  raise HTTPException(404, "caption not found")
25
  return c
26
+
27
+ @router.get("/sources", response_model=List[schemas.SourceOut])
28
+ def get_sources(db: Session = Depends(get_db)):
29
+ """Get all sources for lookup"""
30
+ return crud.get_sources(db)
31
+
32
+ @router.get("/regions", response_model=List[schemas.RegionOut])
33
+ def get_regions(db: Session = Depends(get_db)):
34
+ """Get all regions for lookup"""
35
+ return crud.get_regions(db)
36
+
37
+ @router.get("/types", response_model=List[schemas.TypeOut])
38
+ def get_types(db: Session = Depends(get_db)):
39
+ """Get all types for lookup"""
40
+ return crud.get_types(db)
41
+
42
+ @router.get("/spatial-references", response_model=List[schemas.SpatialReferenceOut])
43
+ def get_spatial_references(db: Session = Depends(get_db)):
44
+ """Get all spatial references for lookup"""
45
+ return crud.get_spatial_references(db)
46
+
47
+ @router.get("/image-types", response_model=List[schemas.ImageTypeOut])
48
+ def get_image_types(db: Session = Depends(get_db)):
49
+ """Get all image types for lookup"""
50
+ return crud.get_image_types(db)
py_backend/app/routers/upload.py CHANGED
@@ -12,12 +12,50 @@ def get_db():
12
  finally:
13
  db.close()
14
 
15
- @router.post("/", response_model=schemas.MapOut)
16
- async def upload_map(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  source: str = Form(...),
18
- region: str = Form(...),
19
- category: str = Form(...),
20
  countries: list[str] = Form([]),
 
 
21
  file: UploadFile = Form(...),
22
  db: Session = Depends(get_db)
23
  ):
@@ -29,26 +67,25 @@ async def upload_map(
29
  key = storage.upload_fileobj(io.BytesIO(content), file.filename)
30
 
31
  # 3) persist the DB record
32
- m = crud.create_map(db, source, region, category, key, sha, countries)
33
 
34
  # 4) generate a URL for your front‑end
35
  #
36
  # If you have an S3/MinIO client in storage:
37
  try:
38
- url = storage.get_presigned_url(key, expires_in=3600)
39
  except AttributeError:
40
- # fallback: if you’re serving via StaticFiles("/uploads")
41
  url = f"/uploads/{key}"
42
 
43
- # 5) return the Map plus that URL
44
- return schemas.MapOut(
45
- map_id = m.map_id,
46
- file_key = m.file_key,
47
- sha256 = m.sha256,
48
- source = m.source,
49
- region = m.region,
50
- category = m.category,
51
- countries = [c.c_code for c in m.countries], # or however you model it
52
- created_at = m.created_at,
53
  image_url = url,
54
  )
 
12
  finally:
13
  db.close()
14
 
15
+
16
+ @router.get("/", response_model=list[schemas.ImageOut])
17
+ def list_images(db: Session = Depends(get_db)):
18
+ images = crud.get_images(db)
19
+ return [
20
+ schemas.ImageOut(
21
+ image_id=img.image_id,
22
+ file_key=img.file_key,
23
+ sha256=img.sha256,
24
+ source=img.source,
25
+ type=img.type,
26
+ epsg=img.epsg,
27
+ image_type=img.image_type,
28
+ caption=img.caption,
29
+ image_url=storage.generate_presigned_url(img.file_key, expires_in=3600)
30
+ ) for img in images
31
+ ]
32
+
33
+ @router.get("/{image_id}", response_model=schemas.ImageOut)
34
+ def get_image(image_id: str, db: Session = Depends(get_db)):
35
+ img = crud.get_image(db, image_id)
36
+ if not img:
37
+ raise HTTPException(404, "image not found")
38
+
39
+ return schemas.ImageOut(
40
+ image_id=img.image_id,
41
+ file_key=img.file_key,
42
+ sha256=img.sha256,
43
+ source=img.source,
44
+ type=img.type,
45
+ epsg=img.epsg,
46
+ image_type=img.image_type,
47
+ caption=img.caption,
48
+ image_url=storage.generate_presigned_url(img.file_key, expires_in=3600)
49
+ )
50
+
51
+
52
+ @router.post("/", response_model=schemas.ImageOut)
53
+ async def upload_image(
54
  source: str = Form(...),
55
+ type: str = Form(...),
 
56
  countries: list[str] = Form([]),
57
+ epsg: str = Form(...),
58
+ image_type: str = Form(...),
59
  file: UploadFile = Form(...),
60
  db: Session = Depends(get_db)
61
  ):
 
67
  key = storage.upload_fileobj(io.BytesIO(content), file.filename)
68
 
69
  # 3) persist the DB record
70
+ img = crud.create_image(db, source, type, key, sha, countries, epsg, image_type)
71
 
72
  # 4) generate a URL for your front‑end
73
  #
74
  # If you have an S3/MinIO client in storage:
75
  try:
76
+ url = storage.generate_presigned_url(key, expires_in=3600)
77
  except AttributeError:
78
+ # fallback: if you're serving via StaticFiles("/uploads")
79
  url = f"/uploads/{key}"
80
 
81
+ # 5) return the Image plus that URL
82
+ return schemas.ImageOut(
83
+ image_id = img.image_id,
84
+ file_key = img.file_key,
85
+ sha256 = img.sha256,
86
+ source = img.source,
87
+ type = img.type,
88
+ epsg = img.epsg,
89
+ image_type = img.image_type,
 
90
  image_url = url,
91
  )
py_backend/app/schemas.py CHANGED
@@ -1,22 +1,24 @@
1
- from pydantic import BaseModel, Field, conint
2
  from typing import List, Optional
3
  from uuid import UUID
4
 
5
- #–– For the UploadPage ––
6
- class MapCreate(BaseModel):
7
  source: str
8
- region: str
9
- category: str
10
  countries: List[str] = []
 
 
11
 
12
- class MapOut(BaseModel):
13
- map_id: UUID
14
  file_key: str
15
  sha256: str
16
  source: str
17
- region: str
18
- category: str
19
- image_url: str
 
 
20
 
21
  class Config:
22
  orm_mode = True
@@ -24,20 +26,65 @@ class MapOut(BaseModel):
24
  #–– For the caption endpoints ––
25
  class CaptionOut(BaseModel):
26
  cap_id: UUID
27
- map_id: UUID
 
 
28
  model: str
29
  raw_json: dict
30
  generated: str
31
- edited: Optional[str]
32
- accuracy: Optional[int]
33
- context: Optional[int]
34
- usability: Optional[int]
 
35
 
36
  class Config:
37
  orm_mode = True
38
 
39
  class CaptionUpdate(BaseModel):
40
- edited: str
41
- accuracy: conint(ge=0, le=100) = None
42
- context: conint(ge=0, le=100) = None
43
- usability: conint(ge=0, le=100) = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
  from typing import List, Optional
3
  from uuid import UUID
4
 
5
+ class ImageCreate(BaseModel):
 
6
  source: str
7
+ type: str
 
8
  countries: List[str] = []
9
+ epsg: str
10
+ image_type: str
11
 
12
+ class ImageOut(BaseModel):
13
+ image_id: UUID
14
  file_key: str
15
  sha256: str
16
  source: str
17
+ type: str
18
+ epsg: str
19
+ image_type: str
20
+ image_url: str
21
+ caption: Optional["CaptionOut"] = None
22
 
23
  class Config:
24
  orm_mode = True
 
26
  #–– For the caption endpoints ––
27
  class CaptionOut(BaseModel):
28
  cap_id: UUID
29
+ image_id: UUID
30
+ title: str
31
+ prompt: str
32
  model: str
33
  raw_json: dict
34
  generated: str
35
+ edited: Optional[str] = None
36
+ accuracy: Optional[int] = None
37
+ context: Optional[int] = None
38
+ usability: Optional[int] = None
39
+ starred: bool = False
40
 
41
  class Config:
42
  orm_mode = True
43
 
44
  class CaptionUpdate(BaseModel):
45
+ edited: Optional[str] = None
46
+ accuracy: Optional[int] = None
47
+ context: Optional[int] = None
48
+ usability: Optional[int] = None
49
+ starred: Optional[bool] = None
50
+
51
+ #–– For lookup data ––
52
+ class SourceOut(BaseModel):
53
+ s_code: str
54
+ label: str
55
+
56
+ class Config:
57
+ orm_mode = True
58
+
59
+ class RegionOut(BaseModel):
60
+ r_code: str
61
+ label: str
62
+
63
+ class Config:
64
+ orm_mode = True
65
+
66
+ class TypeOut(BaseModel):
67
+ t_code: str
68
+ label: str
69
+
70
+ class Config:
71
+ orm_mode = True
72
+
73
+ class SpatialReferenceOut(BaseModel):
74
+ epsg: str
75
+ srid: str
76
+ proj4: str
77
+ wkt: str
78
+
79
+ class Config:
80
+ orm_mode = True
81
+
82
+ class ImageTypeOut(BaseModel):
83
+ image_type: str
84
+ label: str
85
+
86
+ class Config:
87
+ orm_mode = True
88
+
89
+ # Update forward references
90
+ ImageOut.update_forward_refs()
py_backend/requirements.txt CHANGED
@@ -5,7 +5,11 @@ alembic
5
  psycopg2-binary
6
  boto3
7
  python-dotenv
8
- pydantic<2.0.0
 
9
  openai # or other VLM client
10
  pytest
11
- httpx
 
 
 
 
5
  psycopg2-binary
6
  boto3
7
  python-dotenv
8
+ pydantic>=2.7.0
9
+ pydantic-settings>=0.2
10
  openai # or other VLM client
11
  pytest
12
+ httpx
13
+ requests
14
+ pycountry>=22.3.5
15
+ pycountry-convert>=0.7.2