SicheateR99 commited on
Commit
ed997d3
·
1 Parent(s): d7c150e

blablabla

Browse files
Files changed (1) hide show
  1. app.py +554 -96
app.py CHANGED
@@ -1,114 +1,572 @@
1
- import os
 
 
 
 
 
 
2
  import re
3
- import pickle
4
  import numpy as np
5
- import tensorflow as tf
6
- from fastapi import FastAPI
7
- from pydantic import BaseModel
8
- from fastapi.middleware.cors import CORSMiddleware
9
 
10
- # Optimasi Memory (Wajib untuk HF Free Tier)
11
- os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
12
- os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
13
- os.environ["TF_NUM_INTEROP_THREADS"] = "1"
14
- os.environ["TF_NUM_INTRAOP_THREADS"] = "1"
15
-
16
- app = FastAPI(title="NER API Service")
17
 
18
  app.add_middleware(
19
  CORSMiddleware,
20
- allow_origins=["*"],
21
  allow_credentials=True,
22
  allow_methods=["*"],
23
  allow_headers=["*"],
24
  )
25
 
26
- # Global variables untuk Lazy Loading
27
- ner_model = None
28
- vocab = None
29
-
30
- # Konfigurasi NER
31
- MAX_SEQ_LEN = 64
32
- IDX2LABEL = {0: "<PAD>", 1: "O", 2: "B-MERCHANT", 3: "I-MERCHANT", 4: "B-ITEM", 5: "I-ITEM", 6: "B-TOTAL", 7: "I-TOTAL"}
33
- UNK_TOKEN = "<UNK>"
34
- NUM_TOKEN = "<NUM>"
35
- PRICE_TOKEN = "<PRICE>"
36
-
37
- # Schema untuk menerima data JSON dari Space A
38
- class TextRequest(BaseModel):
39
- text: str
40
-
41
- def get_ner_model():
42
- global ner_model, vocab
43
- if ner_model is None:
44
- with open("data/processed/vocab.pkl", "rb") as f:
45
- vocab = pickle.load(f)
46
- # compile=False penting agar tidak error saat mencari custom loss function
47
- ner_model = tf.keras.models.load_model("receipt_ner_model.keras", compile=False)
48
- return ner_model, vocab
49
-
50
- def normalize_token(token: str):
51
- token = token.strip()
52
- if re.fullmatch(r"\d{1,3}([.,]\d{3})*", token):
53
- return PRICE_TOKEN
54
- if re.fullmatch(r"\d+", token):
55
- return NUM_TOKEN
56
- token = re.sub(r"^[^a-zA-Z0-9']+|[^a-zA-Z0-9']+$", "", token)
57
- return token.upper() if token else UNK_TOKEN
58
-
59
- @app.post("/predict-ner/")
60
- def predict_ner(request: TextRequest):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  try:
62
- raw_text = request.text
63
- entities = {"MERCHANT": [], "ITEM": [], "TOTAL": []}
 
64
 
65
- # Jika teks kosong, langsung kembalikan entitas kosong
66
- if not raw_text.strip():
67
- return {"success": True, "entities": entities}
68
-
69
- model, vcb = get_ner_model()
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- # Tokenisasi sederhana
72
- tokens = [t for line in raw_text.split("\n") for t in line.strip().split() if t]
73
- if not tokens:
74
- return {"success": True, "entities": entities}
75
-
76
- # Encoding
77
- norm_tokens = [normalize_token(t) for t in tokens]
78
- X = np.zeros((1, MAX_SEQ_LEN), dtype=np.int32)
79
- mask = np.zeros((1, MAX_SEQ_LEN), dtype=np.float32)
80
 
81
- length = min(len(norm_tokens), MAX_SEQ_LEN)
82
- for i in range(length):
83
- X[0, i] = vcb.get(norm_tokens[i], vcb.get(UNK_TOKEN, 0))
84
- mask[0, i] = 1.0
85
-
86
- # Prediksi menggunakan TensorFlow
87
- probs = model([X, mask], training=False).numpy()
88
- labels = [IDX2LABEL[int(np.argmax(probs[0, i]))] for i in range(length)]
 
 
 
 
 
 
 
 
89
 
