kfkas commited on
Commit
25bd246
·
1 Parent(s): 84b7e4a

Add application file

Browse files
Files changed (1) hide show
  1. app.py +701 -4
app.py CHANGED
@@ -1,7 +1,704 @@
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
1
+ import os
2
+ import shutil
3
+ import cv2
4
+ import base64
5
+ import uuid
6
+ from flask import Flask
7
+
8
  import gradio as gr
9
+ import re
10
+
11
+ # -----------------------------------------------------------------------------
12
+ # Config: 앱 설정 (Gemma와 GPT4o 관련 설정은 제거)
13
+ # -----------------------------------------------------------------------------
14
+ class Config:
15
+ """애플리케이션 설정 및 상수"""
16
+
17
+ # 음식 메뉴 데이터
18
+ FOOD_ITEMS = [
19
+ {"name": "짜장면", "image": "images/food1.jpg", "price": 7.00},
20
+ {"name": "짬뽕", "image": "images/food2.jpg", "price": 8.50},
21
+ {"name": "탕수육", "image": "images/food3.jpg", "price": 15.00},
22
+ {"name": "볶음밥", "image": "images/food4.jpg", "price": 7.50},
23
+ {"name": "깐풍기", "image": "images/food5.jpg", "price": 18.00},
24
+ {"name": "마파두부", "image": "images/food6.jpg", "price": 12.00},
25
+ {"name": "콜라", "image": "images/food6.jpg", "price": 12.00},
26
+ {"name": "사이다", "image": "images/food6.jpg", "price": 12.00},
27
+ ]
28
+
29
+ # 알리바바 Qwen API 키 (기본값은 빈 문자열)
30
+ QWEN_API_KEY = ""
31
+
32
+ # 기본 프롬프트 템플릿
33
+ DEFAULT_PROMPT_TEMPLATE = (
34
+ "### Persona ###\n"
35
+ "You are an expert tip calculation assistant focusing on service quality observed in a video.\n\n"
36
+ "### Task ###\n"
37
+ "1. Watch the video frames provided (via caption) and analyze the service provided by the staff. Provide a summary of the observed actions and interactions in the video.\n"
38
+ "2. Identify the bill amount. First, look for explicit mentions in the 'Video Caption'. If not found, use the 'Calculated Subtotal' from the context. If neither is available, assume a default value of $50.\n"
39
+ "3. Based *only* on the actions and service quality observed in the video (described in the caption) and the user's review/rating, classify the service quality as one of:\n"
40
+ " - Poor\n"
41
+ " - Average\n"
42
+ " - Good\n"
43
+ " Explain your reasoning for the classification based on specific observations from the video and the user's feedback.\n"
44
+ "4. Use the following tip guidelines based *only* on the classified service quality:\n"
45
+ " - Poor Service: 0%~5% of the bill\n"
46
+ " - Average Service: 10%~15% of the bill\n"
47
+ " - Good Service: 15%~20% of the bill\n"
48
+ "5. Calculate a specific tip percentage based on the service quality classification. Choose a percentage within the suggested range.\n"
49
+ "6. Calculate the tip amount by multiplying the bill amount by the chosen percentage. Round to two decimal places.\n"
50
+ "7. Calculate the total bill by adding the tip amount to the subtotal. Round to two decimal places.\n\n"
51
+ "### Context ###\n"
52
+ " - Current Country: USA\n"
53
+ " - Restaurant Name: The Golden Spoon (Assumed)\n"
54
+ " - Calculated Subtotal: ${calculated_subtotal:.2f}\n"
55
+ " - User Star Rating: {star_rating} / 5\n"
56
+ " - Currently User Review: {user_review}\n\n"
57
+ "### Input ###\n"
58
+ "Video Caption:\n{{caption_text}}\n\n"
59
+ "### Output ###\n"
60
+ "Return your answer in the exact format below:\n"
61
+ "Video Text Analysis: [Summary of the observed actions and interactions of the staff in the video.]\n"
62
+ "Analysis: [Explain step-by-step: How you determined the bill amount used. Your detailed reasoning for the service quality classification based on *specific observations from the video (as described in the Video Caption)* and the user's review/rating. How you chose the tip percentage within the guideline range. Show the calculation with the exact percentage used.]\n"
63
+ "Tip Percentage: [X]%\n"
64
+ "Tip Amount: $[Calculated Tip]\n"
65
+ "Total Bill: $[Subtotal + Tip]"
66
+ )
67
+
68
+ # Gradio UI용 CSS
69
+ CUSTOM_CSS = """
70
+ #food-container {
71
+ display: grid;
72
+ grid-template-columns: repeat(3, 1fr);
73
+ gap: 10px;
74
+ overflow-y: auto;
75
+ height: 600px;
76
+ }
77
+
78
+ /* Qwen 버튼을 보라색으로 */
79
+ #qwen-button {
80
+ background-color: #8A2BE2 !important;
81
+ color: white !important;
82
+ border-color: #8A2BE2 !important;
83
+ }
84
+
85
+ #qwen-button:hover {
86
+ background-color: #7722CC !important;
87
+ }
88
+ """
89
+
90
+ def __init__(self):
91
+ # 이미지 디렉토리 확인
92
+ if not os.path.exists("images"):
93
+ print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
94
+ for item in self.FOOD_ITEMS:
95
+ if not os.path.exists(item["image"]):
96
+ print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
97
+
98
+
99
+ # -----------------------------------------------------------------------------
100
+ # ModelClients: 알리바바 Qwen API 클라이언트만 사용
101
+ # -----------------------------------------------------------------------------
102
+ class ModelClients:
103
+ """알리바바 Qwen API 클라이언트 관리"""
104
+
105
+ def __init__(self, config: Config):
106
+ self.config = config
107
+ from openai import OpenAI as QwenOpenAI
108
+ self.qwen_client = QwenOpenAI(
109
+ api_key=config.QWEN_API_KEY,
110
+ base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
111
+ )
112
+
113
+ def encode_video_qwen(self, video_path):
114
+ """Qwen API용 비디오 인코딩"""
115
+ with open(video_path, "rb") as video_file:
116
+ return base64.b64encode(video_file.read()).decode("utf-8")
117
+
118
+
119
+ # -----------------------------------------------------------------------------
120
+ # VideoProcessor: 비디오 처리 및 프레임 추출 기능 제공 (변경 없음)
121
+ # -----------------------------------------------------------------------------
122
+ class VideoProcessor:
123
+ """비디오 처리 및 프레임 추출 기능 제공"""
124
+
125
+ def extract_video_frames(self, video_path, output_folder=None, fps=1):
126
+ """비디오 파일에서 프레임 추출"""
127
+ if not video_path:
128
+ return [], None
129
+
130
+ if output_folder is None:
131
+ output_folder = f"frames_list/frames_{uuid.uuid4().hex}"
132
+
133
+ os.makedirs(output_folder, exist_ok=True)
134
+ cap = cv2.VideoCapture(video_path)
135
+
136
+ if not cap.isOpened():
137
+ print(f"오류: 비디오 파일을 열 수 없습니다 - {video_path}")
138
+ return [], None
139
+
140
+ frame_paths = []
141
+ frame_rate = cap.get(cv2.CAP_PROP_FPS)
142
+
143
+ if not frame_rate or frame_rate == 0:
144
+ print("경고: FPS를 읽을 수 없습니다, 기본값 4으로 설정합니다.")
145
+ frame_rate = 4.0
146
+
147
+ frame_interval = int(frame_rate / fps) if fps > 0 else 1
148
+ if frame_interval <= 0:
149
+ frame_interval = 1
150
+
151
+ frame_count = 0
152
+ saved_frame_count = 0
153
+
154
+ while cap.isOpened():
155
+ ret, frame = cap.read()
156
+ if not ret:
157
+ break
158
+
159
+ if frame is None:
160
+ print(f"경고: {frame_count}번째 프레임이 비어있습니다.")
161
+ frame_count += 1
162
+ continue
163
+
164
+ if frame_count % frame_interval == 0:
165
+ frame_path = os.path.join(output_folder, f"frame_{saved_frame_count}.jpg")
166
+ try:
167
+ if cv2.imwrite(frame_path, frame):
168
+ frame_paths.append(frame_path)
169
+ saved_frame_count += 1
170
+ else:
171
+ print(f"경고: {frame_path} 저장 실패.")
172
+ except Exception as e:
173
+ print(f"경고: 프레임 저장 오류 ({frame_path}): {e}")
174
+
175
+ frame_count += 1
176
+
177
+ cap.release()
178
+
179
+ if not frame_paths:
180
+ print("경고: 프레임 추출 실패.")
181
+ if os.path.exists(output_folder):
182
+ shutil.rmtree(output_folder)
183
+ return [], None
184
+
185
+ return frame_paths, output_folder
186
+
187
+ def cleanup_temp_files(self, video_path, frame_folder):
188
+ """임시 비디오 파일 및 프레임 폴더 정리"""
189
+ if video_path and "temp_video_" in video_path and os.path.exists(video_path):
190
+ try:
191
+ os.remove(video_path)
192
+ print(f"임시 비디오 파일 삭제: {video_path}")
193
+ except OSError as e:
194
+ print(f"임시 비디오 파일 삭제 오류: {e}")
195
+
196
+ if frame_folder and os.path.exists(frame_folder):
197
+ try:
198
+ shutil.rmtree(frame_folder)
199
+ print(f"프레임 폴더 삭제: {frame_folder}")
200
+ except OSError as e:
201
+ print(f"프레임 폴더 삭제 오류: {e}")
202
+
203
+
204
+ # -----------------------------------------------------------------------------
205
+ # TipCalculator: 팁 계산 핵심 로직 (알리바바 Qwen만 사용)
206
+ # -----------------------------------------------------------------------------
207
+ class TipCalculator:
208
+ """팁 계산 핵심 로직 (Alibaba Qwen만 사용)"""
209
+
210
+ def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
211
+ self.config = config
212
+ self.model_clients = model_clients
213
+ self.video_processor = video_processor
214
+
215
+ def parse_llm_output(self, output_text):
216
+ """LLM 출력을 파싱하여 팁 계산 결과 추출"""
217
+ analysis = "Analysis not found."
218
+ tip_percentage = 0.0
219
+ tip_amount = 0.0
220
+ total_bill = 0.0
221
+
222
+ analysis_match = re.search(r"Analysis:\s*(.*?)Tip Percentage:", output_text, re.DOTALL | re.IGNORECASE)
223
+ if analysis_match:
224
+ analysis = analysis_match.group(1).strip()
225
+ else:
226
+ analysis_match_alt = re.search(r"Analysis:\s*(.*)", output_text, re.DOTALL | re.IGNORECASE)
227
+ if analysis_match_alt:
228
+ analysis = analysis_match_alt.group(1).strip()
229
+
230
+ percentage_match = re.search(r"Tip Percentage:\s*\*{0,2}(\d+(?:\.\d+)?)%\*{0,2}", output_text,
231
+ re.DOTALL | re.IGNORECASE)
232
+ if percentage_match:
233
+ try:
234
+ tip_percentage = float(percentage_match.group(1))
235
+ except ValueError:
236
+ print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
237
+ tip_percentage = 0.0
238
+
239
+ tip_match = re.search(r"Tip Amount:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
240
+ if tip_match:
241
+ try:
242
+ tip_amount = float(tip_match.group(1))
243
+ except ValueError:
244
+ print(f"경고: Tip Amount 변환 실패 - {tip_match.group(1)}")
245
+ tip_amount = 0.0
246
+ else:
247
+ print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
248
+
249
+ total_match = re.search(r"Total Bill:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
250
+ if total_match:
251
+ try:
252
+ total_bill = float(total_match.group(1))
253
+ except ValueError:
254
+ print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
255
+
256
+ if len(analysis) < 20 and analysis == "Analysis not found.":
257
+ analysis = output_text
258
+
259
+ return analysis, tip_percentage, tip_amount, output_text
260
+
261
+ def process_tip_qwen(self, video_file_path, star_rating, user_review, calculated_subtotal, custom_prompt=None):
262
+ """Qwen API를 사용한 팁 계산 처리 (비디오 캡션 생성 및 팁 산출)"""
263
+ if not os.path.exists(video_file_path):
264
+ return "Error: 비디오 파일 경로가 유효하지 않습니다.", 0.0, 0.0, [], None, ""
265
+
266
+ # 비디오 -> base64 인코딩
267
+ base64_video = self.model_clients.encode_video_qwen(video_file_path)
268
+ # Omni 프롬프트
269
+ omni_caption_prompt = '''
270
+ Task 1: Describe the waiters' actions in these restaurant video frames. Please check for mistakes or negative behaviors.
271
+ Task 2: Provide a short chronological summary of the entire scene.
272
+ '''
273
+ # Omni 스트리밍 호출
274
+ omni_result = self.model_clients.qwen_client.chat.completions.create(
275
+ model="qwen2.5-omni-7b",
276
+ messages=[
277
+ {
278
+ "role": "system",
279
+ "content": [{"type": "text", "text": "You are a helpful assistant."}],
280
+ },
281
+ {
282
+ "role": "user",
283
+ "content": [
284
+ {
285
+ "type": "video_url",
286
+ "video_url": {"url": f"data:;base64,{base64_video}"},
287
+ },
288
+ {"type": "text", "text": omni_caption_prompt},
289
+ ],
290
+ },
291
+ ],
292
+ modalities=["text"],
293
+ stream=True,
294
+ stream_options={"include_usage": True},
295
+ )
296
+ # 캡션 추출
297
+ all_omni_chunks = list(omni_result)
298
+ caption_text = ""
299
+ for chunk in all_omni_chunks[:-1]:
300
+ if not chunk.choices:
301
+ continue
302
+ if chunk.choices[0].delta.content:
303
+ caption_text += chunk.choices[0].delta.content
304
+ if not caption_text.strip():
305
+ caption_text = "(No caption from Omni)"
306
+
307
+ # --- 2) qvq-max로 팁 계산 ---
308
+ user_review = user_review.strip() if user_review else "(No user review)"
309
+ if custom_prompt is None:
310
+ prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
311
+ calculated_subtotal=calculated_subtotal,
312
+ star_rating=star_rating,
313
+ user_review=user_review
314
+ )
315
+ else:
316
+ try:
317
+ prompt = custom_prompt.format(
318
+ calculated_subtotal=calculated_subtotal,
319
+ star_rating=star_rating,
320
+ user_review=user_review
321
+ )
322
+ except:
323
+ prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
324
+ calculated_subtotal=calculated_subtotal,
325
+ star_rating=star_rating,
326
+ user_review=user_review
327
+ )
328
+
329
+ final_prompt = prompt.replace("{caption_text}", caption_text)
330
+
331
+ qvq_result = self.model_clients.qwen_client.chat.completions.create(
332
+ model="qwen2.5-vl-32b-instruct",
333
+ messages=[
334
+ {
335
+ "role": "system",
336
+ "content": [{"type": "text", "text": "You are a helpful assistant."}],
337
+ },
338
+ {
339
+ "role": "user",
340
+ "content": [
341
+ {"type": "text", "text": final_prompt}
342
+ ],
343
+ },
344
+ ],
345
+ modalities=["text"],
346
+ stream=True,
347
+ )
348
+ all_qvq_chunks = list(qvq_result)
349
+ final_reasoning = ""
350
+ final_answer = ""
351
+ is_answering = False
352
+ for c in all_qvq_chunks[:-1]:
353
+ if not c.choices:
354
+ continue
355
+ d = c.choices[0].delta
356
+ if hasattr(d, "reasoning_content") and d.reasoning_content:
357
+ final_reasoning += d.reasoning_content
358
+ if d.content:
359
+ if not is_answering:
360
+ print("\n" + "=" * 20 + "Complete Response" + "=" * 20 + "\n")
361
+ is_answering = True
362
+ final_answer += d.content
363
+
364
+ final_text = final_reasoning + "\n" + final_answer
365
+ analysis, tip_percentage, tip_amount, output_text = self.parse_llm_output(final_text)
366
+ return analysis, tip_percentage, tip_amount, [], None, output_text
367
+
368
+ def calculate_manual_tip(self, tip_percent, subtotal):
369
+ """백분율에 따른 수동 팁 계산"""
370
+ tip_amount = subtotal * (tip_percent / 100)
371
+ total_bill = subtotal + tip_amount
372
+ analysis_output = f"Manual calculation using fixed tip percentage of {tip_percent}%."
373
+ tip_output = f"${tip_amount:.2f} ({tip_percent:.1f}%)"
374
+ total_bill_output = f"${total_bill:.2f}"
375
+ return analysis_output, tip_output, total_bill_output
376
+
377
+
378
+ # -----------------------------------------------------------------------------
379
+ # UIHandler: Gradio UI 이벤트 및 콜백 처리 (Qwen만 사용, 알리바바 API 키 입력 필드 추가)
380
+ # -----------------------------------------------------------------------------
381
+ class UIHandler:
382
+ """Gradio UI 이벤트 및 콜백 처리"""
383
+
384
+ def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
385
+ self.config = config
386
+ self.tip_calculator = tip_calculator
387
+ self.video_processor = video_processor
388
+
389
+ def update_subtotal_and_prompt(self, *args):
390
+ """사용자 입력에 따라 소계 및 프롬프트 업데이트"""
391
+ num_food_items = len(self.config.FOOD_ITEMS)
392
+ quantities = args[:num_food_items]
393
+ star_rating = args[num_food_items]
394
+ user_review = args[num_food_items + 1]
395
+
396
+ calculated_subtotal = 0.0
397
+ for i in range(num_food_items):
398
+ calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
399
+
400
+ user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
401
+
402
+ updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
403
+ calculated_subtotal=calculated_subtotal,
404
+ star_rating=star_rating,
405
+ user_review=user_review_text
406
+ )
407
+ updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
408
+
409
+ return calculated_subtotal, updated_prompt
410
+
411
+ def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
412
+ """알리바바 Qwen 모델을 사용하여 팁 계산"""
413
+ analysis_output = "계산을 시작합니다..."
414
+ tip_percentage = 0.0
415
+ tip_output = "$0.00"
416
+ total_bill_output = f"${subtotal:.2f}"
417
+
418
+ if video_file_obj is None:
419
+ return "오류: 비디오 파일을 업로드해주세요.", "$0.00", total_bill_output, custom_prompt_text, gr.update(value=None)
420
+
421
+ try:
422
+ # 입력받은 알리바바 API 키가 있으면 Qwen 클라이언트를 재설정
423
+ if alibaba_key and alibaba_key.strip():
424
+ from openai import OpenAI as QwenOpenAI
425
+ self.tip_calculator.model_clients.qwen_client = QwenOpenAI(
426
+ api_key=alibaba_key,
427
+ base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
428
+ )
429
+ temp_video_path = f"temp_video_{uuid.uuid4().hex}.mp4"
430
+ original_path = video_file_obj.name if hasattr(video_file_obj, 'name') else video_file_obj
431
+ shutil.copyfile(original_path, temp_video_path)
432
+ print(f"임시 비디오 파일 생성: {temp_video_path}")
433
+ except Exception as e:
434
+ print(f"임시 비디오 파일 생성 오류: {e}")
435
+ return f"오류: 비디오 파일을 처리할 수 없습니다: {e}", "$0.00", total_bill_output, custom_prompt_text, None
436
+
437
+ frame_folder = None
438
+ try:
439
+ analysis, tip_percentage, tip_amount, _, _, output_text = self.tip_calculator.process_tip_qwen(
440
+ temp_video_path, star_rating, user_review, subtotal, custom_prompt_text
441
+ )
442
+ if "Error" in analysis:
443
+ analysis_output = analysis
444
+ tip_amount = 0.0
445
+ else:
446
+ analysis_output = f"Tip Percentage: {tip_percentage:.1f}%\n\n{output_text}"
447
+ tip_output = f"${tip_amount:.2f} ({tip_percentage:.1f}%)"
448
+ total_bill = subtotal + tip_amount
449
+ total_bill_output = f"${total_bill:.2f}"
450
+ except Exception as e:
451
+ print(f"팁 계산 중 오류 발생 (qwen): {e}")
452
+ analysis_output = f"오류 발생: {e}"
453
+ tip_output = "$0.00"
454
+ total_bill_output = f"${subtotal:.2f}"
455
+ finally:
456
+ self.video_processor.cleanup_temp_files(temp_video_path, frame_folder)
457
+
458
+ return analysis_output, tip_output, total_bill_output, custom_prompt_text, gr.update(value=None)
459
+
460
+ def auto_tip_and_invoice(self, alibaba_key, video_file_obj, subtotal, star_rating, review, prompt, *quantities):
461
+ """AI 모델을 사용한 자동 팁 계산 및 청구서 업데이트 (알리바바 Qwen만 사용)"""
462
+ analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
463
+ alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
464
+ )
465
+ invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
466
+ return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
467
+
468
+ def update_invoice_summary(self, *args):
469
+ """수량 및 팁에 따라 청구서 요약 업데이트"""
470
+ num_items = len(self.config.FOOD_ITEMS)
471
+ quantities = args[:num_items]
472
+
473
+ if len(args) >= num_items + 2:
474
+ tip_str = args[num_items]
475
+ total_bill_str = args[num_items + 1]
476
+ else:
477
+ tip_str = "$0.00"
478
+ total_bill_str = "$0.00"
479
+
480
+ summary = ""
481
+ for i, q in enumerate(quantities):
482
+ try:
483
+ q_val = float(q)
484
+ except:
485
+ q_val = 0
486
+
487
+ if q_val > 0:
488
+ item = self.config.FOOD_ITEMS[i]
489
+ total_price = item['price'] * q_val
490
+ summary += f"{item['name']} x{int(q_val)} : ${total_price:.2f}\n"
491
+
492
+ if summary == "":
493
+ summary = "주문한 메뉴가 없습니다."
494
+
495
+ summary += f"\nTip: {tip_str}\nTotal Bill: {total_bill_str}"
496
+
497
+ return summary
498
+
499
+ def manual_tip_and_invoice(self, tip_percent, subtotal, *quantities):
500
+ """수동 팁 계산 및 청구서 업데이트"""
501
+ analysis, tip_disp, total_bill_disp = self.tip_calculator.calculate_manual_tip(tip_percent, subtotal)
502
+ invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
503
+ return analysis, tip_disp, total_bill_disp, invoice
504
+
505
+ def process_payment(self, total_bill):
506
+ """총 청구액에 대한 결제 처리"""
507
+ return f"{total_bill} 결제되었습니다."
508
+
509
+
510
+ # -----------------------------------------------------------------------------
511
+ # App: 모든 것을 연결하는 메인 애플리케이션 클래스
512
+ # -----------------------------------------------------------------------------
513
+ class App:
514
+ """메인 애플리케이션 클래스"""
515
+
516
+ def __init__(self):
517
+ self.config = Config()
518
+ self.model_clients = ModelClients(self.config)
519
+ self.video_processor = VideoProcessor()
520
+ self.tip_calculator = TipCalculator(self.config, self.model_clients, self.video_processor)
521
+ self.ui_handler = UIHandler(self.config, self.tip_calculator, self.video_processor)
522
+
523
+ # Flask 앱 (필요 시 사용)
524
+ self.flask_app = Flask(__name__)
525
+
526
+ def create_gradio_blocks(self):
527
+ """Gradio Blocks 인터페이스 구성"""
528
+ with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
529
+ css=self.config.CUSTOM_CSS) as interface:
530
+ gr.Markdown("## Video Tip Calculation Interface (Structured)")
531
+
532
+ quantity_inputs = []
533
+ subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
534
+
535
+ with gr.Row():
536
+ with gr.Column(scale=2):
537
+ gr.Markdown("### 1. Select Food Items")
538
+ with gr.Column(elem_id="food-container"):
539
+ for item in self.config.FOOD_ITEMS:
540
+ with gr.Column():
541
+ gr.Image(
542
+ value=item["image"],
543
+ label=None,
544
+ show_label=False,
545
+ width=150,
546
+ height=150,
547
+ interactive=False
548
+ )
549
+ gr.Markdown(f"**{item['name']}**")
550
+ gr.Markdown(f"Price: ${item['price']:.2f}")
551
+ q_input = gr.Number(
552
+ label="Qty",
553
+ value=0,
554
+ minimum=0,
555
+ step=1,
556
+ elem_id=f"qty_{item['name'].replace(' ', '_')}"
557
+ )
558
+ quantity_inputs.append(q_input)
559
+
560
+ subtotal_visible_display = gr.Textbox(label="Subtotal", value="$0.00", interactive=False)
561
+
562
+ gr.Markdown("### 2. Service Feedback")
563
+ review_input = gr.Textbox(label="Review", placeholder="서비스 리뷰를 작성해주세요.", lines=3)
564
+ rating_input = gr.Radio(choices=[1, 2, 3, 4, 5], value=3, label="⭐Star Rating (1-5)⭐", type="value")
565
+
566
+ gr.Markdown("### 3. Calculate Tip")
567
+ with gr.Row():
568
+ btn_5 = gr.Button("5%")
569
+ btn_10 = gr.Button("10%")
570
+ btn_15 = gr.Button("15%")
571
+ btn_20 = gr.Button("20%")
572
+ btn_25 = gr.Button("25%")
573
+
574
+ # Qwen 모델 버튼만 남김
575
+ with gr.Row():
576
+ qwen_btn = gr.Button("Alibaba-Qwen", variant="tertiary", elem_id="qwen-button")
577
+
578
+ gr.Markdown("### 4. Results")
579
+ tip_display = gr.Textbox(label="Calculated Tip", value="$0.00", interactive=False)
580
+ total_bill_display = gr.Textbox(label="Total Bill (Subtotal + Tip)", value="$0.00",
581
+ interactive=False)
582
+ payment_btn = gr.Button("결제하기")
583
+ payment_result = gr.Textbox(label="Payment Result", value="", interactive=False)
584
+
585
+ with gr.Column(scale=1):
586
+ gr.Markdown("### 5. Upload & Prompt")
587
+ # 알리바바 API 키 입력 필드 추가
588
+ alibaba_key_input = gr.Textbox(label="Alibaba API Key", placeholder="Enter your Alibaba API Key", lines=1)
589
+ video_input = gr.Video(label="Upload Service Video")
590
+ prompt_display = gr.Textbox(
591
+ label="Tip Calculation Prompt (Review/Rating/Subtotal 반영)",
592
+ lines=20,
593
+ max_lines=30,
594
+ interactive=True,
595
+ value=self.config.DEFAULT_PROMPT_TEMPLATE.format(
596
+ calculated_subtotal=0.0,
597
+ star_rating=3,
598
+ user_review="(No user review provided)"
599
+ ).replace("{caption_text}", "{{caption_text}}")
600
+ )
601
+
602
+ gr.Markdown("### 6. AI Analysis")
603
+ analysis_display = gr.Textbox(label="AI Analysis", lines=10, max_lines=15, interactive=True)
604
+
605
+ gr.Markdown("### 7. 청구서")
606
+ order_summary_display = gr.Textbox(label="청구서", value="주문한 메뉴가 없습니다.", interactive=True)
607
+
608
+ # Subtotal 값 업데이트 시 $ 표시 갱신
609
+ subtotal_display.change(
610
+ fn=lambda x: f"${x:.2f}",
611
+ inputs=[subtotal_display],
612
+ outputs=[subtotal_visible_display]
613
+ )
614
+
615
+ # 음식 수량, 별점, 리뷰가 바뀔 때마다 Subtotal, Prompt 업데이트
616
+ inputs_for_prompt_update = quantity_inputs + [rating_input, review_input]
617
+ outputs_for_prompt_update = [subtotal_display, prompt_display]
618
+ for comp in inputs_for_prompt_update:
619
+ comp.change(
620
+ fn=self.ui_handler.update_subtotal_and_prompt,
621
+ inputs=inputs_for_prompt_update,
622
+ outputs=outputs_for_prompt_update
623
+ )
624
+
625
+ # 수량 변화 시 청구서 텍스트 업데이트
626
+ for comp in quantity_inputs:
627
+ comp.change(
628
+ fn=self.ui_handler.update_invoice_summary,
629
+ inputs=quantity_inputs,
630
+ outputs=order_summary_display
631
+ )
632
+
633
+ # 모델 호출 후 결과 업데이트 (알리바바 API 키 포함)
634
+ compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
635
+ compute_outputs = [
636
+ analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
637
+ ]
638
+
639
+ qwen_btn.click(
640
+ fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
641
+ alibaba_key, vid, sub, rat, rev, prom, *qty
642
+ ),
643
+ inputs=compute_inputs,
644
+ outputs=compute_outputs
645
+ )
646
+
647
+ # 수동 팁 계산 버튼들
648
+ btn_5.click(
649
+ fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(5, sub, *qty),
650
+ inputs=[subtotal_display] + quantity_inputs,
651
+ outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
652
+ )
653
+ btn_10.click(
654
+ fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(10, sub, *qty),
655
+ inputs=[subtotal_display] + quantity_inputs,
656
+ outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
657
+ )
658
+ btn_15.click(
659
+ fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(15, sub, *qty),
660
+ inputs=[subtotal_display] + quantity_inputs,
661
+ outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
662
+ )
663
+ btn_20.click(
664
+ fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(20, sub, *qty),
665
+ inputs=[subtotal_display] + quantity_inputs,
666
+ outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
667
+ )
668
+ btn_25.click(
669
+ fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(25, sub, *qty),
670
+ inputs=[subtotal_display] + quantity_inputs,
671
+ outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
672
+ )
673
+
674
+ # 결제 버튼
675
+ payment_btn.click(
676
+ fn=self.ui_handler.process_payment,
677
+ inputs=[total_bill_display],
678
+ outputs=[payment_result]
679
+ )
680
+
681
+ return interface
682
+
683
+ def run_gradio(self):
684
+ """Gradio 서버 실행"""
685
+ interface = self.create_gradio_blocks()
686
+ interface.launch(share=True)
687
+
688
+ def run_flask(self):
689
+ """Flask 서버 실행 (원한다면)"""
690
+
691
+ @self.flask_app.route("/")
692
+ def index():
693
+ return "Hello Flask"
694
+
695
+ self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
696
+
697
 
698
+ if __name__ == "__main__":
699
+ app = App()
700
+ # Gradio UI 실행
701
+ app.run_gradio()
702
 
703
+ # Flask 서버 실행 (원하면 아래 주석 해제)
704
+ # app.run_flask()