Sodagraph commited on
Commit
9b9d44e
·
1 Parent(s): 8659b57

LLM추가 및 템플릿 개선, (프록시는 미구현)

Browse files
Dockerfile CHANGED
@@ -1,50 +1,74 @@
1
  # --- Stage 1: Frontend Build ---
2
- FROM node:20-slim as frontend-builder
3
 
4
- WORKDIR /app/frontend
5
-
6
- COPY frontend/package*.json ./
7
-
8
- RUN npm install
9
-
10
- COPY frontend/ .
11
-
12
- RUN npm run build
13
-
14
-
15
- # --- Stage 2: Backend and Combined Serving via FastAPI (Uvicorn) ---
16
- FROM python:3.11-slim-bookworm
17
-
18
- WORKDIR /app
19
-
20
- # 시스템 패키지 업데이트 및 build-essential (Python 패키지 빌드용) 설치
21
- RUN apt-get update && apt-get install -y --no-install-recommends \
22
- build-essential \
23
- curl \
24
- git \
25
- && rm -rf /var/lib/apt/lists/*
26
-
27
- # Python 종속성 설치
28
- COPY backend/requirements.txt ./backend/requirements.txt
29
- RUN pip install --no-cache-dir -r backend/requirements.txt
30
-
31
- # 백엔드 소스 코드 복사
32
- COPY backend/ ./backend/
33
-
34
- # Hugging Face 캐시 디렉토리 설정: /tmp는 일반적으로 쓰기 가능
35
- ENV TRANSFORMERS_CACHE="/tmp/hf_cache"
36
- ENV HF_HOME="/tmp/hf_cache"
37
-
38
- # 프론트엔드 빌드 결과물을 백엔드 앱이 접근할 수 있는 경로로 복사합니다.
39
- # /app/static 폴더를 만들고 그 안에 Vue.js 빌드 결과물을 넣습니다.
40
- RUN mkdir -p /app/static
41
- COPY --from=frontend-builder /app/frontend/dist /app/static
42
-
43
- # 최종 실행 명령어
44
- # 애플리케이션 포트 설정 (허깅페이스 스페이스는 7860번 포트를 사용)
45
- EXPOSE 7860
46
-
47
- # Uvicorn을 사용하여 FastAPI 앱을 실행합니다.
48
- # main:app은 backend/app/main.py 파일의 app 객체를 의미합니다.
49
- # --host 0.0.0.0 모든 네트워크 인터페이스에서 접근 가능하게 합니다.
50
- CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --app-dir backend/app"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # --- Stage 1: Frontend Build ---
2
+ FROM node:20-slim as frontend-builder
3
 
4
+ WORKDIR /app/frontend
5
+
6
+ # package.json 및 package-lock.json 복사
7
+ COPY frontend/package*.json ./
8
+
9
+ # 의존성 설치
10
+ RUN npm install
11
+
12
+ # 나머지 프론트엔드 코드 복사
13
+ COPY frontend/ .
14
+
15
+ # 프론트엔드 빌드 (Vue CLI 프로젝트의 경우 'npm run build'가 적합)
16
+ RUN npm run build
17
+
18
+
19
+ # --- Stage 2: Backend and Combined Serving via FastAPI (Uvicorn) ---
20
+ FROM python:3.11-slim-bookworm
21
+
22
+ WORKDIR /app
23
+
24
+ # 시스템 패키지 업데이트 및 build-essential (Python 패키지 빌드용), curl, git 설치
25
+ # Ollama 설치 스크립트가 필요 없으므로 bash는 제거 가능하지만, 다른 용도로 필요할 수 있어 유지합니다.
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ build-essential \
28
+ curl \
29
+ git \
30
+ bash \
31
+ && rm -rf /var/lib/apt/lists/*
32
+
33
+ # Python 종속성 설치
34
+ COPY backend/requirements.txt ./backend/requirements.txt
35
+ RUN pip install --no-cache-dir -r backend/requirements.txt
36
+
37
+ # 백엔드 소스 코드 복사
38
+ COPY backend/ ./backend/
39
+
40
+ # Hugging Face 캐시 디렉토리 설정 (모델이 직접 다운로드되지 않으므로 필요 없을 수 있지만, 안전을 위해 유지)
41
+ ENV TRANSFORMERS_CACHE="/tmp/hf_cache"
42
+ ENV HF_HOME="/tmp/hf_cache"
43
+
44
+ # Ollama 설치 모델 다운로드 단계 제거 ✨
45
+ # 이 부분은 Hugging Face Spaces 환경에서 관리됩니다.
46
+ # RUN curl -fsSL https://ollama.com/install.sh | sh
47
+ # RUN sh -c "ollama serve & \
48
+ # ATTEMPTS=0; \
49
+ # while ! curl -s http://localhost:11434 > /dev/null && ATTEMPTS < 30; do \
50
+ # ATTEMPTS=$((ATTEMPTS+1)); \
51
+ # echo 'Waiting for Ollama server to start... (Attempt '$ATTEMPTS'/30)'; \
52
+ # sleep 2; \
53
+ # done; \
54
+ # if [ $ATTEMPTS -eq 30 ]; then \
55
+ # echo 'Ollama server did not start in time. Exiting.'; \
56
+ # exit 1; \
57
+ # fi; \
58
+ # echo 'Ollama server started. Pulling model...'; \
59
+ # ollama pull hf.co/DevQuasar/naver-hyperclovax.HyperCLOVAX-SEED-Text-Instruct-0.5B-GGUF:F16"
60
+
61
+ # 프론트엔드 빌드 결과물을 백엔드 앱이 접근할 수 있는 경로로 복사합니다.
62
+ # /app/static 폴더를 만들고 그 안에 Vue.js 빌드 결과물을 넣습니다.
63
+ RUN mkdir -p /app/static
64
+ COPY --from=frontend-builder /app/frontend/dist /app/static
65
+
66
+ # 최종 실행 명령어
67
+ # 애플리케이션 포트 설정 (허깅페이스 스페이스는 7860번 포트를 사용)
68
+ EXPOSE 7860
69
+
70
+ # CMD를 수정하여 Uvicorn만 실행합니다.
71
+ # Ollama 서버는 Hugging Face Spaces 환경에서 별도로 관리됩니다.
72
+ # 백엔드 main.py의 `OLLAMA_API_BASE_URL` 환경 변수가 `http://127.0.0.1:11434`로 설정되어 있는지 확인하세요.
73
+ # (Hugging Face Spaces에서 Ollama SDK를 사용하면 보통 이 주소로 접근 가능합니다.)
74
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "${PORT:-7860}", "--app-dir", "backend/app"]
backend/app/main.py CHANGED
@@ -1,10 +1,11 @@
1
- # backend/app/main.py
2
 
3
  from fastapi import FastAPI, HTTPException
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.staticfiles import StaticFiles # StaticFiles 임포트
6
  import os
7
  from pydantic import BaseModel
 
8
 
9
  from youtube_parser import process_youtube_video_data
10
  from rag_core import perform_rag_query
@@ -31,15 +32,48 @@ current_file_dir = os.path.dirname(os.path.abspath(__file__))
31
  project_root_dir = os.path.join(current_file_dir, "..", "..")
32
  static_files_dir = os.path.join(project_root_dir, "static")
33
 
34
- # API 라우터 먼저 정의
35
- @app.get("/api/health")
36
- async def api_health_check():
37
- return {"status": "ok", "message": "Backend API is running"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  class VideoProcessRequest(BaseModel):
40
  video_url: str
41
  query: str
 
 
42
 
 
43
  @app.post("/api/process_youtube_video")
44
  async def process_youtube_video(request: VideoProcessRequest):
45
  try:
@@ -48,10 +82,30 @@ async def process_youtube_video(request: VideoProcessRequest):
48
  if not processed_chunks_with_timestamps:
49
  return {"message": "자막 또는 내용을 추출할 수 없습니다.", "results": []}
50
 
 
51
  rag_results = await perform_rag_query(
52
  chunks_with_timestamps=processed_chunks_with_timestamps,
53
  query=request.query,
54
- top_k=5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  )
56
 
57
  return {
@@ -59,9 +113,10 @@ async def process_youtube_video(request: VideoProcessRequest):
59
  "message": "성공적으로 영상을 처리하고 RAG 검색을 수행했습니다.",
60
  "video_url": request.video_url,
61
  "query": request.query,
62
- "results": rag_results
 
 
63
  }
64
-
65
  except Exception as e:
66
  print(f"ERROR: 서버 처리 중 오류 발생: {str(e)}")
67
  raise HTTPException(status_code=500, detail="서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
 
1
+ # ./backend/app/main.py
2
 
3
  from fastapi import FastAPI, HTTPException
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.staticfiles import StaticFiles # StaticFiles 임포트
6
  import os
7
  from pydantic import BaseModel
8
+ import httpx
9
 
10
  from youtube_parser import process_youtube_video_data
11
  from rag_core import perform_rag_query
 
32
  project_root_dir = os.path.join(current_file_dir, "..", "..")
33
  static_files_dir = os.path.join(project_root_dir, "static")
34
 
35
+ # OLLAMA_API_BASE_URL 환경 변수 설정
36
+ OLLAMA_API_BASE_URL = os.getenv("OLLAMA_API_BASE_URL", "http://127.0.0.1:11434")
37
+
38
+ async def generate_answer_with_ollama(model_name: str, prompt: str) -> str:
39
+ """
40
+ Ollama 서버에 질의하여 답변을 생성합니다.
41
+ """
42
+ url = f"{OLLAMA_API_BASE_URL}/api/generate"
43
+ headers = {"Content-Type": "application/json"}
44
+ data = {
45
+ "model": model_name,
46
+ "prompt": prompt,
47
+ "stream": False # 스트리밍을 사용하지 않고 한 번에 답변을 받습니다.
48
+ }
49
+ print(f"INFO: Ollama API 호출 시작. 모델: {model_name}")
50
+ print(f"INFO: 프롬프트 미리보기: {prompt[:200]}...")
51
+
52
+ try:
53
+ async with httpx.AsyncClient(timeout=600.0) as client:
54
+ response = await client.post(url, headers=headers, json=data)
55
+ response.raise_for_status()
56
+
57
+ response_data = response.json()
58
+ full_response = response_data.get("response", "").strip()
59
+ return full_response
60
+ except httpx.HTTPStatusError as e:
61
+ print(f"ERROR: Ollama API 호출 실패: {e}")
62
+ raise HTTPException(status_code=500, detail="Ollama API 호출 실패")
63
+ except httpx.RequestError as e:
64
+ print(f"ERROR: 네트워크 오류: {e}")
65
+ raise HTTPException(status_code=500, detail="네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
66
+ except Exception as e:
67
+ print(f"ERROR: 알 수 없는 오류: {e}")
68
+ raise HTTPException(status_code=500, detail="알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
69
 
70
  class VideoProcessRequest(BaseModel):
71
  video_url: str
72
  query: str
73
+ # 사용자가 사용할 Ollama 모델 이름을 지정할 수 있도록 추가
74
+ ollama_model_name: str = "hf.co/DevQuasar/naver-hyperclovax.HyperCLOVAX-SEED-Text-Instruct-0.5B-GGUF:F16"
75
 
76
+ # ✅ 유튜브 영상 처리 API
77
  @app.post("/api/process_youtube_video")
78
  async def process_youtube_video(request: VideoProcessRequest):
79
  try:
 
82
  if not processed_chunks_with_timestamps:
83
  return {"message": "자막 또는 내용을 추출할 수 없습니다.", "results": []}
84
 
85
+ # 1. RAG 검색 수행
86
  rag_results = await perform_rag_query(
87
  chunks_with_timestamps=processed_chunks_with_timestamps,
88
  query=request.query,
89
+ top_k=50
90
+ )
91
+
92
+ if not rag_results:
93
+ return {
94
+ "status": "error",
95
+ "message": "검색 결과가 없습니다.",
96
+ "video_url": request.video_url,
97
+ "query": request.query,
98
+ "results": []
99
+ }
100
+
101
+ # 2. 검색 결과를 프롬프트에 추가
102
+ context = "\\n\\n".join([chunk["text"] for chunk in rag_results])
103
+ prompt = f"다음 정보와 대화 내용을 참고하여 사용자의 질문에 답변하세요. 제공된 정보에서 답을 찾을 수 없다면 '정보 부족'이라고 명시하세요.\\n\\n참고 정보:\\n{context}\\n\\n사용자 질문: {request.query}\\n\\n답변:"
104
+
105
+ # 3. Ollama 모델에 질의하여 답변 생성
106
+ generated_answer = await generate_answer_with_ollama(
107
+ model_name=request.ollama_model_name,
108
+ prompt=prompt
109
  )
110
 
111
  return {
 
113
  "message": "성공적으로 영상을 처리하고 RAG 검색을 수행했습니다.",
114
  "video_url": request.video_url,
115
  "query": request.query,
116
+ "ollama_model_used": request.ollama_model_name,
117
+ "retrieved_chunks": rag_results,
118
+ "generated_answer": generated_answer
119
  }
 
120
  except Exception as e:
121
  print(f"ERROR: 서버 처리 중 오류 발생: {str(e)}")
122
  raise HTTPException(status_code=500, detail="서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
backend/app/proxy_manager.py CHANGED
@@ -1,4 +1,4 @@
1
- # backend/app/proxy_manager.py
2
 
3
  import os
4
  import requests # requests는 FreeProxy 테스트나 ScrapingBee API 직접 호출 시 유용
 
1
+ # ./backend/app/proxy_manager.py
2
 
3
  import os
4
  import requests # requests는 FreeProxy 테스트나 ScrapingBee API 직접 호출 시 유용
backend/app/rag_core.py CHANGED
@@ -1,4 +1,4 @@
1
- # backend/app/rag_core.py
2
 
3
  from sentence_transformers import SentenceTransformer
4
  import faiss
@@ -9,13 +9,13 @@ from typing import List, Dict, Tuple
9
  # 네이버 클로바의 한국어 SentenceBERT 모델을 로드합니다.
10
  try:
11
  # 네이버 클로바 HyperCLOVAX-SEED-Text-Instruct-0.5B 모델 로드
12
- model = SentenceTransformer('jhgan/ko-sroberta-multitask')
13
  print("INFO: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 완료.")
14
  except Exception as e:
15
  print(f"ERROR: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 실패: {e}. 다국어 모델로 시도합니다.")
16
  # 대체 모델 로직 (필요하다면 유지하거나 제거할 수 있습니다)
17
  try:
18
- model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
19
  print("INFO: 임베딩 모델 'sentence-transformers/paraphrase-multilingual-L12-v2' 로드 완료.")
20
  except Exception as e:
21
  print(f"ERROR: 대체 임베딩 모델 로드 실패: {e}. RAG 기능을 사용할 수 없습니다.")
@@ -68,48 +68,32 @@ async def perform_rag_query(chunks_with_timestamps: List[Dict], query: str, top_
68
  print(f"INFO: FAISS 유사도 검색 완료. 상위 {top_k}개 결과.")
69
 
70
  retrieved_chunks = []
71
- for i, idx in enumerate(indices[0]):
72
- if idx >= 0: # 유효한 인덱스만 (FAISS가 -1을 반환할 수 있음)
73
- original_chunk = chunks_with_timestamps[idx]
 
 
 
 
 
 
 
 
 
 
 
 
74
  retrieved_chunks.append({
75
  "text": original_chunk["text"],
76
  "timestamp": original_chunk["timestamp"],
77
- "score": float(distances[0][i]) # 유사도 점수 (거리가 작을수록 유사)
78
  })
 
 
 
79
 
80
  # 거리가 작은 순서(유사도가 높은 순서)로 정렬하여 반환
81
- retrieved_chunks.sort(key=lambda x: x['score'])
82
 
83
  print(f"DEBUG: 최종 검색된 청크 수: {len(retrieved_chunks)}")
84
- return retrieved_chunks
85
-
86
- # ... (나머지 perform_rag_query 함수 코드는 동일합니다) ...
87
-
88
- # 테스트용 코드 (직접 실행 시)
89
- if __name__ == "__main__":
90
- import asyncio
91
-
92
- sample_chunks = [
93
- {"text": "안녕하세요, 오늘 우리는 AI와 머신러닝의 미래에 대해 이야기할 것입니다.", "timestamp": "00:00:05", "start_seconds": 5},
94
- {"text": "특히 딥러닝과 신경망이 어떻게 혁신을 이끄는지 살펴보겠습니다.", "timestamp": "00:00:15", "start_seconds": 15},
95
- {"text": "이번 영상에서는 유튜브 영상 데이트 서비스 개발 과정을 보여드립니다.", "timestamp": "00:00:25", "start_seconds": 25},
96
- {"text": "파이썬과 FastAPI를 사용하여 백엔드를 구축하고 있습니다.", "timestamp": "00:00:35", "start_seconds": 35},
97
- {"text": "데이트 앱 개발은 쉬운 일이 아니지만, 재미있습니다.", "timestamp": "00:00:45", "start_seconds": 45},
98
- {"text": "이 모델은 자연어 처리(NLP) 작업에 매우 유용합니다.", "timestamp": "00:00:55", "start_seconds": 55},
99
- {"text": "다음주에는 새로운 데이트 장소를 탐험할 계획입니다.", "timestamp": "01:00:05", "start_seconds": 65},
100
- ]
101
-
102
- async def main():
103
- print("\n[테스트 1] 쿼리: 데이트 앱")
104
- query1 = "데이트 앱"
105
- results1 = await perform_rag_query(sample_chunks, query1, top_k=3)
106
- for r in results1:
107
- print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
108
-
109
- print("\n[테스트 2] 쿼리: 인공지능")
110
- query2 = "인공지능"
111
- results2 = await perform_rag_query(sample_chunks, query2, top_k=2)
112
- for r in results2:
113
- print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
114
-
115
- asyncio.run(main())
 
1
+ # ./backend/app/rag_core.py
2
 
3
  from sentence_transformers import SentenceTransformer
4
  import faiss
 
9
  # 네이버 클로바의 한국어 SentenceBERT 모델을 로드합니다.
10
  try:
11
  # 네이버 클로바 HyperCLOVAX-SEED-Text-Instruct-0.5B 모델 로드
12
+ model = SentenceTransformer('jhgan/ko-sroberta-multitask', device='cpu')
13
  print("INFO: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 완료.")
14
  except Exception as e:
15
  print(f"ERROR: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 실패: {e}. 다국어 모델로 시도합니다.")
16
  # 대체 모델 로직 (필요하다면 유지하거나 제거할 수 있습니다)
17
  try:
18
+ model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2', device='cpu')
19
  print("INFO: 임베딩 모델 'sentence-transformers/paraphrase-multilingual-L12-v2' 로드 완료.")
20
  except Exception as e:
21
  print(f"ERROR: 대체 임베딩 모델 로드 실패: {e}. RAG 기능을 사용할 수 없습니다.")
 
68
  print(f"INFO: FAISS 유사도 검색 완료. 상위 {top_k}개 결과.")
69
 
70
  retrieved_chunks = []
71
+
72
+ # ✨✨✨ 유사도 임계값 설정 필터링 추가 ✨✨✨
73
+ # 값은 실험을 통해 최적의 값을 찾아야 합니다.
74
+ # 거리가 낮을수록 유사하므로, 이 값보다 '거리(score)'가 낮아야만 결과를 포함합니다.
75
+ # 예를 들어, 0.5는 '거리가 0.5 미만인 경우에만 결과에 포함하라'는 의미입니다.
76
+ # 거리가 0이면 완벽히 일치합니다.
77
+ MIN_DISTANCE_THRESHOLD = 150 # 예시 값: 이 값보다 거리가 작아야 합니다 (더 유사해야 함)
78
+
79
+ for i in range(len(indices[0])):
80
+ idx = indices[0][i]
81
+ original_chunk = chunks_with_timestamps[idx]
82
+ score = float(distances[0][i]) # FAISS에서 반환된 거리 값 (낮을수록 유사)
83
+
84
+ # 설정된 임계값보다 거리가 작을 때만 (즉, 유사도가 높을 때만) 결과에 포함
85
+ if score < MIN_DISTANCE_THRESHOLD:
86
  retrieved_chunks.append({
87
  "text": original_chunk["text"],
88
  "timestamp": original_chunk["timestamp"],
89
+ "score": score
90
  })
91
+ else:
92
+ # 디버깅용: 임계값 때문에 제외된 청크를 로그로 확인
93
+ print(f"DEBUG: 유사도 임계값({MIN_DISTANCE_THRESHOLD:.4f}) 초과로 제외된 청크 (거리: {score:.4f}): {original_chunk['text'][:50]}...")
94
 
95
  # 거리가 작은 순서(유사도가 높은 순서)로 정렬하여 반환
96
+ retrieved_chunks.sort(key=lambda x: x['timestamp'])
97
 
98
  print(f"DEBUG: 최종 검색된 청크 수: {len(retrieved_chunks)}")
99
+ return retrieved_chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/youtube_parser.py CHANGED
@@ -1,4 +1,6 @@
 
1
  import os
 
2
  import contextlib
3
  import asyncio
4
  import json # json 모듈 추가
@@ -58,6 +60,13 @@ async def get_youtube_video_id(url: str) -> str | None:
58
  logger.warning(f"알 수 없는 형식의 YouTube URL: {url}")
59
  return None
60
 
 
 
 
 
 
 
 
61
  async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
62
  logger.info(f"비디오 ID '{video_id}'에 대한 자막 가져오기 시도.")
63
 
@@ -145,7 +154,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
145
 
146
  # 3. 자막 파일이 찾아졌으면 파싱 시작
147
  if caption_file_path and os.path.exists(caption_file_path):
148
- # VTT, SRT, JSON 등 다양한 자막 파일 형식에 대한 파싱 로직 분기
149
  if caption_file_path.endswith('.vtt'):
150
  with open(caption_file_path, 'r', encoding='utf-8') as f:
151
  vtt_content = f.read()
@@ -153,10 +161,11 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
153
  # WEBVTT 파싱
154
  segments = vtt_content.split('\n\n')
155
  for segment in segments:
156
- if '-->' in segment: # 시간 정보 포함 세그먼트
157
  lines = segment.split('\n')
158
  time_str = lines[0].strip()
159
  text_content = ' '.join(lines[1:]).strip()
 
160
 
161
  try:
162
  # VTT 시간은 HH:MM:SS.ms 형태로 제공되므로, 밀리초를 float로 처리 후 정수로 변환
@@ -184,7 +193,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
184
  })
185
  except Exception as e:
186
  logger.warning(f"VTT 시간 파싱 오류: {time_str} - {e}")
187
-
188
  logger.info(f"yt-dlp로 VTT 자막 {len(processed_chunks)}개 청크 처리 완료.")
189
 
190
  elif caption_file_path.endswith('.json'):
@@ -199,6 +207,8 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
199
  hours = total_seconds // 3600
200
  minutes = (total_seconds % 3600) // 60
201
  seconds = total_seconds % 60
 
 
202
 
203
  if hours > 0:
204
  timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
@@ -206,7 +216,7 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
206
  timestamp_str = f"{minutes:02d}:{seconds:02d}"
207
 
208
  processed_chunks.append({
209
- "text": entry['text'],
210
  "timestamp": timestamp_str
211
  })
212
  logger.info(f"yt-dlp로 JSON 자막 {len(processed_chunks)}개 청크 처리 완료.")
@@ -224,6 +234,7 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
224
  if len(lines) >= 3 and '-->' in lines[1]:
225
  time_str = lines[1].strip()
226
  text_content = ' '.join(lines[2:]).strip()
 
227
 
228
  try:
229
  # SRT 시간은 HH:MM:SS,ms 형태로 제공
@@ -248,7 +259,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
248
 
249
  else:
250
  logger.warning(f"지원하지 않는 자막 파일 형식입니다: {caption_file_path}")
251
-
252
  else:
253
  logger.warning(f"비디오 ID '{video_id}'에 대한 yt-dlp 자막 파일을 찾을 수 없습니다. 최종 시도 경로: {caption_file_path}")
254
 
 
1
+ # ./backend/app/youtube_parser.py
2
  import os
3
+ import re
4
  import contextlib
5
  import asyncio
6
  import json # json 모듈 추가
 
60
  logger.warning(f"알 수 없는 형식의 YouTube URL: {url}")
61
  return None
62
 
63
+ def clean_caption_text(text: str) -> str:
64
+ # <00:00:00.000><c>...</c> 또는 </c> 같은 태그 제거
65
+ cleaned_text = re.sub(r'<[^>]+>', '', text)
66
+ # 여러 공백을 하나의 공백으로 줄이고 양쪽 끝 공백 제거
67
+ cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip()
68
+ return cleaned_text
69
+
70
  async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
71
  logger.info(f"비디오 ID '{video_id}'에 대한 자막 가져오기 시도.")
72
 
 
154
 
155
  # 3. 자막 파일이 찾아졌으면 파싱 시작
156
  if caption_file_path and os.path.exists(caption_file_path):
 
157
  if caption_file_path.endswith('.vtt'):
158
  with open(caption_file_path, 'r', encoding='utf-8') as f:
159
  vtt_content = f.read()
 
161
  # WEBVTT 파싱
162
  segments = vtt_content.split('\n\n')
163
  for segment in segments:
164
+ if '-->' in segment:
165
  lines = segment.split('\n')
166
  time_str = lines[0].strip()
167
  text_content = ' '.join(lines[1:]).strip()
168
+ text_content = clean_caption_text(text_content)
169
 
170
  try:
171
  # VTT 시간은 HH:MM:SS.ms 형태로 제공되므로, 밀리초를 float로 처리 후 정수로 변환
 
193
  })
194
  except Exception as e:
195
  logger.warning(f"VTT 시간 파싱 오류: {time_str} - {e}")
 
196
  logger.info(f"yt-dlp로 VTT 자막 {len(processed_chunks)}개 청크 처리 완료.")
197
 
198
  elif caption_file_path.endswith('.json'):
 
207
  hours = total_seconds // 3600
208
  minutes = (total_seconds % 3600) // 60
209
  seconds = total_seconds % 60
210
+ text = entry['text']
211
+ text = clean_caption_text(text)
212
 
213
  if hours > 0:
214
  timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
 
216
  timestamp_str = f"{minutes:02d}:{seconds:02d}"
217
 
218
  processed_chunks.append({
219
+ "text": text,
220
  "timestamp": timestamp_str
221
  })
222
  logger.info(f"yt-dlp로 JSON 자막 {len(processed_chunks)}개 청크 처리 완료.")
 
234
  if len(lines) >= 3 and '-->' in lines[1]:
235
  time_str = lines[1].strip()
236
  text_content = ' '.join(lines[2:]).strip()
237
+ text_content = clean_caption_text(text_content)
238
 
239
  try:
240
  # SRT 시간은 HH:MM:SS,ms 형태로 제공
 
259
 
260
  else:
261
  logger.warning(f"지원하지 않는 자막 파일 형식입니다: {caption_file_path}")
 
262
  else:
263
  logger.warning(f"비디오 ID '{video_id}'에 대한 yt-dlp 자막 파일을 찾을 수 없습니다. 최종 시도 경로: {caption_file_path}")
264
 
backend/requirements.txt CHANGED
@@ -8,4 +8,5 @@ sentence-transformers==3.2.1
8
  faiss-cpu==1.9.0
9
  numpy==1.26.4
10
  pydantic==2.9.2
11
- torch==2.6.0
 
 
8
  faiss-cpu==1.9.0
9
  numpy==1.26.4
10
  pydantic==2.9.2
11
+ torch==2.6.0
12
+ httpx
frontend/.env.development ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # frontend/.env.development 파일
2
+ VUE_APP_BACKEND_URL=http://localhost:7860
frontend/src/App.vue CHANGED
@@ -1,54 +1,71 @@
1
  <template>
2
- <div id="app">
3
- <h1>YouTube Transcript Extraction</h1>
4
- <p>영상 URL과 찾고 싶은 내용을 입력해주세요.</p>
 
5
 
6
- <div class="input-section">
7
- <label for="videoUrl">YouTube 영상 URL:</label>
8
- <input type="text" id="videoUrl" v-model="videoUrl" placeholder="예: https://www.youtube.com/watch?v=..." @input="updateVideoEmbed" />
9
- </div>
10
 
11
- <!-- 유튜브 영상 임베딩 -->
12
- <div v-if="videoEmbedUrl" class="video-embed">
13
- <iframe
14
- :src="videoEmbedUrl"
15
- width="100%"
16
- height="400"
17
- frameborder="0"
18
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
19
- allowfullscreen
20
- ></iframe>
21
- </div>
22
 
23
- <div class="input-section">
24
- <label for="query">찾을 내용 (쿼리):</label>
25
- <input type="text" id="query" v-model="query" placeholder="예: RAG 기술의 장점은?" />
26
- </div>
27
 
28
- <button @click="processVideo" :disabled="loading">
29
- {{ loading ? '처리 중...' : '영상 탐색 시작' }}
30
- </button>
31
 
32
- <div v-if="errorMessage" class="error-message">
33
- {{ errorMessage }}
34
- </div>
35
 
36
- <div v-if="results.length > 0 && !loading" class="results-section">
37
- <h2>검색 결과:</h2>
38
- <div v-for="(result, index) in results" :key="index" class="result-item">
39
- <p><strong>시간:</strong> <a :href="videoUrl + '&t=' + result.timestamp.replace(/:/g, 'm') + 's'" target="_blank">{{ result.timestamp }}</a></p>
40
- <p><strong>내용:</strong> {{ result.text }}</p>
 
 
41
  </div>
42
  </div>
43
- <div v-else-if="!loading && responseMessage" class="info-message">
44
- {{ responseMessage }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </div>
46
  </div>
47
  </template>
48
 
49
  <script>
50
- import apiClient from './api'; // axios 인스턴스 import
51
-
52
  export default {
53
  name: 'App',
54
  data() {
@@ -59,23 +76,23 @@ export default {
59
  results: [],
60
  errorMessage: '',
61
  responseMessage: '',
62
- videoEmbedUrl: ''
 
63
  };
64
  },
65
  methods: {
66
- // 유튜브 비디오 ID 추출 및 임베딩 URL 저장
67
  updateVideoEmbed() {
68
  this.videoEmbedUrl = '';
69
  this.errorMessage = '';
70
  if (!this.videoUrl) return;
71
 
72
- // 유튜브 URL에서 비디오 ID 추출
73
  const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
74
  const match = this.videoUrl.match(regex);
75
  if (match && match[1]) {
 
76
  this.videoEmbedUrl = `https://www.youtube.com/embed/${match[1]}`;
77
  } else{
78
- this.errorMessage = '유요한 유튜브 URL을 입력해주세요.';
79
  }
80
  },
81
  async processVideo() {
@@ -83,51 +100,136 @@ export default {
83
  this.results = [];
84
  this.responseMessage = '';
85
  this.loading = true;
 
86
 
87
  try {
88
- const response = await apiClient.post(`/process_youtube_video`, {
 
 
 
 
 
 
89
  video_url: this.videoUrl,
90
- query: this.query
91
- });
 
 
 
 
 
 
 
 
 
92
 
93
- this.responseMessage = response.data.message;
94
- if (response.data.results && response.data.results.length > 0) {
95
- this.results = response.data.results;
 
 
 
 
 
 
 
96
  } else {
97
- this.responseMessage = response.data.message || "결과를 찾을 수 없습니다.";
 
 
98
  }
99
  } catch (error) {
100
- console.error("API 호출 중 오류 발생:", error);
101
- if (error.response) {
102
- this.errorMessage = `오류: ${error.response.data.detail || error.response.statusText}`;
103
- } else if (error.request) {
104
- this.errorMessage = '서버 응답이 없습니다. 백엔드가 실행 중인지 확인하세요.';
105
- } else {
106
- this.errorMessage = '요청 설정 중 오류가 발생했습니다.';
107
- }
108
  } finally {
109
  this.loading = false;
110
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
112
  }
113
  }
114
  </script>
115
 
116
  <style>
117
- #app {
 
118
  font-family: Avenir, Helvetica, Arial, sans-serif;
119
  -webkit-font-smoothing: antialiased;
120
  -moz-osx-font-smoothing: grayscale;
121
  text-align: center;
122
  color: #2c3e50;
123
  margin-top: 60px;
124
- max-width: 800px;
125
  margin-left: auto;
126
  margin-right: auto;
127
  padding: 20px;
128
  border: 1px solid #eee;
129
  border-radius: 8px;
130
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
 
133
  .input-section {
@@ -157,6 +259,7 @@ button {
157
  cursor: pointer;
158
  font-size: 16px;
159
  transition: background-color 0.3s ease;
 
160
  }
161
 
162
  button:hover:not(:disabled) {
@@ -172,36 +275,78 @@ button:disabled {
172
  color: red;
173
  margin-top: 20px;
174
  font-weight: bold;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
 
177
  .info-message {
178
  color: #3498db;
179
  margin-top: 20px;
180
  font-style: italic;
 
 
 
 
181
  }
182
 
183
- .results-section {
184
- margin-top: 30px;
185
- text-align: left;
186
- border-top: 1px solid #eee;
187
- padding-top: 20px;
188
  }
189
 
190
- .results-section h2 {
191
- text-align: center;
192
- color: #333;
 
 
 
 
 
193
  }
194
 
195
  .result-item {
196
  background-color: #f9f9f9;
197
- border: 1px solid #ddd;
198
- border-radius: 6px;
199
  padding: 15px;
200
- margin-bottom: 15px;
 
201
  }
202
 
203
  .result-item p {
204
- margin: 5px 0;
 
 
 
 
 
 
205
  }
206
 
207
  .result-item a {
@@ -213,11 +358,12 @@ button:disabled {
213
  text-decoration: underline;
214
  }
215
 
216
- .video-embed{
217
- margin: 20px 0;
 
 
218
  border-radius: 8px;
219
  overflow: hidden;
220
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
221
  position: relative;
222
  padding-bottom: 56.25%;
223
  height: 0;
 
1
  <template>
2
+ <div id="app" class="main-container">
3
+ <div class="left-panel">
4
+ <h1>YouTube Transcript Extraction</h1>
5
+ <p>영상 URL과 찾고 싶은 내용을 입력해주세요.</p>
6
 
7
+ <div class="input-section">
8
+ <label for="videoUrl">YouTube 영상 URL:</label>
9
+ <input type="text" id="videoUrl" v-model="videoUrl" placeholder="예: https://www.youtube.com/watch?v=xxxxxxxxxxx" @input="updateVideoEmbed" />
10
+ </div>
11
 
12
+ <div v-if="videoEmbedUrl" class="video-embed">
13
+ <iframe
14
+ :src="videoEmbedUrl"
15
+ width="100%"
16
+ height="400"
17
+ frameborder="0"
18
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
19
+ allowfullscreen
20
+ id="youtube-iframe" ></iframe>
21
+ </div>
 
22
 
23
+ <div class="input-section">
24
+ <label for="query">찾을 내용 (쿼리):</label>
25
+ <input type="text" id="query" v-model="query" placeholder="예: RAG 기술의 장점은?" />
26
+ </div>
27
 
28
+ <button @click="processVideo" :disabled="loading" class="center-button">
29
+ {{ loading ? '처리 중...' : '영상 탐색 시작' }}
30
+ </button>
31
 
32
+ <div v-if="errorMessage" class="error-message">
33
+ {{ errorMessage }}
34
+ </div>
35
 
36
+ <div v-if="generatedAnswer" class="generated-answer-section">
37
+ <h2>✨ 생성된 답변:</h2>
38
+ <p>{{ generatedAnswer }}</p>
39
+ </div>
40
+
41
+ <div v-if="responseMessage && !generatedAnswer && !errorMessage" class="info-message">
42
+ {{ responseMessage }}
43
  </div>
44
  </div>
45
+
46
+ <div class="right-sidebar">
47
+ <h2>검색 결과 (타임 라인):</h2>
48
+ <div v-if="loading && results.length === 0 && !errorMessage">
49
+ <p>검색 결과를 불러오는 중...</p>
50
+ </div>
51
+ <div v-else-if="results.length > 0">
52
+ <div v-for="(result, index) in results" :key="index" class="result-item">
53
+ <p>
54
+ <strong class="timestamp-link" @click="seekVideo(result.timestamp)">
55
+ 시간: {{ result.timestamp }}
56
+ </strong>
57
+ </p>
58
+ <p><strong>내용:</strong> {{ result.text }}</p>
59
+ </div>
60
+ </div>
61
+ <div v-else>
62
+ <p>아직 검색 결과가 없습니다.</p>
63
+ </div>
64
  </div>
65
  </div>
66
  </template>
67
 
68
  <script>
 
 
69
  export default {
70
  name: 'App',
71
  data() {
 
76
  results: [],
77
  errorMessage: '',
78
  responseMessage: '',
79
+ videoEmbedUrl: '',
80
+ generatedAnswer: ''
81
  };
82
  },
83
  methods: {
 
84
  updateVideoEmbed() {
85
  this.videoEmbedUrl = '';
86
  this.errorMessage = '';
87
  if (!this.videoUrl) return;
88
 
 
89
  const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
90
  const match = this.videoUrl.match(regex);
91
  if (match && match[1]) {
92
+ // 유튜브 임베드 URL 수정 (http -> https, googleusercontent.com 제거)
93
  this.videoEmbedUrl = `https://www.youtube.com/embed/${match[1]}`;
94
  } else{
95
+ this.errorMessage = '유효한 유튜브 URL을 입력해주세요.';
96
  }
97
  },
98
  async processVideo() {
 
100
  this.results = [];
101
  this.responseMessage = '';
102
  this.loading = true;
103
+ this.generatedAnswer = '';
104
 
105
  try {
106
+ const backendUrl = process.env.VUE_APP_BACKEND_URL || 'http://localhost:7860';
107
+ const response = await fetch(`${backendUrl}/api/process_youtube_video`, {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Content-Type': 'application/json',
111
+ },
112
+ body: JSON.stringify({
113
  video_url: this.videoUrl,
114
+ query: this.query,
115
+ }),
116
+ })
117
+
118
+ const data = await response.json();
119
+
120
+ if (response.ok) {
121
+ if (data.status === 'success') {
122
+ this.results = data.retrieved_chunks || [];
123
+ this.generatedAnswer = data.generated_answer || '답변을 생성하지 못했습니다.';
124
+ this.responseMessage = data.message;
125
 
126
+ if (this.results.length === 0 && !this.generatedAnswer) {
127
+ this.errorMessage = '관련된 정보나 답변을 찾을 수 없습니다.';
128
+ } else if (this.results.length > 0 && !this.generatedAnswer) {
129
+ this.errorMessage = '검색된 정보를 바탕으로 답변을 생성하지 못했습니다. Ollama 서버를 확인해주세요.';
130
+ }
131
+ } else {
132
+ this.errorMessage = data.message || '영상을 처리하는 데 실패했습니다.';
133
+ this.results = [];
134
+ this.generatedAnswer = '';
135
+ }
136
  } else {
137
+ this.errorMessage = data.detail || '서버 오류가 발생했습니다.'
138
+ this.results = [];
139
+ this.generatedAnswer = '';
140
  }
141
  } catch (error) {
142
+ console.error('Error processing video:', error);
143
+ this.errorMessage = '네트워크 오류 또는 서버에 연결할 수 없습니다.';
144
+ this.results = [];
145
+ this.generatedAnswer = '';
 
 
 
 
146
  } finally {
147
  this.loading = false;
148
  }
149
+ },
150
+ // hh:mm:ss 형식을 초 단위로 변환하는 헬퍼 함수
151
+ timeToSeconds(timeString) {
152
+ const parts = timeString.split(':').map(Number);
153
+ if (parts.length === 3) { // HH:MM:SS
154
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
155
+ } else if (parts.length === 2) { // MM:SS (일부 자막은 이 형식일 수 있음)
156
+ return parts[0] * 60 + parts[1];
157
+ }
158
+ return 0; // 유효하지 않은 형식은 0으로 처리
159
+ },
160
+ // 비디오를 특정 시간으로 이동시키는 함수
161
+ seekVideo(timestamp) {
162
+ const seconds = this.timeToSeconds(timestamp);
163
+ const iframe = document.getElementById('youtube-iframe');
164
+ if (iframe && iframe.src) {
165
+ const currentSrc = iframe.src;
166
+ // 기존 쿼리 파라미터 유지하고 &start=xx 추가 또는 업데이트
167
+ let newSrc;
168
+ const urlObj = new URL(currentSrc);
169
+ urlObj.searchParams.set('start', seconds);
170
+ // autoplay도 추가하여 클릭 시 재생되도록 할 수 있음
171
+ urlObj.searchParams.set('autoplay', '1');
172
+ newSrc = urlObj.toString();
173
+
174
+ iframe.src = newSrc;
175
+ } else {
176
+ this.errorMessage = '비디오 플레이어를 찾을 수 없습니다. URL을 먼저 입력해주세요.';
177
+ }
178
  }
179
  }
180
  }
181
  </script>
182
 
183
  <style>
184
+ /* 기존 #app 스타일을 main-container에 적용 및 수정 */
185
+ .main-container {
186
  font-family: Avenir, Helvetica, Arial, sans-serif;
187
  -webkit-font-smoothing: antialiased;
188
  -moz-osx-font-smoothing: grayscale;
189
  text-align: center;
190
  color: #2c3e50;
191
  margin-top: 60px;
192
+ max-width: 1200px; /* 전체 컨테이너 너비 증가 */
193
  margin-left: auto;
194
  margin-right: auto;
195
  padding: 20px;
196
  border: 1px solid #eee;
197
  border-radius: 8px;
198
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
199
+
200
+ /* Flexbox 레이아웃 설정 */
201
+ display: flex;
202
+ flex-direction: row; /* 자식 요소들을 가로로 배치 */
203
+ gap: 30px; /* 왼쪽 패널과 사이드바 사이의 간격 */
204
+ align-items: flex-start; /* 상단 정렬 */
205
+ }
206
+
207
+ /* 왼쪽 패널 ��타일 */
208
+ .left-panel {
209
+ flex: 2; /* 왼쪽 패널이 더 많은 공간 차지 (예: 66%) */
210
+ text-align: center; /* 왼쪽 패널 내 텍스트 왼쪽 정렬 */
211
+ padding-right: 15px; /* 사이드바와의 시각적 구분 */
212
+ }
213
+
214
+ /* 오른쪽 사이드바 스타일 */
215
+ .right-sidebar {
216
+ flex: 1; /* 오른쪽 사이드바가 남은 공간 차지 (예: 33%) */
217
+ text-align: left; /* 사이드바 내 텍스트 왼쪽 정렬 */
218
+ padding-left: 15px; /* 왼쪽 패널과의 시각적 구분 */
219
+ border-left: 1px solid #eee; /* 구분선 */
220
+ max-height: 80vh; /* 화면 높이의 80%를 넘지 않도록 */
221
+ overflow-y: auto; /* 내용이 넘치면 세로 스크롤바 생성 */
222
+ padding-bottom: 20px; /* 스크롤 시 하단 여백 */
223
+ }
224
+
225
+ /* 기존 스타일 유지 (수정된 .main-container, .left-panel, .right-sidebar 제외) */
226
+ h1 {
227
+ color: #42b983;
228
+ text-align: center; /* 제목 중앙 정렬 유지 */
229
+ }
230
+
231
+ p {
232
+ text-align: center; /* 설명 문구 중앙 정렬 유지 */
233
  }
