# 설계서: 영상-식당 관련도 LLM 평가로 약한 매칭 자동 숨김 (#356)

> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #356 · 유사 패턴: #322(식당 LLM 검증, 09-Done) · 부모 영역: #270(영상→식당 추출 파이프라인 현행화, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java`(신규), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java`(신규), DB 마이그레이션 SQL
> · 테스트: 본 범위 밖 (테스트 인프라 #343 도입됨, 후속에서 점진 확장)

## 1. 목적 (Why)

식당 상세에 연결된 영상 중 식당과 **본격적으로 관련 없는 약한 언급**(비교 대상, 일반 토픽 중 잠깐 언급, 식당 입점 전 영상 등)이 노이즈로 표시. 실제 케이스 — 파이브가이즈 강남의 영상 7개 중 3건이 약한 매칭(쉐이크쉑 비교 / 미국 비만율 일반 토픽 / 한국 입점 전 미국 여행). LLM 평가로 약한 매칭 자동 숨김.

## 2. 범위 (Scope)

- **포함**
  - `video_restaurants` 테이블에 `relevance`, `relevance_reason`, `relevance_evaluated_at` 컬럼 추가.
  - `VideoRelevanceService` 신규 — LLM 판정 + DB 반영 (`#322` 패턴 모방).
  - `PipelineService.processExtract` 완료 후 `verifyAsync(linkId)` 호출 — 신규 등록 자동 평가.
  - `GET /api/restaurants/{id}/videos`: 기본 `relevance = 'strong'`만 응답. `?include_weak=true` 시 모두 포함.
  - 어드민 API: 단건 재평가 / 일괄 백필 / 수동 토글.
- **제외 (별도 후속)**
  - 어드민 UI(검증 칼럼 / 토글) — `#322`의 RestaurantsPanel UI와 같은 패턴으로 별도 후속.
  - 프론트 사용자 옵션 UI("약한 매칭도 보기" 토글) — 별도 후속.
  - LLM 비용 모니터링/메트릭 — 별도.

## 3. 인수조건

- [ ] `video_restaurants` 테이블에 `relevance VARCHAR2(16) DEFAULT 'unknown'`, `relevance_reason VARCHAR2(120)`, `relevance_evaluated_at TIMESTAMP` 컬럼 + `idx_vr_relevance` 인덱스.
- [ ] 가능한 값: `strong | weak | incidental | unknown` (unknown = 미평가).
- [ ] 신규 등록 시 60초 안에 `relevance_evaluated_at` 설정.
- [ ] `GET /api/restaurants/{id}/videos` 기본 응답: `relevance IN ('strong','unknown')` (안전한 기본값 = 평가 실패 시 표시).
- [ ] `?include_weak=true`: 모두 포함 + `relevance`, `relevance_reason` 필드 동봉.
- [ ] 어드민 API:
  - `GET /api/admin/video-relevance/pending` → 미평가(unknown) 카운트
  - `POST /api/admin/video-relevance/all?batchSize=10` → 백필
  - `POST /api/admin/video-relevance/{linkId}/evaluate` → 단건 재평가
  - `PATCH /api/admin/video-relevance/{linkId}` → 수동 강제 토글 `{relevance, reason}`
- [ ] LLM 호출 실패 시 `unknown` 유지 + 로그 (`#322`와 같은 안전 기본값).
- [ ] 빌드/배포 회귀 없음.

## 4. 컨텍스트 & 제약

- 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
- LLM 비용: 영상-식당 페어당 1회 단발. 현재 1,244건 → 백필 시 약 1,244 호출.
- `video_restaurants`는 한 영상에 여러 식당, 한 식당에 여러 영상이 m:n 관계.
- 같은 페어는 `relevance_evaluated_at`이 NULL 아니면 재평가 안 함 (캐시).

## 5. 아키텍처 개요

```
PipelineService.processExtract (기존)
   │
   ▼
RestaurantService.linkVideoRestaurant (video_restaurants INSERT)
   │
   ▼
VideoRelevanceService.verifyAsync(linkId)  ← #356 신규
   │ (비동기)
   ▼
OciGenAiService.chat(prompt, 120)
   │
   ▼
parseRelevance → { relevance: strong|weak|incidental, reason: string }
   │
   ▼
RestaurantMapper.updateRelevance(linkId, relevance, reason)
   │
   ▼ (조회 시)
RestaurantMapper.findVideoLinks(restaurantId, includeWeak)
   ├ includeWeak=false (기본): WHERE relevance IN ('strong','unknown')
   └ includeWeak=true:  모두 + relevance/reason 필드 노출
```

## 6. 데이터 모델

### DB 마이그레이션
```sql
ALTER TABLE video_restaurants ADD (
  relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
  relevance_reason VARCHAR2(120),
  relevance_evaluated_at TIMESTAMP
);
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
```

### 도메인 (`VideoRestaurantLink` 확장은 본 범위 밖 — findVideoLinks는 `resultType="map"`)
응답 Map에 키 추가:
- `relevance`: `"strong" | "weak" | "incidental" | "unknown"`
- `relevance_reason`: `string | null`

### LLM 응답 스키마
```json
{
  "relevance": "strong" | "weak" | "incidental",
  "reason": "20자 이내"
}
```

## 7. 함수 명세

| 함수 | 책임 | 비고 |
|---|---|---|
| `VideoRelevanceService.verifyAsync(linkId)` | 비동기 트리거 | `#322`의 `RestaurantVerifyService.verifyAsync` 유사 |
| `VideoRelevanceService.verify(linkId)` | 단건 검증 + DB 반영 | LLM 실패 시 unknown 유지 |
| `VideoRelevanceService.verifyAll(batchSize)` | 백필 (식당당 200ms sleep) | |
| `VideoRelevanceService.buildPrompt(...)` | 프롬프트 생성 | 식당명·주소·음식·영상 제목·평가 |
| `VideoRelevanceService.parseRelevance(raw)` | LLM 응답 → DTO | 파싱 실패 시 unknown 안전 기본값 |
| `RestaurantMapper.updateRelevance(linkId, rel, reason)` | DB 갱신 | |
| `RestaurantMapper.findVideoLinks(restaurantId, includeWeak)` | 기존 SQL에 WHERE 조건 추가 | |
| `AdminVideoRelevanceController` 신규 | 4개 admin endpoint | requireAdmin |

## 8. 흐름

### 신규 등록 자동 평가
1. `PipelineService.processExtract` → `linkVideoRestaurant` → linkId 획득.
2. `VideoRelevanceService.verifyAsync(linkId)` 호출(@Async).
3. 별도 스레드: 영상/식당/평가 데이터 조회 → buildPrompt → LLM → parse → DB.

### 백필
1. 어드민 `POST /api/admin/video-relevance/all` 호출.
2. `WHERE relevance_evaluated_at IS NULL` 인 link 10개씩 조회 → 순차 검증.
3. 식당당 200ms sleep (LLM rate 보호).

### 프롬프트 예시
```
다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.

식당명: {restaurantName}
주소: {address}
음식: {foodsMentioned}

영상 제목: {videoTitle}
영상 채널: {channelName}
영상에 등장한 평가 내용: {evaluation}

응답 형식(JSON만, 다른 텍스트 없이):
{"relevance": "strong"|"weak"|"incidental", "reason": "20자 이내 한국어"}

가이드:
- strong: 영상 본편이 이 식당을 본격 다룸. 방문 리뷰, 메뉴 평가 등.
- weak: 영상에서 잠깐 언급, 비교 대상으로만 등장, 다른 식당의 일부로.
- incidental: 식당 입점 전 영상에서 단순 언급, 일반 토픽(미국 비만, 환율 등)에서 잠깐.
- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).
```

## 9. 엣지케이스

- **LLM 응답 비-JSON**: parseRelevance → unknown 기본값.
- **LLM 호출 실패**: unknown 유지 → 다음 백필 재시도.
- **영상 데이터 누락(transcript 없음, evaluation 비어있음)**: 프롬프트에 "(미상)" 표기. LLM이 판정 어려우면 strong 보수적.
- **동시성**: 같은 linkId verifyAsync 두 번 호출 → idempotent.
- **삭제된 영상**: linkId 조회 결과 없으면 no-op.

## 10. 테스트 (수동)

- 파이브가이즈 강남 케이스 백필 → 7건 중 3건이 weak/incidental로 마킹되는지 확인.
- 공개 API `/api/restaurants/{id}/videos` → 약한 매칭 제외 확인.
- `?include_weak=true` → 모두 포함 확인.

## 11. 리스크 & 대안

- **선택**: `#322` 동일 패턴 + DB 마이그레이션.
- **대안 A**: 사용자가 직접 "약한 매칭도 보기" 토글 → 사용자 결정 부담.
- **대안 B**: 추출 단계에서 한 번에 판정 → 비용 ↓이지만 ExtractorService 비대.
- **트레이드오프**: 단발 LLM 평가는 비용 합리적. false positive는 어드민 수동 토글 + `unknown` 안전 기본값으로 보완.

## 12. 미해결 질문

- 임계값(weak/incidental 둘 다 숨김 vs incidental만 숨김) — 현재는 둘 다 숨김.
- 영상 자막 전체를 LLM에 보낼지 vs 평가 텍스트만 → 비용/정확도 트레이드오프. 현재는 evaluation만(짧음).
- 사용자에게 "약한 매칭도 보기" UI → 별도 후속.
- 어드민 UI — 별도 후속 (#322 패턴 모방).
