Adibrino commited on
Commit
34aa6dd
·
1 Parent(s): dd2994a
Files changed (4) hide show
  1. .env +5 -2
  2. model/Modelfile → Modelfile +0 -0
  3. app.py +269 -135
  4. requirements.txt +2 -1
.env CHANGED
@@ -1,4 +1,7 @@
1
- MODEL_NAME=adibrino/LAPOR-AI
 
 
2
  ALLOWED_ORIGINS=https://lalim.vercel.app,http://localhost:8000,http://127.0.0.1:8000
3
  SERVICE_CODES_MAP={"DPRKPCK": "Perumahan Rakyat, Kawasan Permukiman dan Cipta Karya", "DPUBM": "Pekerjaan Umum Bina Marga", "DPUSDA": "Pekerjaan Umum Sumber Daya Air", "DLH": "Lingkungan Hidup", "DINSOS": "Sosial", "BPBD": "Penanggulangan Bencana Daerah", "DISHUB": "Perhubungan", "DINKES": "Kesehatan", "SATPOLPP": "Satuan Polisi Pamong Praja", "DISKOMINFO": "Komunikasi dan Informatika", "DISNAKERTRANS": "Tenaga Kerja dan Transmigrasi", "DIPERTAKP": "Pertanian dan Ketahanan Pangan", "DISNAK": "Peternakan", "DKP": "Kelautan dan Perikanan", "DINDIK": "Pendidikan", "DISBUDPAR": "Kebudayaan dan Pariwisata", "DISPERINDAG": "Perindustrian dan Perdagangan", "DPMPTSP": "Penanaman Modal dan Pelayanan Terpadu Satu Pintu", "DISKOPUKM": "Koperasi, Usaha Kecil dan Menengah", "DISPORA": "Kepemudaan dan Olahraga", "DISPERPUSIP": "Perpustakaan dan Kearsipan", "BAPPEDA": "Perencanaan Pembangunan Daerah", "BAPENDA": "Pajak dan Pendapatan Daerah", "DP3AK": "Pemberdayaan Perempuan, Perlindungan Anak dan Kependudukan"}
4
- IS_PRODUCTION=false
 
 
1
+ MODEL_NAME=adibrino/LAPOR-AI:latest
2
+ GEMINI_MODELS="gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite"
3
+ GEMINI_API_KEY=AIzaSyCx1MfYMEH_R_o_lqo1D8pwfUERZK8KVuM
4
  ALLOWED_ORIGINS=https://lalim.vercel.app,http://localhost:8000,http://127.0.0.1:8000
5
  SERVICE_CODES_MAP={"DPRKPCK": "Perumahan Rakyat, Kawasan Permukiman dan Cipta Karya", "DPUBM": "Pekerjaan Umum Bina Marga", "DPUSDA": "Pekerjaan Umum Sumber Daya Air", "DLH": "Lingkungan Hidup", "DINSOS": "Sosial", "BPBD": "Penanggulangan Bencana Daerah", "DISHUB": "Perhubungan", "DINKES": "Kesehatan", "SATPOLPP": "Satuan Polisi Pamong Praja", "DISKOMINFO": "Komunikasi dan Informatika", "DISNAKERTRANS": "Tenaga Kerja dan Transmigrasi", "DIPERTAKP": "Pertanian dan Ketahanan Pangan", "DISNAK": "Peternakan", "DKP": "Kelautan dan Perikanan", "DINDIK": "Pendidikan", "DISBUDPAR": "Kebudayaan dan Pariwisata", "DISPERINDAG": "Perindustrian dan Perdagangan", "DPMPTSP": "Penanaman Modal dan Pelayanan Terpadu Satu Pintu", "DISKOPUKM": "Koperasi, Usaha Kecil dan Menengah", "DISPORA": "Kepemudaan dan Olahraga", "DISPERPUSIP": "Perpustakaan dan Kearsipan", "BAPPEDA": "Perencanaan Pembangunan Daerah", "BAPENDA": "Pajak dan Pendapatan Daerah", "DP3AK": "Pemberdayaan Perempuan, Perlindungan Anak dan Kependudukan"}
