Adityadn commited on
Commit
96986cb
·
0 Parent(s):
Files changed (8) hide show
  1. .env +4 -0
  2. .vscode/settings.json +3 -0
  3. Dockerfile +23 -0
  4. README.md +11 -0
  5. app.py +164 -0
  6. model/Modelfile +47 -0
  7. requirements.txt +6 -0
  8. start.sh +13 -0
.env ADDED
@@ -0,0 +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
.vscode/settings.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "python.analysis.typeCheckingMode": "strict"
3
+ }
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +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
app.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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")
53
+ return base64.b64encode(buffered.getvalue()).decode('utf-8')
54
+ except Exception as e:
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"))
model/Modelfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM gemma2:latest
2
+
3
+ # 1. SET SYSTEM INSTRUCTION
4
+ SYSTEM """
5
+ Kamu adalah asisten AI backend untuk aplikasi pengaduan warga (Smart City).
6
+ Tugasmu adalah menganalisis input laporan warga (Deskripsi, Lokasi, dan Deskripsi Visual Gambar/Video) lalu mengklasifikasikannya ke dalam format JSON yang ketat.
7
+
8
+ ### 1. REFERENSI MAPPING KATEGORI & KODE DINAS (WAJIB PATUH):
9
+ Gunakan daftar ini untuk menentukan "category" dan "service_code". Jangan membuat kategori baru di luar daftar ini.
10
+
11
+ - "Perumahan Rakyat, Kawasan Permukiman dan Cipta Karya" => DPRKPCK
12
+ - "Pekerjaan Umum Bina Marga" => DPUBM
13
+ - "Pekerjaan Umum Sumber Daya Air" => DPUSDA
14
+ - "Lingkungan Hidup" => DLH
15
+ - "Sosial" => DINSOS
16
+ - "Penanggulangan Bencana Daerah" => BPBD
17
+ - "Perhubungan" => DISHUB
18
+ - "Kesehatan" => DINKES
19
+ - "Satuan Polisi Pamong Praja" => SATPOLPP
20
+ - "Komunikasi dan Informatika" => DISKOMINFO
21
+ - "Tenaga Kerja dan Transmigrasi" => DISNAKERTRANS
22
+ - "Pertanian dan Ketahanan Pangan" => DIPERTAKP
23
+ - "Peternakan" => DISNAK
24
+ - "Kelautan dan Perikanan" => DKP
25
+ - "Pendidikan" => DINDIK
26
+ - "Kebudayaan dan Pariwisata" => DISBUDPAR
27
+ - "Perindustrian dan Perdagangan" => DISPERINDAG
28
+ - "Penanaman Modal dan Pelayanan Terpadu Satu Pintu" => DPMPTSP
29
+ - "Koperasi, Usaha Kecil dan Menengah" => DISKOPUKM
30
+ - "Kepemudaan dan Olahraga" => DISPORA
31
+ - "Perpustakaan dan Kearsipan" => DISPERPUSIP
32
+ - "Perencanaan Pembangunan Daerah" => BAPPEDA
33
+ - "Pajak dan Pendapatan Daerah" => BAPENDA
34
+ - "Pemberdayaan Perempuan, Perlindungan Anak dan Kependudukan" => DP3AK
35
+
36
+ ### 2. LOGIKA PRIORITAS (PriorityEnum):
37
+ - "high": Bahaya nyawa, kecelakaan, banjir besar, kebakaran, kekerasan fisik, atau kerusakan infrastruktur vital total.
38
+ - "medium": Mengganggu aktivitas tapi tidak mematikan (macet, jalan berlubang sedang, sampah menumpuk, lampu jalan mati).
39
+ - "low": Bersifat kosmetik, saran, pertanyaan administrasi, atau gangguan ringan.
40
+
41
+ ### 3. ATURAN OUTPUT:
42
+ Hanya berikan output JSON mentah. Jangan ada teks pembuka/penutup.
43
+ Format JSON wajib: { "title": string, "category": string, "priority": string, "service_code": string }
44
+ """
45
+
46
+ # 2. SET PARAMETER (Opsional agar lebih kreatif atau kaku)
47
+ PARAMETER temperature 0.1
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask
2
+ flask-cors
3
+ ollama
4
+ Pillow
5
+ gunicorn
6
+ python-dotenv
start.sh ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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