docs(design): #356 영상-식당 관련도 LLM 평가 설계서 (Architect)

video_restaurants.relevance(strong/weak/incidental/unknown) 컬럼 + VideoRelevanceService.
findVideoLinks에 includeWeak 파라미터. 어드민 4개 API.
#322 식당 LLM 검증과 동일 패턴.

설계서: docs/design/356-video-relevance-llm/README.md (Approved)
Refs: #356 (Architect)
This commit is contained in:
joungmin
2026-06-15 19:24:19 +09:00
parent 8152b71119
commit 88bbf3ca25

View File

@@ -0,0 +1,172 @@
# 설계서: 영상-식당 관련도 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 패턴 모방).