234
 
235
  .input-section {
 
259
  cursor: pointer;
260
  font-size: 16px;
261
  transition: background-color 0.3s ease;
262
+ margin-top: 10px; /* 버튼 위쪽 여백 추가 */
263
  }
264
 
265
  button:hover:not(:disabled) {
 
275
  color: red;
276
  margin-top: 20px;
277
  font-weight: bold;
278
+ background-color: #ffebee;
279
+ border: 1px solid #ef9a9a;
280
+ padding: 10px;
281
+ border-radius: 4px;
282
+ }
283
+
284
+ .generated-answer-section {
285
+ margin-top: 30px;
286
+ padding: 20px;
287
+ background-color: #e8f5e9; /* 부드러운 녹색 배경 */
288
+ border-left: 5px solid #4CAF50; /* 왼쪽 테두리 */
289
+ border-radius: 5px;
290
+ text-align: left;
291
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
292
+ }
293
+
294
+ .generated-answer-section h2 {
295
+ color: #2e7d32; /* 진한 녹색 제목 */
296
+ margin-top: 0;
297
+ margin-bottom: 15px;
298
+ font-size: 1.5em;
299
+ }
300
+
301
+ .generated-answer-section p {
302
+ line-height: 1.6;
303
+ color: #333;
304
+ white-space: pre-wrap; /* 줄바꿈 및 공백 유지 */
305
  }
