Skip to content

Commit 087a66a

Browse files
jinsoojinsoo
authored andcommitted
add dockerfile, change async
1 parent 7abdefd commit 087a66a

File tree

8 files changed

+332
-29
lines changed

8 files changed

+332
-29
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@ qdrant_data/
1818
**/qdrant_data/
1919
*.sqlite
2020
*.db
21+
22+
tasteam_app_all_restaurants_ai_api_results.json
23+
tasteam_app_all_restaurants_ai_api_results.sql
24+
tasteam_all_seed_reviews.csv
25+
tasteam_app_all_review_data.json
26+
merged.csv
27+
260202_api_result.md
28+
tasteam_app_kr3_640k_even.json
29+
service_simul_data/

Dockerfile.cpu

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# CPU 전용 이미지 - GPU 없는 환경에서 src 애플리케이션 실행
2+
#
3+
# 빌드: docker build -f Dockerfile.cpu -t app-cpu .
4+
# 실행: docker run -p 8001:8001 app-cpu
5+
#
6+
FROM python:3.11-slim-bookworm
7+
8+
ENV PYTHONUNBUFFERED=1
9+
# config.py에서 GPU 사용 여부를 "USE_GPU#" 환경 변수로 읽음
10+
ENV "USE_GPU#"=false
11+
WORKDIR /app
12+
13+
# 시스템 의존성: 빌드 도구 + OpenJDK (PySpark/비교 파이프라인용)
14+
RUN apt-get update && apt-get install -y --no-install-recommends \
15+
build-essential \
16+
curl \
17+
openjdk-17-jdk-headless \
18+
&& rm -rf /var/lib/apt/lists/*
19+
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
20+
21+
# PyTorch CPU 버전 먼저 설치 (requirements보다 먼저 해야 충돌 방지)
22+
RUN pip install --no-cache-dir --upgrade pip && \
23+
pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
24+
25+
COPY requirements.txt /app/
26+
RUN pip install --no-cache-dir -r requirements.txt
27+
28+
# 애플리케이션 코드 복사
29+
COPY . /app
30+
31+
# 포트 노출 (app.py 기본값 8001)
32+
EXPOSE 8001
33+
34+
# CPU 환경에서 실행
35+
CMD ["python", "app.py"]

src/api/main.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414

1515
from .routers import sentiment, vector, llm, test
1616
from ..cpu_monitor import get_cpu_monitor
17+
from ..metrics_collector import app_queue_depth_inc, app_queue_depth_dec
18+
19+
try:
20+
from prometheus_fastapi_instrumentator import Instrumentator
21+
_INSTRUMENTATOR_AVAILABLE = True
22+
except ImportError:
23+
_INSTRUMENTATOR_AVAILABLE = False
1724

1825
# 로거 설정 (콘솔 출력)
1926
# basicConfig는 한 번만 실행되므로, root 로거에 직접 핸들러 추가
@@ -74,6 +81,17 @@ async def add_request_id(request: Request, call_next):
7481
return response
7582

7683

84+
# Queue depth (in-flight 요청 수) — Prometheus app_queue_depth 집계용
85+
@app.middleware("http")
86+
async def track_queue_depth(request: Request, call_next):
87+
app_queue_depth_inc()
88+
try:
89+
response = await call_next(request)
90+
return response
91+
finally:
92+
app_queue_depth_dec()
93+
94+
7795
def _error_payload(*, code: int, message: str, details, request_id: str) -> dict:
7896
return {"code": code, "message": message, "details": details, "request_id": request_id}
7997

@@ -158,3 +176,14 @@ async def health():
158176
"version": "1.0.0",
159177
}
160178

179+
180+
# Prometheus 메트릭 (요청 수, 지연 시간 등 자동 수집, 패키지 설치 시에만 노출)
181+
if _INSTRUMENTATOR_AVAILABLE:
182+
Instrumentator().instrument(app).expose(app)
183+
else:
184+
import logging
185+
logging.getLogger(__name__).warning(
186+
"prometheus_fastapi_instrumentator 미설치: /metrics 비활성화. "
187+
"설치: pip install prometheus-client prometheus-fastapi-instrumentator"
188+
)
189+

src/api/routers/llm.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,17 @@ async def summarize_reviews(
337337

338338
# 메트릭 수집
339339
total_reviews_count = sum(len(hits_dict.get(cat, [])) for cat in ["service", "price", "food"])
340+
processing_time_ms = (time.time() - start_time) * 1000
340341
request_id = metrics.collect_metrics(
341342
restaurant_id=request.restaurant_id,
342343
analysis_type="summary",
343344
start_time=start_time,
344345
tokens_used=None,
345346
batch_size=total_reviews_count,
346347
)
347-
348+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
349+
metrics.record_llm_ttft(analysis_type="summary", ttft_ms=processing_time_ms)
350+
348351
# 항상 SummaryDisplayResponse (positive_reviews 등 미사용 필드 제외)
349352
return SummaryDisplayResponse(
350353
restaurant_id=request.restaurant_id,
@@ -353,7 +356,7 @@ async def summarize_reviews(
353356
categories=categories_dict if categories_dict else None,
354357
debug=DebugInfo(
355358
request_id=request_id,
356-
processing_time_ms=(time.time() - start_time) * 1000,
359+
processing_time_ms=processing_time_ms,
357360
tokens_used=None,
358361
model_version=None,
359362
) if debug else None,
@@ -484,8 +487,11 @@ async def compare(
484487
analysis_type="comparison",
485488
start_time=start_time,
486489
batch_size=result.get("total_candidates", 0),
487-
)
488-
490+
)
491+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
492+
ttfur_ms = (time.time() - start_time) * 1000
493+
metrics.record_llm_ttft(analysis_type="comparison", ttft_ms=ttfur_ms)
494+
489495
# 디버그 정보 추가
490496
if debug:
491497
result["debug"] = DebugInfo(
@@ -536,6 +542,7 @@ async def compare_batch(
536542
Returns:
537543
각 레스토랑별 비교 결과 리스트
538544
"""
545+
start_time = time.time()
539546
try:
540547
pipeline = ComparisonPipeline(
541548
llm_utils=llm_utils,
@@ -545,6 +552,9 @@ async def compare_batch(
545552
restaurants=request.restaurants,
546553
all_average_data_path=request.all_average_data_path,
547554
)
555+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
556+
elapsed_ms = (time.time() - start_time) * 1000
557+
metrics.record_llm_ttft(analysis_type="comparison", ttft_ms=elapsed_ms)
548558
return ComparisonBatchResponse(results=[ComparisonResponse(**r) for r in results])
549559
except Exception as e:
550560
logger.error(f"배치 비교 중 오류: {str(e)}", exc_info=True)
@@ -586,13 +596,17 @@ async def summarize_reviews_batch(
586596
Returns:
587597
각 레스토랑별 요약 결과 리스트 (categories 기반)
588598
"""
599+
start_time = time.time()
589600
try:
590601
seed_list = [DEFAULT_SERVICE_SEEDS, DEFAULT_PRICE_SEEDS, DEFAULT_FOOD_SEEDS]
591602
name_list = ["service", "price", "food"]
592603
logger.info("요약: 기본 시드만 사용")
593604

594605
if Config.SUMMARY_SEARCH_ASYNC or Config.SUMMARY_RESTAURANT_ASYNC:
595606
results = await _batch_summarize_async(request, vector_search, llm_utils, seed_list, name_list)
607+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
608+
elapsed_ms = (time.time() - start_time) * 1000
609+
metrics.record_llm_ttft(analysis_type="summary", ttft_ms=elapsed_ms)
596610
return SummaryBatchResponse(results=[SummaryDisplayResponse(**r) for r in results])
597611

598612
# search_async=false, restaurant_async=false: 레스토랑·aspect 완전 순차
@@ -644,7 +658,10 @@ async def summarize_reviews_batch(
644658
per_category_max=request.limit,
645659
)
646660
results.append(_build_category_result(result, restaurant_id, restaurant_data.get("restaurant_name")))
647-
661+
662+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
663+
elapsed_ms = (time.time() - start_time) * 1000
664+
metrics.record_llm_ttft(analysis_type="summary", ttft_ms=elapsed_ms)
648665
return SummaryBatchResponse(results=[
649666
SummaryDisplayResponse(**r) for r in results
650667
])

src/api/routers/sentiment.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,16 @@ async def analyze_sentiment(
116116
)
117117

118118
# 메트릭 수집
119+
processing_time_ms = (time.time() - start_time) * 1000
119120
request_id = metrics.collect_metrics(
120121
restaurant_id=request.restaurant_id,
121122
analysis_type="sentiment",
122123
start_time=start_time,
123124
tokens_used=result.get("tokens_used"),
124125
batch_size=len(request.reviews),
125126
)
127+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
128+
metrics.record_llm_ttft(analysis_type="sentiment", ttft_ms=processing_time_ms)
126129

127130
# 디버그 모드에 따라 응답 반환 (restaurant_name은 요청에서 반환)
128131
result["restaurant_name"] = getattr(request, "restaurant_name", None)
@@ -167,6 +170,7 @@ async def analyze_sentiment(
167170
async def analyze_sentiment_batch(
168171
request: SentimentAnalysisBatchRequest,
169172
analyzer: SentimentAnalyzer = Depends(get_sentiment_analyzer),
173+
metrics: MetricsCollector = Depends(get_metrics_collector),
170174
):
171175
"""
172176
여러 레스토랑의 **전체 리뷰**를 sentiment 모델로 분류하여 결과를 반환합니다.
@@ -180,6 +184,7 @@ async def analyze_sentiment_batch(
180184
Returns:
181185
각 레스토랑별 감성 분석 결과 리스트
182186
"""
187+
start_time = time.time()
183188
try:
184189
results = await analyzer.analyze_multiple_restaurants_async(restaurants_data=request.restaurants)
185190
# 각 결과에 restaurant_name 병합 (요청 항목 순서 대응)
@@ -190,6 +195,9 @@ async def analyze_sentiment_batch(
190195
r["restaurant_name"] = item.get("restaurant_name") if isinstance(item, dict) else getattr(item, "restaurant_name", None)
191196
else:
192197
r["restaurant_name"] = None
198+
# TTFUR = t1 - t0 (요청 수신 시각 t0 → 응답 반환 직전 t1)
199+
elapsed_ms = (time.time() - start_time) * 1000
200+
metrics.record_llm_ttft(analysis_type="sentiment", ttft_ms=elapsed_ms)
193201
return SentimentAnalysisBatchResponse(results=[
194202
SentimentAnalysisResponse(**result) for result in results
195203
])

src/comparison_pipeline.py

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@
2828
except ImportError:
2929
Py4JNetworkError = Exception # no py4j
3030

31+
# Spark/JVM 실패 시 폴백용: JAVA_GATEWAY_EXITED 등 Py4J 이외 예외도 잡기 위함
32+
def _is_spark_or_jvm_error(e: Exception) -> bool:
33+
msg = str(e).upper()
34+
return (
35+
"JAVA_GATEWAY" in msg
36+
or "PY4J" in msg
37+
or "SPARK" in msg
38+
or isinstance(e, (Py4JNetworkError, BrokenPipeError, ConnectionError, OSError, EOFError))
39+
)
40+
41+
42+
def _spark_disabled() -> bool:
43+
"""Docker 등 JVM 없는 환경에서 Spark 비활성화 시 True."""
44+
try:
45+
from .config import Config
46+
return getattr(Config, "DISABLE_SPARK", False)
47+
except Exception:
48+
return False
49+
50+
3151
_spark_session = None
3252

3353

@@ -517,15 +537,18 @@ def calculate_single_restaurant_ratios(
517537
texts = [s for s in reviews if s and isinstance(s, str)]
518538
if not texts:
519539
return {"service": 0.0, "price": 0.0}
520-
if SPARK_AVAILABLE:
540+
if SPARK_AVAILABLE and not _spark_disabled():
521541
try:
522542
spark = _get_spark()
523543
rdd = spark.sparkContext.parallelize(texts, numSlices=max(1, min(len(texts) // 50, 32)))
524544
out = _spark_calculate_ratios(rdd, stopwords)
525545
return {"service": round(out["service"], 2), "price": round(out["price"], 2)}
526-
except (Py4JNetworkError, BrokenPipeError, ConnectionError, OSError, EOFError) as e:
527-
logger.warning("Spark/Py4J 오류, Python 폴백 사용: %s", e)
528-
_reset_spark()
546+
except Exception as e:
547+
if _is_spark_or_jvm_error(e):
548+
logger.warning("Spark/JVM 오류, Python 폴백 사용: %s", e)
549+
_reset_spark()
550+
else:
551+
raise
529552
out = _python_calculate_ratios(texts, stopwords)
530553
return {"service": round(out["service"], 2), "price": round(out["price"], 2)}
531554

@@ -596,6 +619,18 @@ def calculate_all_average_ratios_from_file(
596619
logger.warning("pyspark 미설치. calculate_all_average_ratios_from_file 불가.")
597620
return None
598621

622+
# Docker 등 JVM 없는 환경: Spark 건너뛰고 Python 경로만 사용
623+
if _spark_disabled():
624+
try:
625+
rows = load_reviews_from_aspect_data_file(path, project_root)
626+
texts = [(r.get("content") or r.get("text") or "").strip() for r in rows if isinstance(r, dict)]
627+
texts = [t for t in texts if t]
628+
if texts:
629+
return _python_calculate_ratios(texts, stopwords)
630+
except Exception as e:
631+
logger.warning("DISABLE_SPARK 시 Python 경로 실패: %s", e)
632+
return None
633+
599634
try:
600635
from pyspark.sql.functions import col, length, explode
601636

@@ -632,20 +667,20 @@ def calculate_all_average_ratios_from_file(
632667

633668
texts_rdd = base_df.select("text").rdd.map(lambda r: r["text"])
634669
return _spark_calculate_ratios(texts_rdd, stopwords)
635-
except (Py4JNetworkError, BrokenPipeError, ConnectionError, OSError, EOFError) as e:
636-
logger.warning("Spark/Py4J 오류, Python 폴백 시도: %s", e)
637-
_reset_spark()
638-
try:
639-
rows = load_reviews_from_aspect_data_file(path, project_root)
640-
texts = [(r.get("content") or r.get("text") or "").strip() for r in rows if isinstance(r, dict)]
641-
texts = [t for t in texts if t]
642-
if texts:
643-
return _python_calculate_ratios(texts, stopwords)
644-
except Exception as fb:
645-
logger.warning("Python 폴백 실패: %s", fb)
646-
return None
647670
except Exception as e:
648-
logger.warning("calculate_all_average_ratios_from_file 실패: %s — %s", path, e)
671+
if _is_spark_or_jvm_error(e):
672+
logger.warning("Spark/JVM 오류, Python 폴백 시도: %s", e)
673+
_reset_spark()
674+
try:
675+
rows = load_reviews_from_aspect_data_file(path, project_root)
676+
texts = [(r.get("content") or r.get("text") or "").strip() for r in rows if isinstance(r, dict)]
677+
texts = [t for t in texts if t]
678+
if texts:
679+
return _python_calculate_ratios(texts, stopwords)
680+
except Exception as fb:
681+
logger.warning("Python 폴백 실패: %s", fb)
682+
else:
683+
logger.warning("calculate_all_average_ratios_from_file 실패: %s — %s", path, e)
649684
return None
650685

651686

@@ -727,16 +762,19 @@ def calculate_all_average_ratios_from_reviews(
727762
texts = [t for t in texts if t and isinstance(t, str)]
728763
if not texts:
729764
return {"service": 0.0, "price": 0.0}
730-
if SPARK_AVAILABLE:
765+
if SPARK_AVAILABLE and not _spark_disabled():
731766
try:
732767
spark = _get_spark()
733768
rdd = spark.sparkContext.parallelize(
734769
texts, numSlices=max(1, min(len(texts) // 100, 256))
735770
)
736771
return _spark_calculate_ratios(rdd, stopwords)
737-
except (Py4JNetworkError, BrokenPipeError, ConnectionError, OSError, EOFError) as e:
738-
logger.warning("Spark/Py4J 오류, Python 폴백 사용: %s", e)
739-
_reset_spark()
772+
except Exception as e:
773+
if _is_spark_or_jvm_error(e):
774+
logger.warning("Spark/JVM 오류, Python 폴백 사용: %s", e)
775+
_reset_spark()
776+
else:
777+
raise
740778
return _python_calculate_ratios(texts, stopwords)
741779

742780

src/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ class _CacheConfig:
132132

133133
# --- Spark (Comparison 전체 평균 데이터 등) ---
134134
class _SparkConfig:
135-
"""Spark/배치: 전체 평균 데이터 경로, 비율"""
135+
"""Spark/배치: 전체 평균 데이터 경로, 비율. DISABLE_SPARK=true 시 JVM 없이 Kiwi만 사용 (Docker 등)."""
136+
DISABLE_SPARK: bool = os.getenv("DISABLE_SPARK", "false").lower() == "true"
136137
ALL_AVERAGE_ASPECT_DATA_PATH: Optional[str] = os.getenv("ALL_AVERAGE_ASPECT_DATA_PATH", "data/test_data_sample.json")
137138
ALL_AVERAGE_SERVICE_RATIO: float = float(os.getenv("ALL_AVERAGE_SERVICE_RATIO", "0.60"))
138139
ALL_AVERAGE_PRICE_RATIO: float = float(os.getenv("ALL_AVERAGE_PRICE_RATIO", "0.55"))

0 commit comments

Comments
 (0)