- app.py +192 -86
- requirements.txt +2 -0
app.py
CHANGED
@@ -3,28 +3,27 @@ import shutil
|
|
3 |
import cv2
|
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 |
-
|
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=
|
28 |
self.url = url
|
29 |
self.target_review_count = target_review_count
|
30 |
self.reviews_text = self.fetch_reviews_text()
|
@@ -35,13 +34,14 @@ class GoogleReviewManager:
|
|
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):
|
@@ -55,61 +55,116 @@ class GoogleReviewManager:
|
|
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(
|
|
|
60 |
driver = webdriver.Chrome(service=service, options=options)
|
61 |
print("웹 드라이버 설정 완료 (헤드리스 모드).")
|
62 |
except Exception as e:
|
63 |
print(f"웹 드라이버 설정 중 오류 발생: {e}")
|
64 |
-
|
65 |
|
66 |
reviews_data = []
|
67 |
-
processed_keys = set()
|
|
|
68 |
try:
|
69 |
driver.get(url)
|
|
|
70 |
time.sleep(3)
|
71 |
-
|
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 |
-
|
79 |
for selector in possible_review_selectors:
|
80 |
try:
|
81 |
-
review_tab_button =
|
|
|
|
|
|
|
82 |
break
|
83 |
except TimeoutException:
|
84 |
continue
|
85 |
-
if review_tab_button:
|
86 |
-
|
|
|
|
|
87 |
time.sleep(3)
|
88 |
|
89 |
-
# 최신순
|
90 |
try:
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
92 |
sort_button.click()
|
93 |
time.sleep(1)
|
94 |
-
|
|
|
|
|
|
|
|
|
95 |
newest_option.click()
|
|
|
96 |
time.sleep(3)
|
97 |
-
except
|
98 |
-
print(f"정렬
|
|
|
99 |
|
100 |
-
|
|
|
101 |
try:
|
102 |
-
scrollable_div =
|
|
|
|
|
|
|
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 |
-
|
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
|
@@ -123,44 +178,69 @@ class GoogleReviewManager:
|
|
123 |
if unique_key in processed_keys:
|
124 |
continue
|
125 |
processed_keys.add(unique_key)
|
126 |
-
|
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 |
-
|
|
|
140 |
except Exception:
|
141 |
rating = "N/A"
|
142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
143 |
if len(reviews_data) >= TARGET_REVIEW_COUNT:
|
144 |
break
|
145 |
-
|
|
|
|
|
146 |
break
|
|
|
147 |
if scrollable_div:
|
148 |
-
for
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
else:
|
155 |
-
|
156 |
except Exception as e:
|
157 |
-
print(f"
|
158 |
-
|
159 |
finally:
|
160 |
-
driver
|
161 |
-
|
162 |
|
163 |
-
|
|
|
|
|
164 |
class Config:
|
165 |
"""애플리케이션 설정 및 상수"""
|
166 |
FOOD_ITEMS = [
|
@@ -175,7 +255,7 @@ class Config:
|
|
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"
|
@@ -224,11 +304,17 @@ class Config:
|
|
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 |
-
"
|
229 |
-
"Final Tip
|
230 |
-
"Final
|
|
|
|
|
|
|
|
|
|
|
231 |
)
|
|
|
232 |
CUSTOM_CSS = """
|
233 |
#food-container {
|
234 |
display: grid;
|
@@ -248,18 +334,21 @@ class Config:
|
|
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 |
-
|
|
|
263 |
class ModelClients:
|
264 |
def __init__(self, config: Config):
|
265 |
self.config = config
|
@@ -273,7 +362,8 @@ class ModelClients:
|
|
273 |
with open(video_path, "rb") as video_file:
|
274 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
275 |
|
276 |
-
|
|
|
277 |
class VideoProcessor:
|
278 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
279 |
if not video_path:
|
@@ -288,7 +378,7 @@ class VideoProcessor:
|
|
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,7 +426,8 @@ class VideoProcessor:
|
|
336 |
except OSError as e:
|
337 |
print(f"프레임 폴더 삭제 오류: {e}")
|
338 |
|
339 |
-
|
|
|
340 |
class TipCalculator:
|
341 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
342 |
self.config = config
|
@@ -350,7 +441,9 @@ class TipCalculator:
|
|
350 |
tip_amount = 0.0
|
351 |
total_bill = 0.0
|
352 |
|
353 |
-
|
|
|
|
|
354 |
if analysis_match:
|
355 |
analysis = analysis_match.group(1).strip()
|
356 |
else:
|
@@ -358,7 +451,8 @@ class TipCalculator:
|
|
358 |
if analysis_match_alt:
|
359 |
analysis = analysis_match_alt.group(1).strip()
|
360 |
|
361 |
-
|
|
|
362 |
re.DOTALL | re.IGNORECASE)
|
363 |
if percentage_match:
|
364 |
try:
|
@@ -367,7 +461,8 @@ class TipCalculator:
|
|
367 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
368 |
tip_percentage = 0.0
|
369 |
|
370 |
-
|
|
|
371 |
if tip_match:
|
372 |
try:
|
373 |
tip_amount = float(tip_match.group(1))
|
@@ -377,13 +472,13 @@ class TipCalculator:
|
|
377 |
else:
|
378 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
379 |
|
380 |
-
|
|
|
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 |
|
@@ -426,28 +521,24 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
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,7 +577,8 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
486 |
total_bill_output = f"${total_bill:.2f}"
|
487 |
return analysis_output, tip_output, total_bill_output
|
488 |
|
489 |
-
|
|
|
490 |
class UIHandler:
|
491 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
492 |
self.config = config
|
@@ -494,21 +586,29 @@ class UIHandler:
|
|
494 |
self.video_processor = video_processor
|
495 |
|
496 |
def update_subtotal_and_prompt(self, *args):
|
|
|
497 |
num_food_items = len(self.config.FOOD_ITEMS)
|
498 |
quantities = args[:num_food_items]
|
499 |
star_rating = args[num_food_items]
|
500 |
user_review = args[num_food_items + 1]
|
|
|
501 |
calculated_subtotal = 0.0
|
502 |
for i in range(num_food_items):
|
503 |
calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
|
|
|
504 |
user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
|
|
|
|
|
|
|
|
|
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=
|
510 |
)
|
511 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
|
|
512 |
return calculated_subtotal, updated_prompt
|
513 |
|
514 |
def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
|
@@ -558,6 +658,7 @@ class UIHandler:
|
|
558 |
analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
|
559 |
alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
|
560 |
)
|
|
|
561 |
invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
|
562 |
return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
|
563 |
|
@@ -593,7 +694,8 @@ class UIHandler:
|
|
593 |
def process_payment(self, total_bill):
|
594 |
return f"{total_bill} 결제되었습니다."
|
595 |
|
596 |
-
|
|
|
597 |
class App:
|
598 |
def __init__(self):
|
599 |
self.config = Config()
|
@@ -604,7 +706,8 @@ class App:
|
|
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(),
|
|
|
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)
|
@@ -663,7 +766,7 @@ class App:
|
|
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,7 +793,9 @@ class App:
|
|
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 = [
|
|
|
|
|
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,6 +845,7 @@ class App:
|
|
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()
|
|
|
3 |
import cv2
|
4 |
import base64
|
5 |
import uuid
|
|
|
|
|
|
|
6 |
from flask import Flask
|
7 |
+
from ollama import Client
|
8 |
+
import openai
|
9 |
import gradio as gr
|
10 |
+
import re
|
11 |
+
import pandas as pd
|
12 |
from selenium import webdriver
|
13 |
from selenium.webdriver.common.by import By
|
14 |
from selenium.webdriver.chrome.service import Service
|
15 |
from selenium.webdriver.support.ui import WebDriverWait
|
16 |
from selenium.webdriver.support import expected_conditions as EC
|
|
|
17 |
from webdriver_manager.chrome import ChromeDriverManager
|
18 |
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
19 |
+
import time
|
20 |
|
|
|
21 |
class GoogleReviewManager:
|
22 |
"""
|
23 |
+
구글 리뷰 크롤링을 통해 리뷰 데이터를 한 번만 가져와 텍스트로 저장하고,
|
24 |
+
DEFAULT_PROMPT_TEMPLATE에 적용할 리뷰 문자열을 생성하는 클래스.
|
25 |
"""
|
26 |
+
def __init__(self, url, target_review_count=20):
|
27 |
self.url = url
|
28 |
self.target_review_count = target_review_count
|
29 |
self.reviews_text = self.fetch_reviews_text()
|
|
|
34 |
return "(구글 리뷰를 불러오지 못했습니다.)"
|
35 |
reviews = []
|
36 |
for index, row in df_reviews.iterrows():
|
37 |
+
# 예: [4.5 stars] Excellent service and food.
|
38 |
reviews.append(f"[{row['Rating']} stars] {row['Review Text']}")
|
39 |
+
# 각 리뷰를 개행 문자로 구분하여 하나의 문자열로 생성
|
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):
|
|
|
55 |
options.add_argument("--headless=new")
|
56 |
options.add_argument("--disable-gpu")
|
57 |
options.add_argument("--window-size=600,600")
|
58 |
+
# 언어 설정 (한글 리뷰를 원하면 "--lang=ko"로 변경)
|
59 |
options.add_argument("--lang=en")
|
60 |
+
options.add_argument(
|
61 |
+
"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")
|
62 |
driver = webdriver.Chrome(service=service, options=options)
|
63 |
print("웹 드라이버 설정 완료 (헤드리스 모드).")
|
64 |
except Exception as e:
|
65 |
print(f"웹 드라이버 설정 중 오류 발생: {e}")
|
66 |
+
exit()
|
67 |
|
68 |
reviews_data = []
|
69 |
+
processed_keys = set() # 중복 리뷰 방지를 위한 고유 키 저장
|
70 |
+
|
71 |
try:
|
72 |
driver.get(url)
|
73 |
+
print("Google Maps 접속 완료.")
|
74 |
time.sleep(3)
|
75 |
+
zoom_level = 0.7
|
76 |
+
driver.execute_script(f"document.body.style.zoom = '{zoom_level}'")
|
77 |
+
|
78 |
+
try:
|
79 |
+
overall_rating_selector = (By.CSS_SELECTOR, "div.F7nice span[aria-hidden='true']")
|
80 |
+
overall_rating_element = WebDriverWait(driver, 10).until(
|
81 |
+
EC.visibility_of_element_located(overall_rating_selector)
|
82 |
+
)
|
83 |
+
overall_rating_text = overall_rating_element.text
|
84 |
+
print(f"총 평점 찾음: {overall_rating_text}")
|
85 |
+
except (TimeoutException, NoSuchElementException):
|
86 |
+
print("총 평점 요소를 찾지 못했습니다.")
|
87 |
+
|
88 |
+
# 리뷰 탭으로 이동
|
89 |
review_tab_button = None
|
90 |
possible_review_selectors = [
|
91 |
+
(By.XPATH, "//button[@role='tab'][contains(., 'Reviews')]"),
|
92 |
(By.XPATH, "//button[contains(text(), 'Reviews')]"),
|
93 |
(By.CSS_SELECTOR, "button[aria-label*='Reviews']"),
|
94 |
]
|
95 |
+
wait_for_review_tab = WebDriverWait(driver, 10)
|
96 |
for selector in possible_review_selectors:
|
97 |
try:
|
98 |
+
review_tab_button = wait_for_review_tab.until(
|
99 |
+
EC.element_to_be_clickable(selector)
|
100 |
+
)
|
101 |
+
print(f"리뷰 탭 버튼 찾음 (방식: {selector[0]}, 값: {selector[1]})")
|
102 |
break
|
103 |
except TimeoutException:
|
104 |
continue
|
105 |
+
if not review_tab_button:
|
106 |
+
print("리뷰 탭 버튼을 찾을 수 없습니다.")
|
107 |
+
raise NoSuchElementException("리뷰 탭 버튼 없음")
|
108 |
+
review_tab_button.click()
|
109 |
time.sleep(3)
|
110 |
|
111 |
+
# 정렬 버튼 클릭 후 '최신순' 선택
|
112 |
try:
|
113 |
+
sort_button_selector = (By.XPATH,
|
114 |
+
"//button[contains(@aria-label, '정렬 기준') or contains(@aria-label, 'Sort by') or .//span[contains(text(), 'Sort')]]")
|
115 |
+
sort_button = WebDriverWait(driver, 10).until(
|
116 |
+
EC.element_to_be_clickable(sort_button_selector)
|
117 |
+
)
|
118 |
+
print("정렬 기준 버튼 찾음. 클릭 시도...")
|
119 |
sort_button.click()
|
120 |
time.sleep(1)
|
121 |
+
newest_option_selector = (By.XPATH, "//div[@role='menuitemradio'][contains(., 'Newest')]")
|
122 |
+
newest_option = WebDriverWait(driver, 10).until(
|
123 |
+
EC.element_to_be_clickable(newest_option_selector)
|
124 |
+
)
|
125 |
+
print("최신순 옵션 찾음. 클릭 시도...")
|
126 |
newest_option.click()
|
127 |
+
print("최신순으로 정렬 적용됨. 잠시 대기...")
|
128 |
time.sleep(3)
|
129 |
+
except (TimeoutException, NoSuchElementException) as e:
|
130 |
+
print(f"정렬 적용 중 오류 발생: {e}. 기본 정렬 상태로 진행합니다.")
|
131 |
+
time.sleep(3)
|
132 |
|
133 |
+
scrollable_div_selector = (By.CSS_SELECTOR, "div.m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde[tabindex='-1']")
|
134 |
+
review_elements_selector = (By.CSS_SELECTOR, "div.jftiEf.fontBodyMedium")
|
135 |
try:
|
136 |
+
scrollable_div = WebDriverWait(driver, 15).until(
|
137 |
+
EC.presence_of_element_located(scrollable_div_selector)
|
138 |
+
)
|
139 |
+
print("리뷰 스크롤 영역 찾음.")
|
140 |
except TimeoutException:
|
141 |
+
print("리뷰 스크롤 영역을 찾을 수 없습니다.")
|
142 |
+
scrollable_div = None
|
143 |
+
time.sleep(5)
|
144 |
+
|
145 |
+
def get_review_text(review):
|
146 |
+
try:
|
147 |
+
more_button = review.find_element(By.CSS_SELECTOR, "button.w8nwRe.kyuRq")
|
148 |
+
driver.execute_script("arguments[0].scrollIntoView(true);", more_button)
|
149 |
+
time.sleep(0.3)
|
150 |
+
driver.execute_script("arguments[0].click();", more_button)
|
151 |
+
time.sleep(0.5)
|
152 |
+
except Exception:
|
153 |
+
pass
|
154 |
+
try:
|
155 |
+
review_text = review.find_element(By.CSS_SELECTOR, "span.wiI7pd").text
|
156 |
+
return review_text.strip()
|
157 |
+
except Exception:
|
158 |
+
return ""
|
159 |
|
|
|
160 |
loop_count = 0
|
161 |
max_loop = 50
|
162 |
while len(reviews_data) < TARGET_REVIEW_COUNT and loop_count < max_loop:
|
163 |
loop_count += 1
|
164 |
+
previous_count = len(reviews_data)
|
165 |
all_reviews = driver.find_elements(*review_elements_selector)
|
166 |
+
print(f"Loop {loop_count}: 총 {len(all_reviews)}개의 리뷰 요소 발견.")
|
167 |
+
|
168 |
for review in all_reviews:
|
169 |
try:
|
170 |
reviewer_name = review.find_element(By.CSS_SELECTOR, "div.d4r55").text
|
|
|
178 |
if unique_key in processed_keys:
|
179 |
continue
|
180 |
processed_keys.add(unique_key)
|
181 |
+
|
182 |
+
review_text = get_review_text(review)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
if review_text:
|
184 |
try:
|
185 |
+
rating_span = review.find_element(By.CSS_SELECTOR, "span.kvMYJc")
|
186 |
+
rating = rating_span.get_attribute("aria-label")
|
187 |
except Exception:
|
188 |
rating = "N/A"
|
189 |
+
review_info = {
|
190 |
+
"reviewer_name": reviewer_name,
|
191 |
+
"rating": rating,
|
192 |
+
"date": review_date,
|
193 |
+
"text": review_text.replace('\n', ' ')
|
194 |
+
}
|
195 |
+
reviews_data.append(review_info)
|
196 |
+
print(f"리뷰 추가: {reviewer_name}, {review_date}")
|
197 |
if len(reviews_data) >= TARGET_REVIEW_COUNT:
|
198 |
break
|
199 |
+
|
200 |
+
if len(reviews_data) == previous_count:
|
201 |
+
print("새로운 리뷰가 추가되지 않아 스크롤을 중단합니다.")
|
202 |
break
|
203 |
+
|
204 |
if scrollable_div:
|
205 |
+
for i in range(20):
|
|
|
206 |
time.sleep(0.1)
|
207 |
+
scroll_amount = 1000
|
208 |
+
driver.execute_script('arguments[0].scrollBy(0, arguments[1]);', scrollable_div, scroll_amount)
|
209 |
time.sleep(2)
|
210 |
+
else:
|
211 |
+
break
|
212 |
+
|
213 |
if reviews_data:
|
214 |
+
review_list = []
|
215 |
+
for review in reviews_data[:TARGET_REVIEW_COUNT]:
|
216 |
+
rating_str = review['rating']
|
217 |
+
if rating_str != "N/A":
|
218 |
+
try:
|
219 |
+
rating_num = float(rating_str.split()[0])
|
220 |
+
except Exception:
|
221 |
+
rating_num = None
|
222 |
+
else:
|
223 |
+
rating_num = None
|
224 |
+
|
225 |
+
review_list.append({
|
226 |
+
"Name": review['reviewer_name'],
|
227 |
+
"Rating": rating_num,
|
228 |
+
"Date / Time Ago": review['date'],
|
229 |
+
"Review Text": review['text']
|
230 |
+
})
|
231 |
+
df_reviews = pd.DataFrame(review_list)
|
232 |
else:
|
233 |
+
df_reviews = pd.DataFrame()
|
234 |
except Exception as e:
|
235 |
+
print(f"스크립트 실행 중 예기치 않은 오류 발생: {e}")
|
236 |
+
df_reviews = pd.DataFrame()
|
237 |
finally:
|
238 |
+
if 'driver' in locals() and driver:
|
239 |
+
driver.quit()
|
240 |
|
241 |
+
return df_reviews
|
242 |
+
|
243 |
+
# --- Config 클래스 (Gemma, GPT4o 제거, Qwen만 사용) ---
|
244 |
class Config:
|
245 |
"""애플리케이션 설정 및 상수"""
|
246 |
FOOD_ITEMS = [
|
|
|
255 |
]
|
256 |
# 알리바바 Qwen API 키 (기본값은 빈 문자열)
|
257 |
QWEN_API_KEY = ""
|
258 |
+
|
259 |
DEFAULT_PROMPT_TEMPLATE = (
|
260 |
"### Persona ###\n"
|
261 |
"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"
|
|
|
304 |
"Analysis: [Step-by-step explanation detailing:\n"
|
305 |
" - How you determined the bill amount;\n"
|
306 |
" - 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"
|
307 |
+
" - How you chose the tip percentage within the guideline range, including the calculation details.]\n\n"
|
308 |
+
"### Example Output Indicators(Only Example) ###\n"
|
309 |
+
"**Final Tip Percentage**: 2.0%\n"
|
310 |
+
"**Final Tip Amount**: $0.50\n"
|
311 |
+
"**Final Total Bill**: $25.50\n\n"
|
312 |
+
"### Output Indicators ###\n"
|
313 |
+
"**Final Tip Percentage**: [X]% (only floating point)\n"
|
314 |
+
"**Final Tip Amount**: $[Calculated Tip]\n"
|
315 |
+
"**Final Total Bill**: $[Subtotal + Tip]\n"
|
316 |
)
|
317 |
+
|
318 |
CUSTOM_CSS = """
|
319 |
#food-container {
|
320 |
display: grid;
|
|
|
334 |
"""
|
335 |
|
336 |
def __init__(self):
|
337 |
+
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&g_ep=EgoyMDI1MDQwMi4xIKXMDSoASAFQAw%3D%3D"
|
338 |
+
self.google_review_manager = GoogleReviewManager(review_url, target_review_count=2)
|
339 |
+
# 여기서 정적 메서드를 통해 리뷰를 미리 포맷하여 저장합니다.
|
340 |
+
self.GOOGLE_REVIEWS = GoogleReviewManager.format_google_reviews(
|
341 |
+
self.google_review_manager.reviews_text
|
342 |
+
)
|
343 |
+
# 이미지 디렉토리 확인
|
344 |
if not os.path.exists("images"):
|
345 |
print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
|
346 |
for item in self.FOOD_ITEMS:
|
347 |
if not os.path.exists(item["image"]):
|
348 |
print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
|
|
|
|
|
|
|
|
|
349 |
|
350 |
+
|
351 |
+
# --- ModelClients (알리바바 Qwen API만 사용) ---
|
352 |
class ModelClients:
|
353 |
def __init__(self, config: Config):
|
354 |
self.config = config
|
|
|
362 |
with open(video_path, "rb") as video_file:
|
363 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
364 |
|
365 |
+
|
366 |
+
# --- VideoProcessor: 비디오 프레임 추출 ---
|
367 |
class VideoProcessor:
|
368 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
369 |
if not video_path:
|
|
|
378 |
frame_paths = []
|
379 |
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
380 |
if not frame_rate or frame_rate == 0:
|
381 |
+
print("경고: FPS를 읽을 수 없습니다, 기본값 4으로 설정합니다.")
|
382 |
frame_rate = 4.0
|
383 |
frame_interval = int(frame_rate / fps) if fps > 0 else 1
|
384 |
if frame_interval <= 0:
|
|
|
426 |
except OSError as e:
|
427 |
print(f"프레임 폴더 삭제 오류: {e}")
|
428 |
|
429 |
+
|
430 |
+
# --- TipCalculator (알리바바 Qwen API를 사용한 팁 계산) ---
|
431 |
class TipCalculator:
|
432 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
433 |
self.config = config
|
|
|
441 |
tip_amount = 0.0
|
442 |
total_bill = 0.0
|
443 |
|
444 |
+
# Analysis 부분: "Analysis:" 이후부터 "**Final Tip Percentage**" 이전까지 추출
|
445 |
+
analysis_match = re.search(r"Analysis:\s*(.*?)\*\*Final Tip Percentage\*\*", output_text,
|
446 |
+
re.DOTALL | re.IGNORECASE)
|
447 |
if analysis_match:
|
448 |
analysis = analysis_match.group(1).strip()
|
449 |
else:
|
|
|
451 |
if analysis_match_alt:
|
452 |
analysis = analysis_match_alt.group(1).strip()
|
453 |
|
454 |
+
# **Final Tip Percentage** 추출 (예: **Final Tip Percentage**: 2.00%)
|
455 |
+
percentage_match = re.search(r"\*\*Final Tip Percentage\*\*:\s*([0-9]+(?:\.[0-9]+)?)%", output_text,
|
456 |
re.DOTALL | re.IGNORECASE)
|
457 |
if percentage_match:
|
458 |
try:
|
|
|
461 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
462 |
tip_percentage = 0.0
|
463 |
|
464 |
+
# **Final Tip Amount** 추출 (예: **Final Tip Amount**: $1.44)
|
465 |
+
tip_match = re.search(r"\*\*Final Tip Amount\*\*:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)", output_text, re.IGNORECASE)
|
466 |
if tip_match:
|
467 |
try:
|
468 |
tip_amount = float(tip_match.group(1))
|
|
|
472 |
else:
|
473 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
474 |
|
475 |
+
# **Final Total Bill** 추출 (예: **Final Total Bill**: $73.44)
|
476 |
+
total_match = re.search(r"\*\*Final Total Bill\*\*:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)", output_text, re.IGNORECASE)
|
477 |
if total_match:
|
478 |
try:
|
479 |
total_bill = float(total_match.group(1))
|
480 |
except ValueError:
|
481 |
print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
|
|
|
482 |
if len(analysis) < 20 and analysis == "Analysis not found.":
|
483 |
analysis = output_text
|
484 |
|
|
|
521 |
if not caption_text.strip():
|
522 |
caption_text = "(No caption from Omni)"
|
523 |
user_review = user_review.strip() if user_review else "(No user review)"
|
|
|
524 |
if custom_prompt is None:
|
525 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
526 |
calculated_subtotal=calculated_subtotal,
|
527 |
star_rating=star_rating,
|
528 |
+
user_review=user_review
|
|
|
529 |
)
|
530 |
else:
|
531 |
try:
|
532 |
prompt = custom_prompt.format(
|
533 |
calculated_subtotal=calculated_subtotal,
|
534 |
star_rating=star_rating,
|
535 |
+
user_review=user_review
|
|
|
536 |
)
|
537 |
except:
|
538 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
539 |
calculated_subtotal=calculated_subtotal,
|
540 |
star_rating=star_rating,
|
541 |
+
user_review=user_review
|
|
|
542 |
)
|
543 |
final_prompt = prompt.replace("{caption_text}", caption_text)
|
544 |
qvq_result = self.model_clients.qwen_client.chat.completions.create(
|
|
|
577 |
total_bill_output = f"${total_bill:.2f}"
|
578 |
return analysis_output, tip_output, total_bill_output
|
579 |
|
580 |
+
|
581 |
+
# --- UIHandler: Gradio 인터페이스 이벤트 처리 (알리바바 API 키 입력 포함) ---
|
582 |
class UIHandler:
|
583 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
584 |
self.config = config
|
|
|
586 |
self.video_processor = video_processor
|
587 |
|
588 |
def update_subtotal_and_prompt(self, *args):
|
589 |
+
"""사용자 입력에 따라 소계 및 프롬프트 업데이트"""
|
590 |
num_food_items = len(self.config.FOOD_ITEMS)
|
591 |
quantities = args[:num_food_items]
|
592 |
star_rating = args[num_food_items]
|
593 |
user_review = args[num_food_items + 1]
|
594 |
+
|
595 |
calculated_subtotal = 0.0
|
596 |
for i in range(num_food_items):
|
597 |
calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
|
598 |
+
|
599 |
user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
|
600 |
+
|
601 |
+
# Google 리뷰 텍스트를 동적으로 포맷팅 (정적 메서드 호출)
|
602 |
+
formatted_google_reviews = GoogleReviewManager.format_google_reviews(self.config.GOOGLE_REVIEWS)
|
603 |
+
# print(formatted_google_reviews)
|
604 |
updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
605 |
calculated_subtotal=calculated_subtotal,
|
606 |
star_rating=star_rating,
|
607 |
user_review=user_review_text,
|
608 |
+
google_reviews=formatted_google_reviews
|
609 |
)
|
610 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
611 |
+
|
612 |
return calculated_subtotal, updated_prompt
|
613 |
|
614 |
def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
|
|
|
658 |
analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
|
659 |
alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
|
660 |
)
|
661 |
+
#print(analysis, tip_disp, total_bill_disp, prompt_out, vid_out)
|
662 |
invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
|
663 |
return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
|
664 |
|
|
|
694 |
def process_payment(self, total_bill):
|
695 |
return f"{total_bill} 결제되었습니다."
|
696 |
|
697 |
+
|
698 |
+
# --- App: 모든 컴포넌트 연결 및 Gradio 인터페이스 실행 ---
|
699 |
class App:
|
700 |
def __init__(self):
|
701 |
self.config = Config()
|
|
|
706 |
self.flask_app = Flask(__name__)
|
707 |
|
708 |
def create_gradio_blocks(self):
|
709 |
+
with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
|
710 |
+
css=self.config.CUSTOM_CSS) as interface:
|
711 |
gr.Markdown("## Video Tip Calculation Interface (Structured)")
|
712 |
quantity_inputs = []
|
713 |
subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
|
|
|
766 |
calculated_subtotal=0.0,
|
767 |
star_rating=3,
|
768 |
user_review="(No user review provided)",
|
769 |
+
google_reviews=GoogleReviewManager.format_google_reviews(self.config.GOOGLE_REVIEWS)
|
770 |
).replace("{caption_text}", "{{caption_text}}")
|
771 |
)
|
772 |
gr.Markdown("### 6. AI Analysis")
|
|
|
793 |
outputs=order_summary_display
|
794 |
)
|
795 |
compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
|
796 |
+
compute_outputs = [
|
797 |
+
analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
|
798 |
+
]
|
799 |
qwen_btn.click(
|
800 |
fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
|
801 |
alibaba_key, vid, sub, rat, rev, prom, *qty
|
|
|
845 |
return "Hello Flask"
|
846 |
self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
|
847 |
|
848 |
+
|
849 |
if __name__ == "__main__":
|
850 |
app = App()
|
851 |
app.run_gradio()
|
requirements.txt
CHANGED
@@ -2,3 +2,5 @@ gradio
|
|
2 |
opencv-python
|
3 |
flask
|
4 |
openai
|
|
|
|
|
|
2 |
opencv-python
|
3 |
flask
|
4 |
openai
|
5 |
+
webdriver-manager
|
6 |
+
selenium
|