6
+ IS_PRODUCTION=false
7
+ GEMINI_SYSTEM_INSTRUCTION='Kamu adalah asisten AI backend untuk aplikasi pengaduan warga (Smart City).\nTugasmu adalah menganalisis input laporan warga (Deskripsi, Lokasi, dan Deskripsi Visual Gambar/Video) lalu mengklasifikasikannya ke dalam format JSON yang ketat.\n\n### 1. REFERENSI MAPPING KATEGORI & KODE DINAS (WAJIB PATUH):\nGunakan daftar ini untuk menentukan "category" dan "service_code". Jangan membuat kategori baru di luar daftar ini.\n\n- "Perumahan Rakyat, Kawasan Permukiman dan Cipta Karya" => DPRKPCK\n- "Pekerjaan Umum Bina Marga" => DPUBM\n- "Pekerjaan Umum Sumber Daya Air" => DPUSDA\n- "Lingkungan Hidup" => DLH\n- "Sosial" => DINSOS\n- "Penanggulangan Bencana Daerah" => BPBD\n- "Perhubungan" => DISHUB\n- "Kesehatan" => DINKES\n- "Satuan Polisi Pamong Praja" => SATPOLPP\n- "Komunikasi dan Informatika" => DISKOMINFO\n- "Tenaga Kerja dan Transmigrasi" => DISNAKERTRANS\n- "Pertanian dan Ketahanan Pangan" => DIPERTAKP\n- "Peternakan" => DISNAK\n- "Kelautan dan Perikanan" => DKP\n- "Pendidikan" => DINDIK\n- "Kebudayaan dan Pariwisata" => DISBUDPAR\n- "Perindustrian dan Perdagangan" => DISPERINDAG\n- "Penanaman Modal dan Pelayanan Terpadu Satu Pintu" => DPMPTSP\n- "Koperasi, Usaha Kecil dan Menengah" => DISKOPUKM\n- "Kepemudaan dan Olahraga" => DISPORA\n- "Perpustakaan dan Kearsipan" => DISPERPUSIP\n- "Perencanaan Pembangunan Daerah" => BAPPEDA\n- "Pajak dan Pendapatan Daerah" => BAPENDA\n- "Pemberdayaan Perempuan, Perlindungan Anak dan Kependudukan" => DP3AK\n\n### 2. LOGIKA PRIORITAS (PriorityEnum):\n- "high": Bahaya nyawa, kecelakaan, banjir besar, kebakaran, kekerasan fisik, atau kerusakan infrastruktur vital total.\n- "medium": Mengganggu aktivitas tapi tidak mematikan (macet, jalan berlubang sedang, sampah menumpuk, lampu jalan mati).\n- "low": Bersifat kosmetik, saran, pertanyaan administrasi, atau gangguan ringan.\n\n### 3. ATURAN OUTPUT:\nHanya berikan output JSON mentah. Jangan ada teks pembuka/penutup.\nFormat JSON wajib: { "title": string, "category": string, "priority": string, "service_code": string }'
model/Modelfile → Modelfile RENAMED
File without changes
app.py CHANGED
@@ -4,11 +4,11 @@ import base64
4
  import json
5
  import time
6
  import subprocess
7
- import threading
8
- import shutil
9
- from typing import List, Any, Dict, Union
10
 
11
- from fastapi import FastAPI, UploadFile, File, Form
12
  from fastapi.responses import JSONResponse, Response
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
@@ -17,45 +17,60 @@ from dotenv import load_dotenv
17
  import ollama
18
  import spaces # type: ignore
19
  import gradio as gr
 
20
 
21
  load_dotenv()
22
 