306
 
307
  .info-message {
308
  color: #3498db;
309
  margin-top: 20px;
310
  font-style: italic;
311
+ background-color: #e3f2fd;
312
+ border: 1px solid #90caf9;
313
+ padding: 10px;
314
+ border-radius: 4px;
315
  }
316
 
317
+ .timestamp-link {
318
+ color: #007bff; /* 파란색 링크 스타일 */
319
+ cursor: pointer; /* 클릭 가능하다는 표시 */
320
+ text-decoration: none; /* 밑줄 */
 
321
  }
322
 
323
+ .timestamp-link:hover {
324
+ color: #0056b3; /* 호버 시 색상 변경 */
325
+ }
326
+
327
+ .results-section h2 { /* 이 스타일은 이제 .right-sidebar h2로 대체될 수 있음 */
328
+ color: #2c3e50;
329
+ margin-bottom: 15px;
330
+ font-size: 1.3em;
331
  }
332
 
333
  .result-item {
334
  background-color: #f9f9f9;
335
+ border: 1px solid #eee;
336
+ border-radius: 4px;
337
  padding: 15px;
338
+ margin-bottom: 10px;
339
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
340
  }
341
 
342
  .result-item p {
343
+ margin: 0 0 5px 0;
344
+ color: #555;
345
+ text-align: left;
346
+ }
347
+
348
+ .result-item p strong {
349
+ color: #000;
350
  }
351
 
352
  .result-item a {
 
358
  text-decoration: underline;
359
  }
360
 
361
+ .video-embed {
362
+ margin-top: 20px;
363
+ margin-bottom: 30px;
364
+ background-color: #000;
365
  border-radius: 8px;
366
  overflow: hidden;
 
367
  position: relative;
368
  padding-bottom: 56.25%;
369
  height: 0;