Adibrino commited on
Commit
de96e47
·
1 Parent(s): e565637

PYTHON #3

Browse files
Files changed (6) hide show
  1. .env +1 -1
  2. Dockerfile +0 -23
  3. README.md +8 -8
  4. app.py +140 -94
  5. requirements.txt +8 -5
  6. start.sh +0 -13
.env CHANGED
@@ -1,4 +1,4 @@
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
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
Dockerfile DELETED
@@ -1,23 +0,0 @@
1
- FROM python:3.9-slim
2
-
3
- RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
4
-
5
- RUN curl -fsSL https://ollama.com/install.sh | sh
6
-
7
- RUN useradd -m -u 1000 user
8
- USER user
9
- ENV HOME=/home/user \
10
- PATH=/home/user/.local/bin:$PATH
11
-
12
- WORKDIR $HOME/app
13
- COPY --chown=user . $HOME/app
14
-
15
- RUN pip install --no-cache-dir -r requirements.txt
16
-
17
- ENV MODEL_NAME=adibrino/LAPOR-AI
18
-
19
- RUN chmod +x start.sh
20
-
21
- EXPOSE 7860
22
-
23
- CMD ["./start.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,11 +1,11 @@
1
  ---
2
- title: LAPOR AI SERVER
3
- emoji: 🏃
4
- colorFrom: purple
5
- colorTo: yellow
6
- sdk: docker
 
 
7
  pinned: false
8
  license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: LAPOR AI
3
+ emoji: 📞
4
+ colorFrom: lightblue
5
+ colorTo: white
6
+ sdk: gradio
7
+ python_version: 3.9
8
+ app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ ---
 
 
app.py CHANGED
@@ -2,51 +2,80 @@ import os
2
  import io
3
  import base64
4
  import json
5
- import ollama
6
  import time
7
- from typing import List, Optional, Union, Any, Dict
8
- from flask import Flask, request, jsonify, Response
9
- from flask_cors import CORS
 
 
 
 
 
 
10
  from PIL import Image
11
- from werkzeug.datastructures import FileStorage
12
  from dotenv import load_dotenv
 
 
 
13
 
14
  load_dotenv()
15
 
16
- ALLOWED_ORIGINS_RAW: Optional[str] = os.getenv("ALLOWED_ORIGINS", None)
17
- MODEL_NAME: Optional[str] = os.getenv("MODEL_NAME", None)
18
- IS_PRODUCTION: Optional[str] = os.getenv("IS_PRODUCTION", "false")
19
 
 
20
  try:
21
- SERVICE_MAP_STR = os.getenv("SERVICE_CODES_MAP", "{}")
22
  SERVICE_MAP = json.loads(SERVICE_MAP_STR)
23
  except json.JSONDecodeError:
24
- raise EnvironmentError("SERVICE_CODES_MAP in .env is not valid JSON.")
25
-
26
- if not ALLOWED_ORIGINS_RAW:
27
- raise EnvironmentError("The ALLOWED_ORIGINS environment variable is not set.")
28
- if not MODEL_NAME:
29
- raise EnvironmentError("The MODEL_NAME environment variable is not set.")
30
- if not SERVICE_MAP:
31
- raise EnvironmentError("The SERVICE_MAP environment variable is not set or empty.")
32
 
33
- ALLOWED_ORIGINS: Union[str, List[str]] = "*" if ALLOWED_ORIGINS_RAW == "*" else [origin.strip() for origin in ALLOWED_ORIGINS_RAW.split(",")]
 
 
 
34
 
35
- print(f"ALLOWED_ORIGINS (Parsed): {ALLOWED_ORIGINS}")
36
  print(f"MODEL_NAME: {MODEL_NAME}")
37
- print(f"IS_PRODUCTION: {IS_PRODUCTION}")
38
- print(f"SERVICE_MAP Loaded: {len(SERVICE_MAP)} items")
39
- print(json.dumps(SERVICE_MAP, indent=2, ensure_ascii=True))
40
 
41
- app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- app.config['MAX_CONTENT_LENGTH'] = 128 * 1024 * 1024
44
 
45
- CORS(app, resources={r"/api/*": {"origins": ALLOWED_ORIGINS}})
46
 
47
- def process_image_to_base64(image_file: FileStorage) -> Optional[str]:
 
 
 
 
 
 
 
 
48
  try:
49
- img = Image.open(image_file) # type: ignore
50
  img = img.convert('RGB')
51
  buffered = io.BytesIO()
52
  img.save(buffered, format="JPEG")
@@ -55,110 +84,127 @@ def process_image_to_base64(image_file: FileStorage) -> Optional[str]:
55
  print(f"Error processing image: {e}")
56
  return None
57
 
58
- @app.route('/', methods=['GET'])
59
- def health_check() -> Response:
60
- return Response("Python Backend is running.")
61
-
62
- @app.route('/api/analyze', methods=['POST'])
63
- def analyze() -> tuple[Response, int]:
64
- text_laporan: str = request.form.get('laporan', '')
65
 
66
- if not text_laporan or len(text_laporan) < 10:
67
- return jsonify({
68
- "status": "error",
69
- "message": "Deskripsi laporan wajib diisi minimal 10 karakter."
70
- }), 400
71
-
72
- images: List[FileStorage] = request.files.getlist('images')
73
- valid_images: List[FileStorage] = [img for img in images if img.filename != '']
74
-
75
- if len(valid_images) < 1:
76
- return jsonify({
77
- "status": "error",
78
- "message": "Wajib melampirkan minimal 1 foto bukti."
79
- }), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  base64_images: List[str] = []
82
- for img_file in valid_images:
83
- b64 = process_image_to_base64(img_file)
84
- if b64:
85
- base64_images.append(b64)
 
 
86
 
 
 
 
 
 
 
87
  max_retries = 3
88
  last_exception = None
89
-
90
  for attempt in range(max_retries):
91
  try:
92
  print(f"Attempting AI Analysis... ({attempt + 1}/{max_retries})")
93
 
94
- if not MODEL_NAME:
95
- raise ValueError("Model name is missing")
96
-
97
- response: Any = ollama.chat( # type: ignore
98
- model=MODEL_NAME,
99
- messages=[{
100
- 'role': 'user',
101
- 'content': text_laporan,
102
- 'images': base64_images if base64_images else None
103
- }],
104
- format='json',
105
- options={'temperature': 0.1}
106
- )
107
-
108
- if 'message' not in response or 'content' not in response['message']:
109
  raise ValueError("Empty response structure from AI")
110
 
111
- content_str = response['message']['content']
112
- ai_content: Dict[str, Any] = json.loads(content_str)
 
113
 
114
  required_keys = ["title", "category", "priority", "service_code"]
115
  missing_keys = [key for key in required_keys if key not in ai_content]
116
-
117
  if missing_keys:
118
  raise ValueError(f"Missing keys in JSON: {missing_keys}")
119
-
120
- if not str(ai_content["title"]).strip():
121
- raise ValueError("AI returned empty title")
122
 
123
  service_code = ai_content["service_code"]
124
  if service_code not in SERVICE_MAP:
125
- raise ValueError(f"Invalid service_code: {service_code}. Not found in SERVICE_MAP.")
126
-
127
- expected_category = SERVICE_MAP[service_code]
128
- if ai_content["category"] != expected_category:
129
- raise ValueError(f"Category mismatch. Got '{ai_content['category']}', expected '{expected_category}' for code {service_code}")
130
-
131
  priority = str(ai_content["priority"]).lower()
132
  if priority not in ['high', 'medium', 'low']:
133
- raise ValueError(f"Invalid priority: {priority}")
134
-
135
  ai_content["priority"] = priority
136
 
137
- data: dict[str, Any] = {
138
  "status": "success",
139
  "data": ai_content,
140
  "meta": {
141
  "model": MODEL_NAME,
142
- 'processing_time_sec': (response.get("total_duration", 0)) / 1e9,
143
  "images_analyzed": len(base64_images),
144
  "attempts": attempt + 1
145
  }
146
  }
147
-
148
- print("AI Success:", json.dumps(data, indent=2, ensure_ascii=True))
149
-
150
- return jsonify(data), 200
151
 
152
  except Exception as e:
153
  print(f"Attempt {attempt + 1} failed: {str(e)}")
154
  last_exception = e
155
  time.sleep(1)
156
  continue
157
-
158
- return jsonify({
159
- "status": "error",
160
- "message": f"AI Failed after {max_retries} attempts. Last Error: {str(last_exception)}"
161
- }), 500
162
 
163
  if __name__ == "__main__":
164
- app.run(host="0.0.0.0", port=7860, debug=(IS_PRODUCTION == "false"))
 
 
 
 
 
 
 
 
2
  import io
3
  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
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ import uvicorn
15
  from PIL import Image
 
16
  from dotenv import load_dotenv
17
+ import ollama
18
+ import spaces
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
  try:
 
29
  SERVICE_MAP = json.loads(SERVICE_MAP_STR)
30
  except json.JSONDecodeError:
31
+ SERVICE_MAP = {}
 
 
 
 
 
 
 
32
 
33
+ if ALLOWED_ORIGINS_RAW == "*":
34
+ ALLOWED_ORIGINS = ["*"]
35
+ else:
36
+ ALLOWED_ORIGINS = [origin.strip() for origin in ALLOWED_ORIGINS_RAW.split(",")]
37
 
38
+ print(f"ALLOWED_ORIGINS: {ALLOWED_ORIGINS}")
39
  print(f"MODEL_NAME: {MODEL_NAME}")
 
 
 
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)
60
+ print("Model pulled successfully.")
61
+ except Exception as e:
62
+ print(f"Error pulling model: {e}")
63
 