23
- ALLOWED_ORIGINS_RAW: str = os.getenv("ALLOWED_ORIGINS", "*")
24
- MODEL_NAME: str = os.getenv("MODEL_NAME") or "adibrino/LAPOR-AI"
25
- IS_PRODUCTION: str = os.getenv("IS_PRODUCTION", "false")
 
26
 
27
  SERVICE_MAP_STR = os.getenv("SERVICE_CODES_MAP", "{}")
28
  SERVICE_MAP = json.loads(SERVICE_MAP_STR)
29
 
30
- ALLOWED_ORIGINS = ["*"] if ALLOWED_ORIGINS_RAW == "*" else [origin.strip() for origin in ALLOWED_ORIGINS_RAW.split(",")]
 
 
 
31
 
32
  print(f"ALLOWED_ORIGINS: {ALLOWED_ORIGINS}")
33
- print(f"MODEL_NAME: {MODEL_NAME}")
 
34
 
35
- def setup_ollama():
36
- print("Checking Ollama setup...")
37
- if not shutil.which("ollama"):
38
- print("Ollama not found. Installing...")
39
- subprocess.run("curl -fsSL https://ollama.com/install.sh | sh", shell=True, check=True)
40
 
41
- def run_server():
42
- print("Starting Ollama Serve...")
43
- subprocess.Popen(["ollama", "serve"])
44
 
45
- t = threading.Thread(target=run_server, daemon=True)
46
- t.start()
47
 
48
- print("Waiting for Ollama to spin up...")
49
- time.sleep(5)
50
 
51
- print(f"Pulling Model: {MODEL_NAME}...")
 
 
 
 
 
 
 
 
 
52
  try:
53
- subprocess.run(["ollama", "pull", MODEL_NAME], check=True)
54
- print("Model pulled successfully.")
55
  except Exception as e:
56
- print(f"Error pulling model: {e}")
57
-
58
- setup_ollama()
59
 
60
  app = FastAPI()
61
 
@@ -68,9 +83,9 @@ app.add_middleware(
68
  )
69
 
70
  def process_image_to_base64(image_bytes: bytes) -> Union[str, None]:
 
71
  try:
72
- img = Image.open(io.BytesIO(image_bytes))
73
- img = img.convert('RGB')
74
  buffered = io.BytesIO()
75
  img.save(buffered, format="JPEG")
76
  return base64.b64encode(buffered.getvalue()).decode('utf-8')
@@ -78,141 +93,260 @@ def process_image_to_base64(image_bytes: bytes) -> Union[str, None]:
78
  print(f"Error processing image: {e}")
79
  return None
80
 
