Add application file
Browse files
app.py
CHANGED
@@ -1,7 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
-
|
4 |
-
|
|
|
|
|
5 |
|
6 |
-
|
7 |
-
|
|
|
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()
|