64
+ setup_ollama()
65
 
66
+ app = FastAPI()
67
 
68
+ app.add_middleware(
69
+ CORSMiddleware,
70
+ allow_origins=ALLOWED_ORIGINS,
71
+ allow_credentials=True,
72
+ allow_methods=["*"],
73
+ allow_headers=["*"],
74
+ )
75
+
76
+ def process_image_to_base64(image_bytes: bytes) -> Union[str, None]:
77
  try:
78
+ img = Image.open(io.BytesIO(image_bytes))
79
  img = img.convert('RGB')
80
  buffered = io.BytesIO()
81
  img.save(buffered, format="JPEG")
 
84
  print(f"Error processing image: {e}")
85
  return None
86
 
87
+ @spaces.GPU(duration=60)
88
+ def run_inference(text_laporan: str, base64_images: List[str]) -> Dict[str, Any]:
89
+ print("Starting GPU Inference...")
 
 
 
 
90
 
91
+ try:
92
+ ollama.show(MODEL_NAME)
93
+ except Exception:
94
+ print("Model not found in GPU context, pulling again...")
95
+ subprocess.run(["ollama", "pull", MODEL_NAME], check=True)
96
+
97
+ response: Any = ollama.chat(
98
+ model=MODEL_NAME,
99
+ messages=[{
100
+ 'role': 'user',
101
+ 'content': text_laporan,
102
+ 'images': base64_images if base64_images else None # type: ignore
103
+ }],
104
+ format='json',
105
+ options={'temperature': 0.1}
106
+ )
107
+
108
+ if isinstance(response, dict):
109
+ return response
110
+ return dict(response)
111
+
112
+ @app.get("/")
113
+ def health_check():
114
+ return {"status": "Python Backend with ZeroGPU is running."}
115
+
116
+ @app.post("/api/analyze")
117
+ async def analyze(
118
+ laporan: str = Form(...),
119
+ images: List[UploadFile] = File(...)
120
+ ):
121
+ if not laporan or len(laporan) < 10:
122
+ return JSONResponse(
123
+ status_code=400,
124
+ content={"status": "error", "message": "Deskripsi laporan wajib diisi minimal 10 karakter."}
125
+ )
126
+
127
+ if not images:
128
+ return JSONResponse(
129
+ status_code=400,
130
+ content={"status": "error", "message": "Wajib melampirkan minimal 1 foto bukti."}
131
+ )
132
 