81
- @spaces.GPU(duration=60)
82
- def run_inference(report_text: str, base64_images: List[str]) -> Dict[str, Any]:
83
- print("Starting GPU Inference...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
 
 
 
 
 
 
 
85
  try:
86
- ollama.show(MODEL_NAME)
87
  except Exception:
88
  print("Model not found in GPU context, pulling again...")
89
- subprocess.run(["ollama", "pull", MODEL_NAME], check=True)
90
 
91
- response: Any = ollama.chat( # type: ignore
92
- model=MODEL_NAME,
93
  messages=[{
94
  'role': 'user',
95
  'content': report_text,
96
- 'images': base64_images if base64_images else None # type: ignore
97
  }],
98
  format='json',
99
  options={'temperature': 0.1}
100
  )
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- if isinstance(response, dict):
103
- return response # type: ignore
104
- return dict(response)
105
 
106
  @app.get("/")
107
  def health_check():
108
  return Response("Python Backend is running.")
109
 
110
- @app.post("/api/analyze")
111
- async def analyze( # type: ignore
112
- report: str = Form(...),
113
- images: List[UploadFile] = File(...)
114
- ):
 
 
 
 
115
  try:
116
- if not report or len(report) < 10:
117
- return JSONResponse(
118
- status_code=400,
119
- content={"status": "error", "message": "Deskripsi laporan wajib diisi minimal 10 karakter."}
120
- )
121
-
122
- if not images:
123
- return JSONResponse(
124
- status_code=400,
125
- content={"status": "error", "message": "Wajib melampirkan minimal 1 foto bukti."}
126
- )
127
-
128
- base64_images: List[str] = []
129
- for img_file in images:
130
- content = await img_file.read()
131
- if len(content) > 0:
132
- b64 = process_image_to_base64(content)
133
- if b64:
134
- base64_images.append(b64)
 
 
 
 
 
135
 
136
- if not base64_images:
137
- return JSONResponse(
138
- status_code=400,
139
- content={"status": "error", "message": "File gambar tidak valid/corrupt."}
140
- )
141
-
142
- max_retries = 3
143
- last_exception = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- print("Report Text:", report)
146
-
147
- for attempt in range(max_retries):
148
- try:
149
- print(f"Attempting AI Analysis... ({attempt + 1}/{max_retries})")
150
-
151
- response_raw = run_inference(report, base64_images)
152
-
153
- if 'message' not in response_raw or 'content' not in response_raw['message']:
154
- raise ValueError("Empty response structure from AI")
155
-
156
- content_str = response_raw['message']['content']
157
-
158
- ai_content = json.loads(content_str)
159
-
160
- required_keys = ["title", "category", "priority", "service_code"]
161
- missing_keys = [key for key in required_keys if key not in ai_content]
162
-
163
- if missing_keys:
164
- raise ValueError(f"Missing keys in JSON: {missing_keys}")
165
-
166
- if not str(ai_content["title"]).strip():
167
- raise ValueError("AI returned empty title")
168
-
169
- service_code = ai_content["service_code"]
170
- if service_code not in SERVICE_MAP:
171
- raise ValueError(f"Invalid service_code: {service_code}. Not found in SERVICE_MAP.")
172
-
173
- expected_category = SERVICE_MAP[service_code]
174
- if ai_content["category"] != expected_category:
175
- raise ValueError(f"Category mismatch. Got '{ai_content['category']}', expected '{expected_category}' for code {service_code}")
176
-
177
- priority = str(ai_content["priority"]).lower()
178
- if priority not in ['high', 'medium', 'low']:
179
- raise ValueError(f"Invalid priority: {priority}")
180
-
181
- ai_content["priority"] = priority
182
-
183
- data = { # type: ignore
184
- "status": "success",
185
- "data": ai_content,
186
- "meta": {
187
- "model": MODEL_NAME,
188
- 'processing_time_sec': (response_raw.get("total_duration", 0)) / 1e9,
189
- "images_analyzed": len(base64_images),
190
- "attempts": attempt + 1
191
- }
192
- }
193
-
194
- print("AI Success")
195
- print(json.dumps(data, indent=2, ensure_ascii=True))
196
-
197
- return data # type: ignore
198
- except Exception as e:
199
- print(f"Attempt {attempt + 1} failed: {str(e)}")
200
- last_exception = e
201
- time.sleep(1)
202
- continue
203
 
 
 
 
204
  return JSONResponse(
205
  status_code=500,
206
- content={"status": "error", "message": f"AI Failed: {str(last_exception)}"}
 
 
 
 
207
  )
208
- except Exception as e:
209
- raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
 
 
 
 
 
 
 
 
 
 
211
  if __name__ == "__main__":
212
  with gr.Blocks() as demo:
213
  gr.Markdown("# LAPOR AI API Backend")
214
- gr.Markdown("This space hosts the API at `/api/analyze`.")
215
- gr.Markdown(f"**Model:** {MODEL_NAME}")
 
 
 
 
216
 
217
  app = gr.mount_gradio_app(app, demo, path="/") # type: ignore
218
 
 
4
  import json
5
  import time
6
  import subprocess
7
+ import threading # type: ignore
8
+ import shutil # type: ignore
9
+ from typing import List, Any, Dict, Union, Optional
10
 
11
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
12
  from fastapi.responses import JSONResponse, Response
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
 
17
  import ollama
18
  import spaces # type: ignore
19
  import gradio as gr
20
+ import google.generativeai as genai
21
 
22
  load_dotenv()
23
 
24
+ ALLOWED_ORIGINS_RAW: Optional[str] = os.getenv("ALLOWED_ORIGINS")
25
+ MODEL_NAME: Optional[str] = os.getenv("MODEL_NAME")
26
+ GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
27
+ GEMINI_MODELS_RAW: Optional[str] = os.getenv("GEMINI_MODELS")
28
 
29
  SERVICE_MAP_STR = os.getenv("SERVICE_CODES_MAP", "{}")
30
  SERVICE_MAP = json.loads(SERVICE_MAP_STR)
31
 
32
+ GEMINI_SYSTEM_INSTRUCTION = os.getenv("GEMINI_SYSTEM_INSTRUCTION", "{}")
33
+
34
+ ALLOWED_ORIGINS = ["*"] if ALLOWED_ORIGINS_RAW == "*" else [origin.strip() for origin in ALLOWED_ORIGINS_RAW.split(",")] # type: ignore
35
+ GEMINI_MODEL_LIST: List[str] = [model.strip() for model in GEMINI_MODELS_RAW.split(',')] if GEMINI_MODELS_RAW else []
36
 
37
  print(f"ALLOWED_ORIGINS: {ALLOWED_ORIGINS}")
38
+ print(f"LOCAL_MODEL_NAME: {MODEL_NAME}")
39
+ print(f"GEMINI_MODELS: {GEMINI_MODEL_LIST}")
40
 
41
+ # def setup_ollama():
42
+ # print("Checking Ollama setup...")
43
+ # if not shutil.which("ollama"):
44
+ # print("Ollama not found. Installing...")
45
+ # subprocess.run("curl -fsSL https://ollama.com/install.sh | sh", shell=True, check=True)
46
 
47
+ # def run_server():
48
+ # print("Starting Ollama Serve...")
49
+ # subprocess.Popen(["ollama", "serve"])
50
 
51
+ # t = threading.Thread(target=run_server, daemon=True)
52
+ # t.start()
53
 
54
+ # print("Waiting for Ollama to spin up...")
55
+ # time.sleep(5)
56
 
57
+ # print(f"Pulling Model: {MODEL_NAME}...")
58
+ # try:
59
+ # subprocess.run(["ollama", "pull", MODEL_NAME], check=True) # type: ignore
60
+ # print("Model pulled successfully.")
61
+ # except Exception as e:
62
+ # print(f"Error pulling model: {e}")
63
+
64
+ # setup_ollama()
65
+
66
+ if GEMINI_API_KEY:
67
  try:
68
+ genai.configure(api_key=GEMINI_API_KEY) # type: ignore
69
+ print("Gemini client configured successfully.")
70
  except Exception as e:
71
+ raise EnvironmentError(f"Error configuring Gemini: {e}")
72
+ else:
73
+ raise EnvironmentError("Warning: GEMINI_API_KEY not found. The /api/analyze/gemini endpoint and fallback will be unavailable.")
74
 
75
  app = FastAPI()
76
 
 
83
  )
