- .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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|