133
  base64_images: List[str] = []
134
+ for img_file in images:
135
+ content = await img_file.read()
136
+ if len(content) > 0:
137
+ b64 = process_image_to_base64(content)
138
+ if b64:
139
+ base64_images.append(b64)
140
 
141
+ if not base64_images:
142
+ return JSONResponse(
143
+ status_code=400,
144
+ content={"status": "error", "message": "File gambar tidak valid/corrupt."}
145
+ )
146
+
147
  max_retries = 3
148
  last_exception = None
149
+
150
  for attempt in range(max_retries):
151
  try:
152
  print(f"Attempting AI Analysis... ({attempt + 1}/{max_retries})")
153
 
154
+ response_raw = run_inference(laporan, base64_images)
155
+
156
+ if 'message' not in response_raw or 'content' not in response_raw['message']:
 
 
 
 
 
 
 
 
 
 
 
 
157
  raise ValueError("Empty response structure from AI")
158
 
159
+ content_str = response_raw['message']['content']
160
+
161
+ ai_content = json.loads(content_str)
162
 
163
  required_keys = ["title", "category", "priority", "service_code"]
164
  missing_keys = [key for key in required_keys if key not in ai_content]
 
165
  if missing_keys:
166
  raise ValueError(f"Missing keys in JSON: {missing_keys}")
 
 
 