84
 
85
  def process_image_to_base64(image_bytes: bytes) -> Union[str, None]:
86
+ """Converts image bytes to a base64 encoded string."""
87
  try:
88
+ img = Image.open(io.BytesIO(image_bytes)).convert('RGB')
 
89
  buffered = io.BytesIO()
90
  img.save(buffered, format="JPEG")
91
  return base64.b64encode(buffered.getvalue()).decode('utf-8')
 
93
  print(f"Error processing image: {e}")
94
  return None
95
 
96
+ async def process_uploaded_files(images: List[UploadFile]) -> Dict[str, List[Any]]:
97
+ """Reads uploaded files and converts them to bytes and base64 strings."""
98
+ if not images:
99
+ raise HTTPException(status_code=400, detail="Wajib melampirkan minimal 1 foto bukti.")
100
+
101
+ image_bytes_list: List[bytes] = []
102
+ base64_images: List[str] = []
103
+
104
+ for img_file in images:
105
+ content = await img_file.read()
106
+ if len(content) > 0:
107
+ image_bytes_list.append(content)
108
+ b64 = process_image_to_base64(content)
109
+ if b64:
110
+ base64_images.append(b64)
111
+
112
+ if not base64_images:
113
+ raise HTTPException(status_code=400, detail="File gambar tidak valid atau corrupt.")
114
+
115
+ return {"bytes": image_bytes_list, "b64": base64_images}
116
+
117
+ def validate_ai_output(ai_content: Dict[str, Any]) -> Dict[str, Any]:
118
+ """Validates the JSON output from an AI model against the required structure and values."""
119
+ required_keys = ["title", "category", "priority", "service_code"]
120
+ missing_keys = [key for key in required_keys if key not in ai_content]
121
+ if missing_keys:
122
+ raise ValueError(f"Missing keys in AI JSON response: {', '.join(missing_keys)}")
123
+
124
+ if not str(ai_content.get("title", "")).strip():
125
+ raise ValueError("AI returned an empty title")
126
+
127
+ service_code = ai_content["service_code"]
128
+ if service_code not in SERVICE_MAP:
129
+ raise ValueError(f"Invalid service_code '{service_code}'. Not found in service map.")
130
+
131
+ expected_category = SERVICE_MAP[service_code]
132
+ if ai_content["category"] != expected_category:
133
+ raise ValueError(f"Category mismatch for code {service_code}. Got '{ai_content['category']}', expected '{expected_category}'")
134
+
135
+ priority = str(ai_content["priority"]).lower()
136
+ if priority not in ['high', 'medium', 'low']:
137
+ raise ValueError(f"Invalid priority value: '{priority}'")
138
 
