- .gitattributes +0 -35
- app.py +229 -49
- space.yaml +0 -4
.gitattributes
CHANGED
@@ -1,37 +1,2 @@
|
|
1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
*.task filter=lfs diff=lfs merge=lfs -text
|
37 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
*.task filter=lfs diff=lfs merge=lfs -text
|
2 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
app.py
CHANGED
@@ -4,10 +4,163 @@ import cv2
|
|
4 |
import base64
|
5 |
import uuid
|
6 |
import re
|
|
|
|
|
7 |
from flask import Flask
|
8 |
import gradio as gr
|
9 |
|
10 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
class Config:
|
12 |
"""애플리케이션 설정 및 상수"""
|
13 |
FOOD_ITEMS = [
|
@@ -22,42 +175,60 @@ class Config:
|
|
22 |
]
|
23 |
# 알리바바 Qwen API 키 (기본값은 빈 문자열)
|
24 |
QWEN_API_KEY = ""
|
25 |
-
|
26 |
DEFAULT_PROMPT_TEMPLATE = (
|
27 |
"### Persona ###\n"
|
28 |
-
"You are an expert tip calculation assistant focusing on service quality observed in a video.\n\n"
|
|
|
29 |
"### Task ###\n"
|
30 |
-
"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"
|
31 |
-
"2. Identify the bill amount
|
32 |
-
"3.
|
33 |
-
"
|
34 |
-
"
|
35 |
-
"
|
36 |
-
"
|
37 |
-
"
|
38 |
-
"
|
39 |
-
"
|
40 |
-
"
|
41 |
-
"
|
42 |
-
"
|
43 |
-
"
|
44 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
" - Current Country: USA\n"
|
46 |
" - Restaurant Name: The Golden Spoon (Assumed)\n"
|
47 |
" - Calculated Subtotal: ${calculated_subtotal:.2f}\n"
|
48 |
" - User Star Rating: {star_rating} / 5\n"
|
49 |
" - Currently User Review: {user_review}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
"### Input ###\n"
|
51 |
"Video Caption:\n{{caption_text}}\n\n"
|
|
|
52 |
"### Output ###\n"
|
53 |
"Return your answer in the exact format below:\n"
|
54 |
"Video Text Analysis: [Summary of the observed actions and interactions of the staff in the video.]\n"
|
55 |
-
"Analysis: [
|
56 |
-
"
|
57 |
-
"
|
58 |
-
"
|
|
|
|
|
|
|
|
|
59 |
)
|
60 |
-
|
61 |
CUSTOM_CSS = """
|
62 |
#food-container {
|
63 |
display: grid;
|
@@ -77,14 +248,18 @@ class Config:
|
|
77 |
"""
|
78 |
|
79 |
def __init__(self):
|
|
|
80 |
if not os.path.exists("images"):
|
81 |
print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
|
82 |
for item in self.FOOD_ITEMS:
|
83 |
if not os.path.exists(item["image"]):
|
84 |
print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
|
|
|
|
|
|
|
|
|
85 |
|
86 |
-
|
87 |
-
# --- ModelClients (알리바바 Qwen API만 사용) ---
|
88 |
class ModelClients:
|
89 |
def __init__(self, config: Config):
|
90 |
self.config = config
|
@@ -98,8 +273,7 @@ class ModelClients:
|
|
98 |
with open(video_path, "rb") as video_file:
|
99 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
100 |
|
101 |
-
|
102 |
-
# --- VideoProcessor: 비디오 프레임 추출 ---
|
103 |
class VideoProcessor:
|
104 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
105 |
if not video_path:
|
@@ -114,7 +288,7 @@ class VideoProcessor:
|
|
114 |
frame_paths = []
|
115 |
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
116 |
if not frame_rate or frame_rate == 0:
|
117 |
-
print("경고: FPS를 읽을 수 없습니다, 기본값 4
|
118 |
frame_rate = 4.0
|
119 |
frame_interval = int(frame_rate / fps) if fps > 0 else 1
|
120 |
if frame_interval <= 0:
|
@@ -162,8 +336,7 @@ class VideoProcessor:
|
|
162 |
except OSError as e:
|
163 |
print(f"프레임 폴더 삭제 오류: {e}")
|
164 |
|
165 |
-
|
166 |
-
# --- TipCalculator (알리바바 Qwen API를 사용한 팁 계산) ---
|
167 |
class TipCalculator:
|
168 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
169 |
self.config = config
|
@@ -171,18 +344,21 @@ class TipCalculator:
|
|
171 |
self.video_processor = video_processor
|
172 |
|
173 |
def parse_llm_output(self, output_text):
|
|
|
174 |
analysis = "Analysis not found."
|
175 |
tip_percentage = 0.0
|
176 |
tip_amount = 0.0
|
177 |
total_bill = 0.0
|
178 |
-
|
|
|
179 |
if analysis_match:
|
180 |
analysis = analysis_match.group(1).strip()
|
181 |
else:
|
182 |
analysis_match_alt = re.search(r"Analysis:\s*(.*)", output_text, re.DOTALL | re.IGNORECASE)
|
183 |
if analysis_match_alt:
|
184 |
analysis = analysis_match_alt.group(1).strip()
|
185 |
-
|
|
|
186 |
re.DOTALL | re.IGNORECASE)
|
187 |
if percentage_match:
|
188 |
try:
|
@@ -190,7 +366,8 @@ class TipCalculator:
|
|
190 |
except ValueError:
|
191 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
192 |
tip_percentage = 0.0
|
193 |
-
|
|
|
194 |
if tip_match:
|
195 |
try:
|
196 |
tip_amount = float(tip_match.group(1))
|
@@ -199,14 +376,17 @@ class TipCalculator:
|
|
199 |
tip_amount = 0.0
|
200 |
else:
|
201 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
202 |
-
|
|
|
203 |
if total_match:
|
204 |
try:
|
205 |
total_bill = float(total_match.group(1))
|
206 |
except ValueError:
|
207 |
print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
|
|
|
208 |
if len(analysis) < 20 and analysis == "Analysis not found.":
|
209 |
analysis = output_text
|
|
|
210 |
return analysis, tip_percentage, tip_amount, output_text
|
211 |
|
212 |
def process_tip_qwen(self, video_file_path, star_rating, user_review, calculated_subtotal, custom_prompt=None):
|
@@ -246,24 +426,28 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
246 |
if not caption_text.strip():
|
247 |
caption_text = "(No caption from Omni)"
|
248 |
user_review = user_review.strip() if user_review else "(No user review)"
|
|
|
249 |
if custom_prompt is None:
|
250 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
251 |
calculated_subtotal=calculated_subtotal,
|
252 |
star_rating=star_rating,
|
253 |
-
user_review=user_review
|
|
|
254 |
)
|
255 |
else:
|
256 |
try:
|
257 |
prompt = custom_prompt.format(
|
258 |
calculated_subtotal=calculated_subtotal,
|
259 |
star_rating=star_rating,
|
260 |
-
user_review=user_review
|
|
|
261 |
)
|
262 |
except:
|
263 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
264 |
calculated_subtotal=calculated_subtotal,
|
265 |
star_rating=star_rating,
|
266 |
-
user_review=user_review
|
|
|
267 |
)
|
268 |
final_prompt = prompt.replace("{caption_text}", caption_text)
|
269 |
qvq_result = self.model_clients.qwen_client.chat.completions.create(
|
@@ -302,8 +486,7 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
302 |
total_bill_output = f"${total_bill:.2f}"
|
303 |
return analysis_output, tip_output, total_bill_output
|
304 |
|
305 |
-
|
306 |
-
# --- UIHandler: Gradio 인터페이스 이벤트 처리 (알리바바 API 키 입력 포함) ---
|
307 |
class UIHandler:
|
308 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
309 |
self.config = config
|
@@ -322,7 +505,8 @@ class UIHandler:
|
|
322 |
updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
323 |
calculated_subtotal=calculated_subtotal,
|
324 |
star_rating=star_rating,
|
325 |
-
user_review=user_review_text
|
|
|
326 |
)
|
327 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
328 |
return calculated_subtotal, updated_prompt
|
@@ -409,8 +593,7 @@ class UIHandler:
|
|
409 |
def process_payment(self, total_bill):
|
410 |
return f"{total_bill} 결제되었습니다."
|
411 |
|
412 |
-
|
413 |
-
# --- App: 모든 컴포넌트 연결 및 Gradio 인터페이스 실행 ---
|
414 |
class App:
|
415 |
def __init__(self):
|
416 |
self.config = Config()
|
@@ -421,8 +604,7 @@ class App:
|
|
421 |
self.flask_app = Flask(__name__)
|
422 |
|
423 |
def create_gradio_blocks(self):
|
424 |
-
with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
|
425 |
-
css=self.config.CUSTOM_CSS) as interface:
|
426 |
gr.Markdown("## Video Tip Calculation Interface (Structured)")
|
427 |
quantity_inputs = []
|
428 |
subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
|
@@ -480,7 +662,8 @@ class App:
|
|
480 |
value=self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
481 |
calculated_subtotal=0.0,
|
482 |
star_rating=3,
|
483 |
-
user_review="(No user review provided)"
|
|
|
484 |
).replace("{caption_text}", "{{caption_text}}")
|
485 |
)
|
486 |
gr.Markdown("### 6. AI Analysis")
|
@@ -507,9 +690,7 @@ class App:
|
|
507 |
outputs=order_summary_display
|
508 |
)
|
509 |
compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
|
510 |
-
compute_outputs = [
|
511 |
-
analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
|
512 |
-
]
|
513 |
qwen_btn.click(
|
514 |
fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
|
515 |
alibaba_key, vid, sub, rat, rev, prom, *qty
|
@@ -559,7 +740,6 @@ class App:
|
|
559 |
return "Hello Flask"
|
560 |
self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
|
561 |
|
562 |
-
|
563 |
if __name__ == "__main__":
|
564 |
app = App()
|
565 |
app.run_gradio()
|
|
|
4 |
import base64
|
5 |
import uuid
|
6 |
import re
|
7 |
+
import time
|
8 |
+
import pandas as pd
|
9 |
from flask import Flask
|
10 |
import gradio as gr
|
11 |
|
12 |
+
# Selenium 관련 임포트 (구글 리뷰 크롤링용)
|
13 |
+
from selenium import webdriver
|
14 |
+
from selenium.webdriver.common.by import By
|
15 |
+
from selenium.webdriver.chrome.service import Service
|
16 |
+
from selenium.webdriver.support.ui import WebDriverWait
|
17 |
+
from selenium.webdriver.support import expected_conditions as EC
|
18 |
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
19 |
+
from webdriver_manager.chrome import ChromeDriverManager
|
20 |
+
|
21 |
+
# --- GoogleReviewManager: 구글 리뷰 크롤링 및 포맷팅 ---
|
22 |
+
class GoogleReviewManager:
|
23 |
+
"""
|
24 |
+
구글 리뷰 크롤링을 통해 리뷰 데이터를 가져와 텍스트로 저장하고,
|
25 |
+
프롬프트에 삽입할 리뷰 문자열을 생성하는 클래스.
|
26 |
+
"""
|
27 |
+
def __init__(self, url, target_review_count=2):
|
28 |
+
self.url = url
|
29 |
+
self.target_review_count = target_review_count
|
30 |
+
self.reviews_text = self.fetch_reviews_text()
|
31 |
+
|
32 |
+
def fetch_reviews_text(self):
|
33 |
+
df_reviews = self.google_review_crawling(self.target_review_count, self.url)
|
34 |
+
if df_reviews.empty:
|
35 |
+
return "(구글 리뷰를 불러오지 못했습니다.)"
|
36 |
+
reviews = []
|
37 |
+
for index, row in df_reviews.iterrows():
|
38 |
+
# 예: [4.5 stars] Excellent service.
|
39 |
+
reviews.append(f"[{row['Rating']} stars] {row['Review Text']}")
|
40 |
+
return "\n".join(reviews)
|
41 |
+
|
42 |
+
@staticmethod
|
43 |
+
def format_google_reviews(reviews_text):
|
44 |
+
# 각 줄로 분리한 후 "####"가 없는 순수 리뷰만 선택
|
45 |
+
reviews = [line for line in reviews_text.split("\n") if line.strip() and "####" not in line]
|
46 |
+
formatted_reviews = []
|
47 |
+
for i, review in enumerate(reviews, start=1):
|
48 |
+
formatted_reviews.append(f"#### Google Review {i} ####\n{review}")
|
49 |
+
return "\n\n".join(formatted_reviews)
|
50 |
+
|
51 |
+
def google_review_crawling(self, TARGET_REVIEW_COUNT, url):
|
52 |
+
try:
|
53 |
+
service = Service(ChromeDriverManager().install())
|
54 |
+
options = webdriver.ChromeOptions()
|
55 |
+
options.add_argument("--headless=new")
|
56 |
+
options.add_argument("--disable-gpu")
|
57 |
+
options.add_argument("--window-size=600,600")
|
58 |
+
options.add_argument("--lang=en")
|
59 |
+
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
|
60 |
+
driver = webdriver.Chrome(service=service, options=options)
|
61 |
+
print("웹 드라이버 설정 완료 (헤드리스 모드).")
|
62 |
+
except Exception as e:
|
63 |
+
print(f"웹 드라이버 설정 중 오류 발생: {e}")
|
64 |
+
return pd.DataFrame()
|
65 |
+
|
66 |
+
reviews_data = []
|
67 |
+
processed_keys = set()
|
68 |
+
try:
|
69 |
+
driver.get(url)
|
70 |
+
time.sleep(3)
|
71 |
+
driver.execute_script("document.body.style.zoom = '0.7'")
|
72 |
+
# 리뷰 탭 버튼 클릭
|
73 |
+
review_tab_button = None
|
74 |
+
possible_review_selectors = [
|
75 |
+
(By.XPATH, "//button[contains(text(), 'Reviews')]"),
|
76 |
+
(By.CSS_SELECTOR, "button[aria-label*='Reviews']"),
|
77 |
+
]
|
78 |
+
wait = WebDriverWait(driver, 10)
|
79 |
+
for selector in possible_review_selectors:
|
80 |
+
try:
|
81 |
+
review_tab_button = wait.until(EC.element_to_be_clickable(selector))
|
82 |
+
break
|
83 |
+
except TimeoutException:
|
84 |
+
continue
|
85 |
+
if review_tab_button:
|
86 |
+
review_tab_button.click()
|
87 |
+
time.sleep(3)
|
88 |
+
|
89 |
+
# 최신순 정렬 (선택사항)
|
90 |
+
try:
|
91 |
+
sort_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(@aria-label, 'Sort')]")))
|
92 |
+
sort_button.click()
|
93 |
+
time.sleep(1)
|
94 |
+
newest_option = wait.until(EC.element_to_be_clickable((By.XPATH, "//div[@role='menuitemradio'][contains(., 'Newest')]")))
|
95 |
+
newest_option.click()
|
96 |
+
time.sleep(3)
|
97 |
+
except Exception as e:
|
98 |
+
print(f"정렬 설정 오류: {e}")
|
99 |
+
|
100 |
+
scrollable_div = None
|
101 |
+
try:
|
102 |
+
scrollable_div = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "div.m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde[tabindex='-1']")))
|
103 |
+
except TimeoutException:
|
104 |
+
print("리뷰 스크롤 영역을 찾지 못했습니다.")
|
105 |
+
|
106 |
+
review_elements_selector = (By.CSS_SELECTOR, "div.jftiEf.fontBodyMedium")
|
107 |
+
loop_count = 0
|
108 |
+
max_loop = 50
|
109 |
+
while len(reviews_data) < TARGET_REVIEW_COUNT and loop_count < max_loop:
|
110 |
+
loop_count += 1
|
111 |
+
prev_count = len(reviews_data)
|
112 |
+
all_reviews = driver.find_elements(*review_elements_selector)
|
113 |
+
for review in all_reviews:
|
114 |
+
try:
|
115 |
+
reviewer_name = review.find_element(By.CSS_SELECTOR, "div.d4r55").text
|
116 |
+
except Exception:
|
117 |
+
reviewer_name = "N/A"
|
118 |
+
try:
|
119 |
+
review_date = review.find_element(By.CSS_SELECTOR, "span.rsqaWe").text
|
120 |
+
except Exception:
|
121 |
+
review_date = "N/A"
|
122 |
+
unique_key = reviewer_name + review_date
|
123 |
+
if unique_key in processed_keys:
|
124 |
+
continue
|
125 |
+
processed_keys.add(unique_key)
|
126 |
+
try:
|
127 |
+
# 클릭하여 전체 텍스트 표시 (더보기)
|
128 |
+
more_button = review.find_element(By.CSS_SELECTOR, "button.w8nwRe.kyuRq")
|
129 |
+
driver.execute_script("arguments[0].click();", more_button)
|
130 |
+
time.sleep(0.3)
|
131 |
+
except Exception:
|
132 |
+
pass
|
133 |
+
try:
|
134 |
+
review_text = review.find_element(By.CSS_SELECTOR, "span.wiI7pd").text.strip()
|
135 |
+
except Exception:
|
136 |
+
review_text = ""
|
137 |
+
if review_text:
|
138 |
+
try:
|
139 |
+
rating = review.find_element(By.CSS_SELECTOR, "span.kvMYJc").get_attribute("aria-label")
|
140 |
+
except Exception:
|
141 |
+
rating = "N/A"
|
142 |
+
reviews_data.append({"Rating": rating, "Review Text": review_text})
|
143 |
+
if len(reviews_data) >= TARGET_REVIEW_COUNT:
|
144 |
+
break
|
145 |
+
if len(reviews_data) == prev_count:
|
146 |
+
break
|
147 |
+
if scrollable_div:
|
148 |
+
for _ in range(20):
|
149 |
+
driver.execute_script('arguments[0].scrollBy(0, 1000);', scrollable_div)
|
150 |
+
time.sleep(0.1)
|
151 |
+
time.sleep(2)
|
152 |
+
if reviews_data:
|
153 |
+
df = pd.DataFrame(reviews_data[:TARGET_REVIEW_COUNT])
|
154 |
+
else:
|
155 |
+
df = pd.DataFrame()
|
156 |
+
except Exception as e:
|
157 |
+
print(f"리뷰 크롤링 중 오류: {e}")
|
158 |
+
df = pd.DataFrame()
|
159 |
+
finally:
|
160 |
+
driver.quit()
|
161 |
+
return df
|
162 |
+
|
163 |
+
# --- Config 클래스 (Qwen, 구글 리뷰, 새로운 프롬프트 포함) ---
|
164 |
class Config:
|
165 |
"""애플리케이션 설정 및 상수"""
|
166 |
FOOD_ITEMS = [
|
|
|
175 |
]
|
176 |
# 알리바바 Qwen API 키 (기본값은 빈 문자열)
|
177 |
QWEN_API_KEY = ""
|
178 |
+
# 새로운 프롬프트 템플릿 (구글 리뷰 분석 포함)
|
179 |
DEFAULT_PROMPT_TEMPLATE = (
|
180 |
"### Persona ###\n"
|
181 |
+
"You are an expert tip calculation assistant focusing on service quality observed in a video, and you also consider the user's review when evaluating the overall experience.\n\n"
|
182 |
+
|
183 |
"### Task ###\n"
|
184 |
+
"1. **Video Analysis**: Watch the video frames provided (via caption) and analyze the service provided by the staff. Provide a concise summary of the observed actions and interactions in the video.\n\n"
|
185 |
+
"2. **Bill Amount Determination**: Identify the bill amount by first looking 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\n"
|
186 |
+
"3. **Service Quality Classification**:\n"
|
187 |
+
" - Based *only* on the actions and service quality observed in the video (as described in the Video Caption) **and** the user's review/rating,\n"
|
188 |
+
" classify the service quality as one of the following:\n"
|
189 |
+
" - Poor\n"
|
190 |
+
" - Average\n"
|
191 |
+
" - Good\n"
|
192 |
+
" - Explain your reasoning for the classification with specific observations from both the video and the user's review.\n\n"
|
193 |
+
"4. **Tip Guidelines and Calculation**:\n"
|
194 |
+
" - Apply the following tip guidelines based *only* on the classified service quality:\n"
|
195 |
+
" - Poor Service: 0% ~ 5% of the bill\n"
|
196 |
+
" - Average Service: 10% ~ 15% of the bill\n"
|
197 |
+
" - Good Service: 15% ~ 20% of the bill\n"
|
198 |
+
" - Choose a specific tip percentage within the suggested range.\n"
|
199 |
+
" - Calculate the tip amount by multiplying the determined bill amount by the chosen percentage (round to two decimal places).\n"
|
200 |
+
" - Calculate the total bill by adding the tip amount to the subtotal (round to two decimal places).\n\n"
|
201 |
+
"5. **Google Review Consideration**:\n"
|
202 |
+
" - If the Recent Google Reviews are predominantly racist, extremely negative, or report bugs (e.g., insects or other quality issues),\n"
|
203 |
+
" you must reflect this by applying a significantly lower tip percentage (preferably within the 0% to 5% range) in your calculation.\n\n"
|
204 |
+
|
205 |
+
"### User Context ###\n"
|
206 |
" - Current Country: USA\n"
|
207 |
" - Restaurant Name: The Golden Spoon (Assumed)\n"
|
208 |
" - Calculated Subtotal: ${calculated_subtotal:.2f}\n"
|
209 |
" - User Star Rating: {star_rating} / 5\n"
|
210 |
" - Currently User Review: {user_review}\n\n"
|
211 |
+
|
212 |
+
"### Recent Google Review ###\n\n"
|
213 |
+
"{google_reviews}\n\n"
|
214 |
+
"#### Recent Google Review 3 ####\n"
|
215 |
+
"[1.0 stars] It was a very racist remark, 매우 인종차별적인 발언을 겪었어요. 모욕적입니다,,\n\n"
|
216 |
+
|
217 |
"### Input ###\n"
|
218 |
"Video Caption:\n{{caption_text}}\n\n"
|
219 |
+
|
220 |
"### Output ###\n"
|
221 |
"Return your answer in the exact format below:\n"
|
222 |
"Video Text Analysis: [Summary of the observed actions and interactions of the staff in the video.]\n"
|
223 |
+
"Recent Google Review Analysis : [Check if the recent Google Review is predominantly racist, extremely negative, or report bugs (e.g., insects or other quality issues).]\n"
|
224 |
+
"Analysis: [Step-by-step explanation detailing:\n"
|
225 |
+
" - How you determined the bill amount;\n"
|
226 |
+
" - Your reasoning for the service quality classification should incorporate specific observations from the video (as described in the Video Caption), as well as a thorough analysis of the Recent Google Reviews, the user's review, and the user's star rating;\n"
|
227 |
+
" - How you chose the tip percentage within the guideline range, including the calculation details.]\n"
|
228 |
+
"Final Tip Percentage: [X]%\n"
|
229 |
+
"Final Tip Amount: $[Calculated Tip]\n"
|
230 |
+
"Final Total Bill: $[Subtotal + Tip]"
|
231 |
)
|
|
|
232 |
CUSTOM_CSS = """
|
233 |
#food-container {
|
234 |
display: grid;
|
|
|
248 |
"""
|
249 |
|
250 |
def __init__(self):
|
251 |
+
# 이미지 폴더 및 파일 확인
|
252 |
if not os.path.exists("images"):
|
253 |
print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
|
254 |
for item in self.FOOD_ITEMS:
|
255 |
if not os.path.exists(item["image"]):
|
256 |
print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
|
257 |
+
# 구글 리뷰 크롤링: 원하는 구글 리뷰 URL을 입력 (아래 예시는 임의 URL)
|
258 |
+
review_url = "https://www.google.com/maps/place/Wolfgang%E2%80%99s+Steakhouse/data=!3m1!4b1!4m6!3m5!1s0x357ca4778cdd1105:0x27d5ead252b66bfd!8m2!3d37.5244965!4d127.0414635!16s%2Fg%2F11c3pwpp26?hl=en&entry=ttu"
|
259 |
+
self.google_review_manager = GoogleReviewManager(review_url, target_review_count=2)
|
260 |
+
self.GOOGLE_REVIEWS = GoogleReviewManager.format_google_reviews(self.google_review_manager.reviews_text)
|
261 |
|
262 |
+
# --- ModelClients: 알리바바 Qwen API만 사용 ---
|
|
|
263 |
class ModelClients:
|
264 |
def __init__(self, config: Config):
|
265 |
self.config = config
|
|
|
273 |
with open(video_path, "rb") as video_file:
|
274 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
275 |
|
276 |
+
# --- VideoProcessor: 비디오 프레임 추출 및 임시 파일 정리 ---
|
|
|
277 |
class VideoProcessor:
|
278 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
279 |
if not video_path:
|
|
|
288 |
frame_paths = []
|
289 |
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
290 |
if not frame_rate or frame_rate == 0:
|
291 |
+
print("경고: FPS를 읽을 수 없습니다, 기본값 4로 설정합니다.")
|
292 |
frame_rate = 4.0
|
293 |
frame_interval = int(frame_rate / fps) if fps > 0 else 1
|
294 |
if frame_interval <= 0:
|
|
|
336 |
except OSError as e:
|
337 |
print(f"프레임 폴더 삭제 오류: {e}")
|
338 |
|
339 |
+
# --- TipCalculator: 알리바바 Qwen API를 사용한 팁 계산 및 파싱 ---
|
|
|
340 |
class TipCalculator:
|
341 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
342 |
self.config = config
|
|
|
344 |
self.video_processor = video_processor
|
345 |
|
346 |
def parse_llm_output(self, output_text):
|
347 |
+
"""LLM 출력을 파싱하여 팁 계산 결과 추출"""
|
348 |
analysis = "Analysis not found."
|
349 |
tip_percentage = 0.0
|
350 |
tip_amount = 0.0
|
351 |
total_bill = 0.0
|
352 |
+
|
353 |
+
analysis_match = re.search(r"Analysis:\s*(.*?)Final Tip Percentage:", output_text, re.DOTALL | re.IGNORECASE)
|
354 |
if analysis_match:
|
355 |
analysis = analysis_match.group(1).strip()
|
356 |
else:
|
357 |
analysis_match_alt = re.search(r"Analysis:\s*(.*)", output_text, re.DOTALL | re.IGNORECASE)
|
358 |
if analysis_match_alt:
|
359 |
analysis = analysis_match_alt.group(1).strip()
|
360 |
+
|
361 |
+
percentage_match = re.search(r"Final Tip Percentage:\s*\*{0,2}(\d+(?:\.\d+)?)%\*{0,2}", output_text,
|
362 |
re.DOTALL | re.IGNORECASE)
|
363 |
if percentage_match:
|
364 |
try:
|
|
|
366 |
except ValueError:
|
367 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
368 |
tip_percentage = 0.0
|
369 |
+
|
370 |
+
tip_match = re.search(r"Final Tip Amount:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
|
371 |
if tip_match:
|
372 |
try:
|
373 |
tip_amount = float(tip_match.group(1))
|
|
|
376 |
tip_amount = 0.0
|
377 |
else:
|
378 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
379 |
+
|
380 |
+
total_match = re.search(r"Final Total Bill:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
|
381 |
if total_match:
|
382 |
try:
|
383 |
total_bill = float(total_match.group(1))
|
384 |
except ValueError:
|
385 |
print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
|
386 |
+
|
387 |
if len(analysis) < 20 and analysis == "Analysis not found.":
|
388 |
analysis = output_text
|
389 |
+
|
390 |
return analysis, tip_percentage, tip_amount, output_text
|
391 |
|
392 |
def process_tip_qwen(self, video_file_path, star_rating, user_review, calculated_subtotal, custom_prompt=None):
|
|
|
426 |
if not caption_text.strip():
|
427 |
caption_text = "(No caption from Omni)"
|
428 |
user_review = user_review.strip() if user_review else "(No user review)"
|
429 |
+
# 새로운 프롬프트 템플릿에 구글 리뷰를 포함하도록 업데이트
|
430 |
if custom_prompt is None:
|
431 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
432 |
calculated_subtotal=calculated_subtotal,
|
433 |
star_rating=star_rating,
|
434 |
+
user_review=user_review,
|
435 |
+
google_reviews=self.config.GOOGLE_REVIEWS
|
436 |
)
|
437 |
else:
|
438 |
try:
|
439 |
prompt = custom_prompt.format(
|
440 |
calculated_subtotal=calculated_subtotal,
|
441 |
star_rating=star_rating,
|
442 |
+
user_review=user_review,
|
443 |
+
google_reviews=self.config.GOOGLE_REVIEWS
|
444 |
)
|
445 |
except:
|
446 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
447 |
calculated_subtotal=calculated_subtotal,
|
448 |
star_rating=star_rating,
|
449 |
+
user_review=user_review,
|
450 |
+
google_reviews=self.config.GOOGLE_REVIEWS
|
451 |
)
|
452 |
final_prompt = prompt.replace("{caption_text}", caption_text)
|
453 |
qvq_result = self.model_clients.qwen_client.chat.completions.create(
|
|
|
486 |
total_bill_output = f"${total_bill:.2f}"
|
487 |
return analysis_output, tip_output, total_bill_output
|
488 |
|
489 |
+
# --- UIHandler: Gradio 인터페이스 이벤트 및 Alibaba API 키 업데이트 처리 ---
|
|
|
490 |
class UIHandler:
|
491 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
492 |
self.config = config
|
|
|
505 |
updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
506 |
calculated_subtotal=calculated_subtotal,
|
507 |
star_rating=star_rating,
|
508 |
+
user_review=user_review_text,
|
509 |
+
google_reviews=self.config.GOOGLE_REVIEWS
|
510 |
)
|
511 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
512 |
return calculated_subtotal, updated_prompt
|
|
|
593 |
def process_payment(self, total_bill):
|
594 |
return f"{total_bill} 결제되었습니다."
|
595 |
|
596 |
+
# --- App: 모든 컴포넌트를 연결하여 Gradio 인터페이스 실행 ---
|
|
|
597 |
class App:
|
598 |
def __init__(self):
|
599 |
self.config = Config()
|
|
|
604 |
self.flask_app = Flask(__name__)
|
605 |
|
606 |
def create_gradio_blocks(self):
|
607 |
+
with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(), css=self.config.CUSTOM_CSS) as interface:
|
|
|
608 |
gr.Markdown("## Video Tip Calculation Interface (Structured)")
|
609 |
quantity_inputs = []
|
610 |
subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
|
|
|
662 |
value=self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
663 |
calculated_subtotal=0.0,
|
664 |
star_rating=3,
|
665 |
+
user_review="(No user review provided)",
|
666 |
+
google_reviews=self.config.GOOGLE_REVIEWS
|
667 |
).replace("{caption_text}", "{{caption_text}}")
|
668 |
)
|
669 |
gr.Markdown("### 6. AI Analysis")
|
|
|
690 |
outputs=order_summary_display
|
691 |
)
|
692 |
compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
|
693 |
+
compute_outputs = [analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display]
|
|
|
|
|
694 |
qwen_btn.click(
|
695 |
fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
|
696 |
alibaba_key, vid, sub, rat, rev, prom, *qty
|
|
|
740 |
return "Hello Flask"
|
741 |
self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
|
742 |
|
|
|
743 |
if __name__ == "__main__":
|
744 |
app = App()
|
745 |
app.run_gradio()
|
space.yaml
DELETED
@@ -1,4 +0,0 @@
|
|
1 |
-
runtime: python
|
2 |
-
python_version: "3.10"
|
3 |
-
hardware:
|
4 |
-
gpu: false
|
|
|
|
|
|
|
|
|
|