90
- # Ekstraksi IOB (Inside-Outside-Beginning)
91
- current_type = None
92
- span = []
93
- for tok, lbl in zip(tokens[:length], labels):
94
- if lbl.startswith("B-"):
95
- if current_type:
96
- entities[current_type].append(" ".join(span))
97
- current_type = lbl[2:]
98
- span = [tok]
99
- elif lbl.startswith("I-") and current_type == lbl[2:]:
100
- span.append(tok)
101
- else:
102
- if current_type:
103
- entities[current_type].append(" ".join(span))
104
- current_type = None
105
- span = []
106
- if current_type:
107
- entities[current_type].append(" ".join(span))
108
-
109
- return {"success": True, "entities": entities}
110
 
111
  except Exception as e:
112
- print(f"Error NER: {e}")
113
- # Jika terjadi error internal, kembalikan objek kosong agar Space A tidak crash
114
- return {"success": False, "error": str(e), "entities": {"MERCHANT": [], "ITEM": [], "TOTAL": []}}
 
1
+ from fastapi import FastAPI, File, UploadFile
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+ from paddleocr import PaddleOCR
5
+ from datetime import datetime
6
+ from rapidfuzz import fuzz
7
+ import cv2
8
  import re
 
9
  import numpy as np
10
+ import requests
 
 
 
11
 
12
+ app = FastAPI(title="OCR & Extraction Service")
 
 
 
 
 
 
13
 
14
  app.add_middleware(
15
  CORSMiddleware,
16
+ allow_origins=["*"],
17
  allow_credentials=True,
18
  allow_methods=["*"],
19
  allow_headers=["*"],
20
  )
21
 