139
+ ai_content["priority"] = priority
140
+ return ai_content
141
+
142
+ @spaces.GPU(duration=60)
143
+ def run_local_inference(report_text: str, base64_images: List[str]) -> Dict[str, Any]:
144
+ """Runs inference using the local Ollama model."""
145
+ print("Starting Local GPU Inference...")
146
  try:
147
+ ollama.show(MODEL_NAME) # type: ignore
148
  except Exception:
149
  print("Model not found in GPU context, pulling again...")
150
+ subprocess.run(["ollama", "pull", MODEL_NAME], check=True) # type: ignore
151
 
152
+ response = ollama.chat( # type: ignore
153
+ model=MODEL_NAME, # type: ignore
154
  messages=[{
155
  'role': 'user',
156
  'content': report_text,
157
+ 'images': base64_images,
158
  }],
159
  format='json',
160
  options={'temperature': 0.1}
161
  )
162
+ return response # type: ignore
163
+
164
+ def run_gemini_inference(report_text: str, image_bytes_list: List[bytes], model_name: str) -> Dict[str, Any]:
165
+ """Runs inference using the Google Gemini model."""
166
+ print(f"Starting Gemini Inference with model: {model_name}...")
167
+ if not GEMINI_API_KEY:
168
+ raise ConnectionError("GEMINI_API_KEY is not configured.")
169
+
170
+ model = genai.GenerativeModel(model_name, system_instruction=GEMINI_SYSTEM_INSTRUCTION) # type: ignore
171
+ pil_images = [Image.open(io.BytesIO(content)) for content in image_bytes_list]
172
+
173
+ response = model.generate_content([report_text, *pil_images], generation_config={"response_mime_type": "application/json"}) # type: ignore
174
 
175
+ ai_content = json.loads(response.text)
176
+ return ai_content
 
177
 
178
  @app.get("/")
179
  def health_check():
180
  return Response("Python Backend is running.")
181
 