167
 
168
  service_code = ai_content["service_code"]
169
  if service_code not in SERVICE_MAP:
170
+ print(f"Warning: Service code {service_code} unknown.")
171
+
 
 
 
 
172
  priority = str(ai_content["priority"]).lower()
173
  if priority not in ['high', 'medium', 'low']:
174
+ priority = 'medium'
 
175
  ai_content["priority"] = priority
176
 
177
+ data = {
178
  "status": "success",
179
  "data": ai_content,
180
  "meta": {
181
  "model": MODEL_NAME,
182
+ 'processing_time_sec': (response_raw.get("total_duration", 0)) / 1e9,
183
  "images_analyzed": len(base64_images),
184
  "attempts": attempt + 1
185
  }
186
  }
187
+
188
+ print("AI Success")
189
+ return data
 
190
 
191
  except Exception as e:
192
  print(f"Attempt {attempt + 1} failed: {str(e)}")
193
  last_exception = e
194
  time.sleep(1)
195
  continue
196
+
197
+ return JSONResponse(
198
+ status_code=500,
199
+ content={"status": "error", "message": f"AI Failed: {str(last_exception)}"}
200
+ )
201
 
202
  if __name__ == "__main__":
203
+ with gr.Blocks() as demo:
204
+ gr.Markdown("# LAPOR AI API Backend")
205
+ gr.Markdown("This space hosts the API at `/api/analyze`.")
206
+ gr.Markdown(f"**Model:** {MODEL_NAME}")
207
+
208
+ app = gr.mount_gradio_app(app, demo, path="/")
209
+
210
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt CHANGED
@@ -1,6 +1,9 @@
1
- flask
2
- flask-cors
 
 
3
  ollama
4
- Pillow
5
- gunicorn
6
- python-dotenv
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ pydantic
5
  ollama
6
+ gradio
7
+ spaces
8
+ python-dotenv
9
+ Pillow
start.sh DELETED
@@ -1,13 +0,0 @@
1
- #!/bin/bash
2
-
3
- echo "Starting Ollama Serve..."
4
- ollama serve &
5
-
6
- echo "Waiting for Ollama socket..."
7
- sleep 5
8
-
9
- echo "PRE-LOADING MODEL: $MODEL_NAME"
10
- ollama pull $MODEL_NAME
11
-
12
- echo "Model loaded. Starting Flask Server..."
13
- python app.py