22
+ # Inisialisasi PaddleOCR
23
+ paddle_engine = PaddleOCR(
24
+ lang='id',
25
+ show_log=False,
26
+ use_angle_cls=False,
27
+ enable_mkldnn=True,
28
+ cpu_threads=2,
29
+ ocr_version="PP-OCRv4",
30
+ det_limit_side_len=640
31
+ )
32
+
33
+ SPACE_B_URL = "https://struklyai-clasify.hf.space/classify/"
34
+
35
+
36
+ # ENGINE EKSTRAKSI (RULE-BASED + REGEX)
37
+
38
+ def clean(text):
39
+ return text.replace(" ", " ")
40
+
41
+ def clean_item_name(text):
42
+ text = re.sub(r'\d+[A-Z]?$', '', text).strip()
43
+ text = text.replace("'", "")
44
+ return text
45
+
46
+ MONTH_MAP = {
47
+ "jan": 1, "januari": 1,
48
+ "feb": 2, "februari": 2,
49
+ "mar": 3, "maret": 3,
50
+ "apr": 4, "april": 4, "ap": 4,
51
+ "mei": 5, "may": 5,
52
+ "jun": 6, "juni": 6,
53
+ "jul": 7, "juli": 7,
54
+ "aug": 8, "agu": 8, "agustus": 8,
55
+ "sep": 9, "september": 9,
56
+ "oct": 10, "okt": 10, "oktober": 10,
57
+ "nov": 11, "november": 11,
58
+ "dec": 12, "des": 12, "desember": 12,
59
+ }
60
+
61
+ def extract_transaction_date(raw_text: str):
62
+ raw_text = re.sub(r"\s+", " ", raw_text).strip()
63
+ current_year = datetime.now().year
64
+
65
+ def valid_year(y: int) -> bool:
66
+ return 2000 <= y <= current_year + 1
67
+
68
+ def make_date(d: int, m: int, y: int):
69
+ if y < 100:
70
+ y += 2000
71
+ if valid_year(y) and 1 <= m <= 12 and 1 <= d <= 31:
72
+ try:
73
+ return datetime(y, m, d)
74
+ except ValueError:
75
+ pass
76
+ return None
77
+
78
+ # yyyy-mm-ddTHH:MM[:SS]
79
+ hit = re.search(r'(\d{4})-(\d{2})-(\d{2})T', raw_text)
80
+ if hit:
81
+ dt = make_date(int(hit.group(3)), int(hit.group(2)), int(hit.group(1)))
82
+ if dt: return dt.strftime("%d-%m-%Y")
83
+
84
+ # yyyy[sep]mm[sep]dd
85
+ hit = re.search(r'(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})', raw_text)
86
+ if hit:
87
+ dt = make_date(int(hit.group(3)), int(hit.group(2)), int(hit.group(1)))
88
+ if dt: return dt.strftime("%d-%m-%Y")
89
+
90
+ # HH[:.]MM[:SS] <space> dd[sep]mm[sep]yyyy/yy
91
+ hit = re.search(
92
+ r'\d{1,2}[:.]\d{2}(?::\d{2})?\s+(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})',
93
+ raw_text
94
+ )
95
+ if hit:
96
+ dt = make_date(int(hit.group(1)), int(hit.group(2)), int(hit.group(3)))
97
+ if dt: return dt.strftime("%d-%m-%Y")
98
+
99
+ # dd[sep]Month[sep]yyyy/yy
100
+ for h in re.finditer(r'(\d{1,2})[/\-. ]([a-zA-Z]+)[/\-. ](\d{2,4})', raw_text, re.IGNORECASE):
101
+ candidate = h.group(2).lower()
102
+ if len(candidate) > 9:
103
+ continue
104
+ month_num = MONTH_MAP.get(candidate)
105
+ if month_num:
106
+ dt = make_date(int(h.group(1)), month_num, int(h.group(3)))
107
+ if dt: return dt.strftime("%d-%m-%Y")
108
+
109
+ # ddMonyyyy atau ddMonyy
110
+ for yr_pat in (r'(\d{4})', r'(\d{2})'):
111
+ hit = re.search(
112
+ rf'(\d{{1,2}})([a-zA-Z]{{2,}}){yr_pat}',
113
+ raw_text, re.IGNORECASE
114
+ )
115
+ if hit:
116
+ month_num = MONTH_MAP.get(hit.group(2).lower())
117
+ if month_num:
118
+ dt = make_date(int(hit.group(1)), month_num, int(hit.group(3)))
119
+ if dt: return dt.strftime("%d-%m-%Y")
120
+
121
+ # dd[sep]mm[sep]yyyy
122
+ for sep_pat in (r'[/\-.]', r'\s'):
123
+ hit = re.search(rf'(\d{{1,2}}){sep_pat}(\d{{1,2}}){sep_pat}(\d{{4}})', raw_text)
124
+ if hit:
125
+ dt = make_date(int(hit.group(1)), int(hit.group(2)), int(hit.group(3)))
126
+ if dt: return dt.strftime("%d-%m-%Y")
127
+
128
+ # yyyymmdd
129
+ hit = re.search(r'(?<!\d)(20\d{2})([01]\d)([0-3]\d)(?!\d)', raw_text)
130
+ if hit:
131
+ dt = make_date(int(hit.group(3)), int(hit.group(2)), int(hit.group(1)))
132
+ if dt: return dt.strftime("%d-%m-%Y")
133
+
134
+ # ddmmyyyy
135
+ hit = re.search(r'(?<!\d)([0-3]\d)([01]\d)(20\d{2})(?!\d)', raw_text)
136
+ if hit:
137
+ dt = make_date(int(hit.group(1)), int(hit.group(2)), int(hit.group(3)))
138
+ if dt: return dt.strftime("%d-%m-%Y")
139
+
140
+ # dd[sep]mm[sep]yy
141
+ for sep in ('-', '/', '.', ' '):
142
+ esc = re.escape(sep)
143
+ hit = re.search(
144
+ rf'(\d{{1,2}}){esc}(\d{{1,2}}){esc}(\d{{2}})(?!\d{{2}}{esc})',
145
+ raw_text
146
+ )
147
+ if hit:
148
+ dt = make_date(int(hit.group(1)), int(hit.group(2)), int(hit.group(3)))
149
+ if dt: return dt.strftime("%d-%m-%Y")
150
+
151
+ # ddmmyy
152
+ hit = re.search(r'(?<!\d)([0-3]\d)([01]\d)(\d{2})(?!\d)', raw_text)
153
+ if hit:
154
+ dt = make_date(int(hit.group(1)), int(hit.group(2)), int(hit.group(3)))
155
+ if dt: return dt.strftime("%d-%m-%Y")
156
+
157
+ return None
158
+
159
+ def extract_number(line):
160
+ line = line.upper().replace("RP", "")
161
+ match = re.search(r'\d{1,3}(?:[.,]\d{3})+', line)
162
+ return match.group() if match else None
163
+
164
+ def has_money(line):
165
+ return bool(extract_number(line))
166
+
167
+ def is_noise(line):
168
+ upper = line.upper().strip()
169
+
170
+ noise_keywords = [
171
+ "TOTAL", "SUBTOTAL", "CASH", "KEMBALI", "CHANGE", "BAYAR", "PAID",
172
+ "ITEM", "QTY", "TERIMA", "KASIH", "THANK", "PELANGGAN",
173
+ "STAFF", "KASSA", "NPWP", "TRANS", "DELIVERY",
174
+ "DISCOUNT", "DISC", "ROUNDED", "DEBIT", "CREDIT",
175
+ "PPN", "DPP", "TUNAI", "HEMAT",
176
+ "UNBOXING", "COMPLAIN", "VIDEO",
177
+ "WWW.", "HTTP",
178
+ "CASHIER", "MOHON"
179
+ ]
180
+
181
+ address_keywords = [
182
+ "JL", "JLN", "JALAN", "RAYA", "KEL", "KEC", "KAB",
183
+ "KOTA", "RT", "RW","BARAT", "TIMUR",
184
+ "SELATAN", "UTARA", "TENGAH"
185
+ ]
186
+
187
+ promo_keywords = [
188
+ "PT ", "/PT", "CV ", "PROMO", "VOUCHER", "MEMBER"
189
+ ]
190
+
191
+ if any(k in upper for k in noise_keywords):
192
+ return True
193
+
194
+ if any(k in upper for k in address_keywords):
195
+ return True
196
+
197
+ if any(k in upper for k in promo_keywords):
198
+ return True
199
+
200
+ if re.fullmatch(r'[\d.,:=-]+', upper):
201
+ return True
202
+
203
+ if re.search(r'\{?\d+\}?X\d+', upper.replace(" ", "")):
204
+ return True
205
+
206
+ if re.search(r'\d+\s*X\s*@?', upper):
207
+ return True
208
+
209
+ if re.search(r'\bRP\b', upper):
210
+ return True
211
+
212
+ return False
213
+
214
+ def is_product_like(line):
215
+ return (
216
+ bool(re.search(r'\d+\s*(PCS|BKS|BTL|@)', line.upper())) or
217
+ bool(re.search(r'\d{1,3}[.,]\d{3}', line))
218
+ )
219
+
220
+ def is_noise_merc(line):
221
+ upper = line.upper().strip()
222
+
223
+ noise_keywords = [
224
+ "TOTAL", "CASH", "KEMBALI", "CHANGE", "BAYAR", "PAID",
225
+ "ITEM", "QTY", "TERIMA", "KASIH", "THANK",
226
+ "RUKO", "PELANGGAN", "STAFF", "KASSA",
227
+ "NPWP", "HP", "TRANS", "BISA DELIVERY"
228
+ ]
229
+
230
+ if any(k in upper for k in noise_keywords):
231
+ return True
232
+
233
+ if re.fullmatch(r'[\d.,:()\-]+', upper):
234
+ return True
235
+
236
+ if re.fullmatch(r'\d+\w*@', upper.replace(" ", "")):
237
+ return True
238
+
239
+ if "." in line and len(line.split()) == 1:
240
+ return True
241
+
242
+ if re.search(r'^[A-Z0-9]{5,}$', upper) and any(c.isdigit() for c in upper):
243
+ return True
244
+
245
+ return False
246
+
247
+
248
+ def detect_merchant(lines):
249
+ for line in lines[:4]:
250
+ line = line.strip("= ").strip()
251
+
252
+ if not line:
253
+ continue
254
+
255
+ if is_noise_merc(line):
256
+ continue
257
+
258
+ # skip line yang mirip product / harga
259
+ if is_product_like(line):
260
+ continue
261
+
262
+ # skip line dominan angka
263
+ if any(c.isdigit() for c in line) and len(line) < 8:
264
+ continue
265
+
266
+ return line
267
+
268
+ return "Tidak Terdeteksi"
269
+
270
+
271
+ def normalize(line):
272
+ return re.sub(r"\s+", " ", line).strip()
273
+
274
+
275
+ def is_noise_items(line):
276
+
277
+ NOISE_KEYWORDS = {
278
+ "TOTAL", "SUBTOTAL", "CASH", "KEMBALI", "CHANGE", "BAYAR", "PAID",
279
+ "ITEM", "QTY", "TERIMA", "KASIH", "THANK", "PELANGGAN",
280
+ "STAFF", "KASSA", "NPWP", "TRANS", "DELIVERY", "TT4L",
281
+ "DISCOUNT", "DISC", "ROUNDED", "DEBIT", "CREDIT", "BTKP",
282
+ "PPN", "DPP", "TUNAI", "HEMAT", "TIMUR", "BARAT", "BKP",
283
+ "UNBOXING", "COMPLAIN", "VIDEO", "UTARA", "SELATAN",
284
+ "WWW", "HTTP", "CASHIER", "MOHON", "JL", "J1", "JALAN",
285
+ "RAYA", "KEL", "KEC", "KAB", "KOTA", "RT", "RW",
286
+ "PT", "/PT", "CV", "PROMO", "VOUCHER", "MEMBER",
287
+ "TAGIHAN", "TEMPAT", "WAKTU", "CHECK", "PAYMENT",
288
+ "CLOSED", "SARAN", "CUSTOMER", "SERVICE",
289
+ "POTONGAN", "PEMBAYARAN", "JUMLAH BAYAR"
290
+ }
291
+
292
+ line = normalize(line)
293
+ upper = line.upper()
294
+
295
+ if not line:
296
+ return True
297
+
298
+ if re.fullmatch(r'[\d.,:;=\-+@{}xX()/ ]+', line):
299
+ return True
300
+
301
+ if re.search(r'\bRP\b', upper):
302
+ return True
303
+
304
+ if any(k in upper for k in NOISE_KEYWORDS):
305
+ return True
306
+
307
+ if "WWW" in upper or "HTTP" in upper:
308
+ return True
309
+
310
+ if re.search(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', line):
311
+ return True
312
+
313
+ if re.search(r'\d{1,2}:\d{2}', line):
314
+ return True
315
+
316
+ return False
317
+
318
+
319
+ def looks_like_price(line):
320
+ line = normalize(line).upper()
321
+
322
+ if re.search(r'RP\s?[\d.,]+', line):
323
+ return True
324
+
325
+ if re.fullmatch(r'[\d.,]{3,}', line):
326
+ return True
327
+
328
+ return False
329
+
330
+
331
+ def looks_like_qty_price(line):
332
+
333
+ line = normalize(line).upper().replace(" ", "")
334
+
335
+ patterns = [
336
+ r'^\{?\d+\}?X',
337
+ r'^\d+[.,]?\d*X@',
338
+ r'^\d+(PCS|BTL|BKS)@?',
339
+ ]
340
+
341
+ return any(re.search(p, line) for p in patterns)
342
+
343
+ def is_item_candidate(line):
344
+ line = normalize(line)
345
+
346
+ if is_noise_items(line):
347
+ return False
348
+
349
+ letters = sum(c.isalpha() for c in line)
350
+ if letters < 3:
351
+ return False\
352
+
353
+ if re.fullmatch(r'[A-Z0-9\-]{8,}', line.upper()):
354
+ return False
355
+
356
+ return True
357
+
358
+ def looks_like_phone(line):
359
+ digits = re.sub(r'\D', '', line)
360
+ lowered = line.lower()
361
+
362
+ return (
363
+ len(digits) >= 9 and
364
+ (
365
+ digits.startswith('08') or
366
+ digits.startswith('62') or
367
+ 'telp' in lowered or
368
+ 'phone' in lowered or
369
+ 'hp' in lowered
370
+ )
371
+ )
372
+
373
+
374
+ def clean_item(line):
375
+ line = normalize(line)
376
+
377
+ line = re.sub(r'^\{?\d+\}?[Xx]\s*@?[\d.,]*', '', line)
378
+ line = re.sub(r'^\d+(PCS|BTL|BKS)@?', '', line, flags=re.I)
379
+
380
+ return line.strip(" -=@")
381
+
382
+
383
+ def extract_items(lines):
384
+ lines = [normalize(x) for x in lines if normalize(x) and not looks_like_phone(normalize(x))]
385
+ lines = lines[3:]
386
+ result = []
387
+
388
+ i = 0
389
+ while i < len(lines):
390
+ line = lines[i]
391
+
392
+ # qty+item same line
393
+ if looks_like_qty_price(line):
394
+ item = clean_item(line)
395
+ if is_item_candidate(item):
396
+ result.append(item)
397
+ i += 1
398
+ continue
399
+
400
+ # item followed by price/qty
401
+ if is_item_candidate(line) and not looks_like_phone(line):
402
+ next_lines = lines[i+1:i+4]
403
+
404
+ if any(looks_like_price(x) or looks_like_qty_price(x) for x in next_lines):
405
+ result.append(line)
406
+ i += 1
407
+ continue
408
+
409
+ # qty line followed by item
410
+ if looks_like_qty_price(line) or re.match(r'^\d+[Xx]', line):
411
+ next_lines = lines[i+1:i+3]
412
+ for nxt in next_lines:
413
+ if is_item_candidate(nxt):
414
+ result.append(nxt)
415
+ break
416
+
417
+ i += 1
418
+
419
+ seen = set()
420
+ final = []
421
+ for x in result:
422
+ key = x.upper()
423
+ if key not in seen:
424
+ seen.add(key)
425
+ final.append(x)
426
+
427
+ return final
428
+
429
+
430
+ def extract_number(text):
431
+ nums = re.findall(r'-?\d[\d.,]*', text)
432
+ values = []
433
+ for n in nums:
434
+ digits = re.sub(r'\D', '', n)
435
+ if len(digits) > 7:
436
+ continue
437
+
438
+ clean = re.sub(r'[.,]', '', n)
439
+ clean = clean.replace("-", "")
440
+
441
+ if clean.isdigit():
442
+ value = int(clean)
443
+ if 100 <= value <= 50000000:
444
+ values.append(value)
445
+
446
+ if not values:
447
+ return None
448
+
449
+ return max(values)
450
+
451
+ def detect_total(lines):
452
+ rules = [
453
+ (r"^TOTAL[: ]*$", 100),
454
+ (r"\bTOTAL\s*BAYAR\b", 95),
455
+ (r"\bGRAND\s*TOTAL\b", 95),
456
+ (r"\bPEMBAYARAN\b", 90),
457
+ (r"\bSUB\s*TOTAL\b", 50),
458
+ ]
459
+
460
+ ignore_keywords = [
461
+ "HEMAT", "DISCOUNT", "PPN", "DPP",
462
+ "BKP", "POT", "BTKP", "TELP",
463
+ "CUSTOMER", "NPWP",
464
+ ]
465
+
466
+ stop_keywords = [
467
+ "KEMBALI", "CHANGE",
468
+ ]
469
+
470
+ candidates = []
471
+
472
+ for i, line in enumerate(lines):
473
+ up = line.upper().strip()
474
+ if any(x in up for x in ignore_keywords):
475
+ continue
476
+
477
+ for pattern, score in rules:
478
+ if re.search(pattern, up):
479
+ for j in range(i, min(i + 5, len(lines))):
480
+ nxt = lines[j].upper().strip()
481
+ if any(x in nxt for x in stop_keywords):
482
+ break
483
+ num = extract_number(lines[j])
484
+ if num:
485
+ distance_penalty = (j - i) * 5
486
+ final_score = score - distance_penalty
487
+ candidates.append(
488
+ (final_score, num)
489
+ )
490
+
491
+ if not candidates:
492
+ return "0"
493
+
494
+ candidates.sort(reverse=True)
495
+ return str(candidates[0][1])
496
+
497
+ def extract_receipt(text):
498
+ text = clean(text)
499
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
500
+ return {
501
+ "merchant": detect_merchant(lines),
502
+ "items": extract_items(lines),
503
+ "total": detect_total(lines)
504
+ }
505
+
506
+
507
+ # API ENDPOINTS
508
+
509
+ @app.get("/")
510
+ def home():
511
+ return {"status": "Optima Team - OCR API Aktif :D !"}
512
+
513
+ @app.post("/scan-ocr/")
514
+ async def proses_gambar_paddle(file: UploadFile = File(...)):
515
  try:
516
+ contents = await file.read()
517
+ nparr = np.frombuffer(contents, np.uint8)
518
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
519
 
520
+ result = paddle_engine.ocr(img)
521
+
522
+ teks_terbaca = []
523
+ if result and result[0]:
524
+ for baris in result[0]:
525
+ teks_terbaca.append(baris[1][0])
526
+
527
+ ocr_mentah = "\n".join(teks_terbaca)
528
+
529
+ date = extract_transaction_date(ocr_mentah)
530
+ hasil_ekstraksi = extract_receipt(ocr_mentah)
531
+
532
+ merchant = hasil_ekstraksi["merchant"]
533
+ items_list = hasil_ekstraksi["items"]
534
+
535
+ total_str = re.sub(r'[^\d]', '', hasil_ekstraksi["total"])
536
+ total = int(total_str) if total_str else 0
537
 
538
+ class_id = None
539
+ class_name = "Tidak Ditemukan"
540
+ confidence = 0.0
 
 
 
 
 
 
541
 
542
+ if items_list:
543
+ try:
544
+ response = requests.post(SPACE_B_URL, json={"items": items_list}, timeout=15)
545
+
546
+ if response.status_code == 200:
547
+ data = response.json()
548
+ class_id = data.get("class_id")
549
+ class_name = data.get("category_name")
550
+ confidence = data.get("confidence")
551
+ else:
552
+ print(f"Gagal mendapat respons Klasifikasi, status: {response.status_code}")
553
+ class_name = f"Error AI (Status {response.status_code})"
554
+
555
+ except requests.exceptions.RequestException as e:
556
+ print(f"Koneksi ke Space B gagal: {e}")
557
+ class_name = "AI Sedang Tidur / Timeout"
558
 
559
+ return {
560
+ "sukses": True,
561
+ "ocr_mentah": ocr_mentah,
562
+ "date": date,
563
+ "items": items_list,
564
+ "merchant": merchant,
565
+ "total": total,
566
+ "classes": class_id,
567
+ "category_name": class_name,
568
+ "confidence": confidence
569
+ }
 
 
 
 
 
 
 
 
 
570
 
571
  except Exception as e:
572
+ return JSONResponse(status_code=500, content={"sukses": False, "pesan": str(e)})