182
+ @app.post("/api/analyze/local")
183
+ async def analyze_local(report: str = Form(...), images: List[UploadFile] = File(...)): # type: ignore
184
+ """Endpoint to analyze a report using only the local Ollama model."""
185
+ if not report or len(report) < 10:
186
+ raise HTTPException(status_code=400, detail="Deskripsi laporan wajib diisi minimal 10 karakter.")
187
+
188
+ processed_images = await process_uploaded_files(images)
189
+ base64_images = processed_images["b64"]
190
+
191
  try:
192
+ response_raw = run_local_inference(report, base64_images)
193
+ if 'message' not in response_raw or 'content' not in response_raw['message']:
194
+ raise ValueError("Empty or invalid response structure from local AI")
195
+
196
+ ai_content = validate_ai_output(json.loads(response_raw['message']['content']))
197
+
198
+ return { # type: ignore
199
+ "status": "success",
200
+ "data": ai_content,
201
+ "meta": {
202
+ "model": MODEL_NAME,
203
+ 'processing_time_sec': (response_raw.get("total_duration", 0)) / 1e9,
204
+ "images_analyzed": len(base64_images),
205
+ }
206
+ }
207
+ except Exception as e:
208
+ print(f"Local analysis failed: {str(e)}")
209
+ raise HTTPException(status_code=500, detail=f"Local AI Failed: {str(e)}")
210
+
211
+ @app.post("/api/analyze/gemini")
212
+ async def analyze_gemini(report: str = Form(...), images: List[UploadFile] = File(...)): # type: ignore
213
+ """Endpoint to analyze a report using only the Gemini model."""
214
+ if not report or len(report) < 10:
215
+ raise HTTPException(status_code=400, detail="Deskripsi laporan wajib diisi minimal 10 karakter.")
216
 
217
+ processed_images = await process_uploaded_files(images)
218
+ image_bytes_list = processed_images["bytes"]
219
+
220
+ if not GEMINI_MODEL_LIST:
221
+ raise HTTPException(status_code=501, detail="No Gemini models configured in the environment.")
222
+
223
+ primary_gemini_model = GEMINI_MODEL_LIST[0]
224
+
225
+ try:
226
+ start_time = time.time()
227
+ ai_content = validate_ai_output(run_gemini_inference(report, image_bytes_list, primary_gemini_model))
228
+ end_time = time.time()
229
+
230
+ return { # type: ignore
231
+ "status": "success",
232
+ "data": ai_content,
233
+ "meta": {
234
+ "model": primary_gemini_model,
235
+ 'processing_time_sec': end_time - start_time,
236
+ "images_analyzed": len(image_bytes_list),
237
+ }
238
+ }
239
+ except Exception as e:
240
+ print(f"Gemini analysis failed: {str(e)}")
241
+ raise HTTPException(status_code=500, detail=f"Gemini AI Failed: {str(e)}")
242
+
243
+ @app.post("/api/analyze")
244
+ async def analyze_with_fallback(report: str = Form(...), images: List[UploadFile] = File(...)): # type: ignore
245
+ """
246
+ Main analysis endpoint. Tries the local model up to 3 times.
247
+ If it fails, it falls back to the Gemini model.
248
+ """
249
+ if not report or len(report) < 10:
250
+ raise HTTPException(status_code=400, detail="Deskripsi laporan wajib diisi minimal 10 karakter.")
251
+
252
+ processed_images = await process_uploaded_files(images)
253
+ base64_images = processed_images["b64"] # type: ignore
254
+ image_bytes_list = processed_images["bytes"]
255
+
256
+ last_local_exception = None
257
+ last_gemini_exception = None
258
+
259
+ # max_local_retries = 3 # type: ignore
260
+ # for attempt in range(max_local_retries):
261
+ # try:
262
+ # print(f"Attempting Local AI Analysis... ({attempt + 1}/{max_local_retries})")
263
+ # response_raw = run_local_inference(report, base64_images)
264
+
265
+ # if 'message' not in response_raw or 'content' not in response_raw['message']:
266
+ # raise ValueError("Empty response structure from local AI")
267
+
268
+ # ai_content = validate_ai_output(json.loads(response_raw['message']['content']))
269
 
