feat(verify): #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김

DB 마이그레이션 (운영 ATP에 사전 실행 완료):
- restaurants.hidden NUMBER(1) DEFAULT 0 NOT NULL
- restaurants.hidden_reason VARCHAR2(120)
- restaurants.verified_at TIMESTAMP
- idx_restaurants_hidden 인덱스

코드:
- Restaurant 도메인에 hidden/hiddenReason/verifiedAt 필드 추가
- RestaurantMapper.xml resultMap 갱신 + findAll에 hidden=0 조건 (includeHidden=true 시 제외)
- RestaurantMapper에 updateVerification/clearHidden/findUnverified/countUnverified 추가
- RestaurantService.findAll() includeHidden 오버로드 + 검증 헬퍼 메서드
- RestaurantVerifyService 신규 (verify, verifyAsync, verifyAll, buildPrompt, parseVerifyResponse)
  - LLM 응답이 JSON 아닐 때 안전 기본값(valid=true) → hidden 유지
  - 백필은 식당당 200ms sleep으로 LLM rate 보호
- PipelineService.processExtract 끝에 verifyAsync(restId) 호출 (신규 등록 자동 검증)
- AdminRestaurantController 신규 — requireAdmin 필수:
  - GET  /api/admin/restaurants/verify/pending
  - POST /api/admin/restaurants/verify/all?batchSize=10
  - POST /api/admin/restaurants/{id}/verify
  - PATCH /api/admin/restaurants/{id}/hidden {hidden, reason}

프롬프트:
- 식당명, 주소, 지역, cuisine, foods를 OCI GenAI로 보내 valid/is_franchise/reason 판정
- 보수적 가이드 (모호하면 valid=true)

설계서: docs/design/322-restaurant-llm-verify/README.md (Approved 대기)

Refs: #322
This commit is contained in:
joungmin
2026-06-15 13:04:23 +09:00
parent d3cd1b5d5f
commit d2e78b0363
9 changed files with 516 additions and 5 deletions

View File

@@ -22,6 +22,9 @@
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="updated_at"/>
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
<result property="hiddenReason" column="hidden_reason"/>
<result property="verifiedAt" column="verified_at"/>
</resultMap>
<!-- ===== Queries ===== -->
@@ -29,7 +32,8 @@
<select id="findAll" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at
r.business_status, r.rating, r.rating_count, r.updated_at,
r.hidden, r.hidden_reason, r.verified_at
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
@@ -39,6 +43,9 @@
<where>
r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
<if test="includeHidden == null or !includeHidden">
AND r.hidden = 0
</if>
<if test="cuisine != null and cuisine != ''">
AND r.cuisine_type = #{cuisine}
</if>
@@ -277,4 +284,35 @@
ORDER BY r.name
</select>
<!-- ===== #322 LLM 검증 ===== -->
<update id="updateVerification">
UPDATE restaurants
SET hidden = #{hidden},
hidden_reason = #{hiddenReason,jdbcType=VARCHAR},
verified_at = CURRENT_TIMESTAMP
WHERE id = #{id}
</update>
<update id="clearHidden">
UPDATE restaurants
SET hidden = 0,
hidden_reason = NULL,
verified_at = CURRENT_TIMESTAMP
WHERE id = #{id}
</update>
<select id="findUnverified" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range,
r.hidden, r.hidden_reason, r.verified_at
FROM restaurants r
WHERE r.verified_at IS NULL
ORDER BY r.updated_at DESC
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="countUnverified" resultType="int">
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
</select>
</mapper>