270
+ # response = { # type: ignore
271
+ # "status": "success",
272
+ # "data": ai_content,
273
+ # "meta": {
274
+ # "model": MODEL_NAME,
275
+ # 'processing_time_sec': (response_raw.get("total_duration", 0)) / 1e9,
276
+ # "images_analyzed": len(base64_images),
277
+ # "source": "local",
278
+ # "attempts": attempt + 1
279
+ # }
280
+ # }
281
+
282
+ # print("Local AI Success")
283
+ # print(json.dumps(response, indent=2, ensure_ascii=True))
284
+
285
+ # return response # type: ignore
286
+ # except Exception as e:
287
+ # print(f"Local AI Attempt {attempt + 1} failed: {str(e)}")
288
+ # last_local_exception = e
289
+ # time.sleep(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
+ # print(f"Local model failed. Falling back to Gemini models.")
292
+
293
+ if not GEMINI_MODEL_LIST:
294
  return JSONResponse(
295
  status_code=500,
296
+ content={
297
+ "status": "error",
298
+ "message": "Local AI failed and no Gemini models are configured for fallback.",
299
+ "local_model_error": str(last_local_exception),
300
+ }
301
  )
302
+
303
+ print(GEMINI_MODEL_LIST)
304
+
305
+ for model_name in [model_name for model_name in GEMINI_MODEL_LIST for _ in range(3)]:
306
+ try:
307
+ start_time = time.time()
308
+ ai_content = validate_ai_output(run_gemini_inference(report, image_bytes_list, model_name))
309
+ end_time = time.time()
310
+
311
+ response = { # type: ignore
312
+ "status": "success",
313
+ "data": ai_content,
314
+ "meta": {
315
+ "model": model_name,
316
+ 'processing_time_sec': end_time - start_time,
317
+ "images_analyzed": len(image_bytes_list),
318
+ "source": "gemini_fallback"
319
+ }
320
+ }
321
+
322
+ print(f"Gemini AI Fallback Success with model {model_name}")
323
+ print(json.dumps(response, indent=2, ensure_ascii=True))
324
+
325
+ return response # type: ignore
326
+ except Exception as e:
327
+ print(f"Gemini AI Fallback with model {model_name} failed: {str(e)}")
328
+ last_gemini_exception = e
329
+ continue
330
 
331
+ return JSONResponse(
332
+ status_code=500,
333
+ content={
334
+ "status": "error",
335
+ "message": "All AI models (Local and Gemini fallbacks) failed to process the request.",
336
+ "local_model_error": str(last_local_exception),
337
+ "last_gemini_model_error": str(last_gemini_exception)
338
+ }
339
+ )
340
+
341
  if __name__ == "__main__":
342
  with gr.Blocks() as demo:
343
  gr.Markdown("# LAPOR AI API Backend")
344
+ gr.Markdown(
345
+ "This space hosts the API endpoints for analyzing citizen reports. "
346
+ "The primary endpoint is `/api/analyze` which uses a local model with a Gemini fallback."
347
+ )
348
+ gr.Markdown(f"**Local Model:** `{MODEL_NAME}`")
349
+ gr.Markdown(f"**Fallback Models (in order):** `{', '.join(GEMINI_MODEL_LIST)}`")
350
 
351
  app = gr.mount_gradio_app(app, demo, path="/") # type: ignore
352
 
requirements.txt CHANGED
@@ -6,4 +6,5 @@ ollama
6
  gradio
7
  spaces
8
  python-dotenv
9
- Pillow
 
 
6
  gradio
7
  spaces
8
  python-dotenv
9
+ Pillow
10
+ google-generativeai