Conversation
Contest API와 Policy Admin 파이프라인을 동시에 유지하도록 DI·라우터·폰트 경로 충돌을 해결한다. Co-authored-by: Cursor <cursoragent@cursor.com>
RAG 설정 필드 이중 정의를 제거하고 prod 동작값(rerank off, top_k=10)을 단일 정의로 고정한다. Gemini 팩토리·민원 서비스 조립·pin_content 유틸·카드뉴스 경로 해석을 공통 모듈로 추출해 deps/main/transform 간 중복 코드를 줄인다.
Linkareer 공모전을 정책 핀과 같은 3단계(크롤→transform→import)로 자동 적재한다. contest_api_id 기준 중복과 종료일(KST) 경과 건은 LLM·DB 모두 스킵하고, attachments/media-cdn 이미지 분류·S3 카드뉴스 업로드·매일 KST 12시 sync를 포함한다. - ContestAdminRoute: /sync, /status, crawl·transform·import 배치 API - ContestEventIngestService: pin/event_pin/community/pin_image/cardnews INSERT - EventPin.contest_api_id 및 EventPinRepo 조회·중복 제거 - contest_pin_transform: handoff 병합, S3 카드뉴스, pending/만료 스킵 - ContestPinSchedulerService 및 CONTEST_* 설정·deps·main 등록 - fetch_linkareer_contests: start_page, pin_images, KST 만료 필터 - tests/test_contest_admin_batch.py
Feat/104 contest crawler db
[FEAT] contest api 구현
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive pipeline for crawling, processing, and importing contest and extracurricular activity information from Linkareer, complete with a dedicated Pillow-based browser-style cardnews rendering engine and a background scheduler. Key additions include services for managing contest documents, generating Gemini-powered slide copy and captions, uploading assets to S3, and administering batch imports. Feedback on the changes highlights several opportunities to improve robustness and performance: first, normalizing slide copy before checking for empty slides in prepare_contest_slides to prevent potential AttributeError exceptions; second, avoiding synchronous file I/O inside loops within ContestPinService.sync_pipeline by using a state-based loop instead; and third, offloading blocking synchronous file reads in get_pipeline_status to a separate thread using to_thread.run_sync to keep the async event loop responsive.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| while load_jsonl_rows(self.handoff_path()): | ||
| prev_pending = last_import.pending_import_count | ||
| import_batch = await ingest_service.import_handoff_batch( | ||
| import_all=False, | ||
| limit=effective_batch, | ||
| ) | ||
| _accumulate_import(import_batch) | ||
| _track_imported_ids(import_batch) | ||
| if import_batch.pending_import_count == 0: | ||
| break | ||
| if import_batch.inserted_count == 0 and import_batch.pending_import_count >= prev_pending: | ||
| break |
There was a problem hiding this comment.
while load_jsonl_rows(self.handoff_path()): 조건문은 루프가 돌 때마다 디스크에서 handoff JSONL 파일을 동기적으로 다시 읽어오므로 매우 비효율적이며 이벤트 루프를 블로킹합니다.
import_handoff_batch 내부에서 이미 파일을 읽고 처리한 후 남은 대기 건수(pending_import_count)를 반환하므로, 조건문을 while True로 변경하고 배치 처리 결과에 따라 루프를 종료하도록 수정하는 것이 성능상 훨씬 유리합니다.
| while load_jsonl_rows(self.handoff_path()): | |
| prev_pending = last_import.pending_import_count | |
| import_batch = await ingest_service.import_handoff_batch( | |
| import_all=False, | |
| limit=effective_batch, | |
| ) | |
| _accumulate_import(import_batch) | |
| _track_imported_ids(import_batch) | |
| if import_batch.pending_import_count == 0: | |
| break | |
| if import_batch.inserted_count == 0 and import_batch.pending_import_count >= prev_pending: | |
| break | |
| while True: | |
| prev_pending = last_import.pending_import_count | |
| import_batch = await ingest_service.import_handoff_batch( | |
| import_all=False, | |
| limit=effective_batch, | |
| ) | |
| _accumulate_import(import_batch) | |
| _track_imported_ids(import_batch) | |
| if import_batch.pending_import_count == 0: | |
| break | |
| if import_batch.inserted_count == 0 and import_batch.pending_import_count >= prev_pending: | |
| break |
| documents = load_jsonl_rows(CONTEST_DOCUMENTS_PATH) | ||
| handoff_by_id = load_rows_by_content_id(CONTEST_HANDOFF_PATH) |
There was a problem hiding this comment.
async def get_pipeline_status 함수 내에서 load_jsonl_rows 및 load_rows_by_content_id와 같은 동기식 파일 I/O 작업을 이벤트 루프에서 직접 실행하고 있습니다. 이는 대용량 파일 처리 시 이벤트 루프를 블로킹하여 애플리케이션의 응답성을 저하시킬 수 있습니다.
anyio.to_thread.run_sync를 사용하여 이러한 블로킹 I/O 작업을 별도의 스레드 풀에서 실행하도록 개선하는 것을 권장합니다.
| documents = load_jsonl_rows(CONTEST_DOCUMENTS_PATH) | |
| handoff_by_id = load_rows_by_content_id(CONTEST_HANDOFF_PATH) | |
| documents = await to_thread.run_sync(load_jsonl_rows, CONTEST_DOCUMENTS_PATH) | |
| handoff_by_id = await to_thread.run_sync(load_rows_by_content_id, CONTEST_HANDOFF_PATH) |
prepare_contest_slides에서 is_contest_slide_empty가 원본 슬라이드에 먼저 호출되어 items에 문자열이 있으면 AttributeError가 발생할 수 있고, value/content 키 항목이 비어 있다고 잘못 판단될 수 있었다. normalize_contest_slide_copy 후 필터링하도록 순서를 바꾸고, is_contest_slide_empty의 items 판별에 문자열 방어 처리를 추가한다.
hotfix: 공모전 슬라이드 빈 필터를 정규화 이후에 수행
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive pipeline for crawling, transforming, and rendering contest cardnews and pins, featuring a dedicated Pillow-based browser template with mascot integration and an automated background scheduler. Feedback on the changes highlights a critical security concern regarding missing admin authorization on the /contest-pins endpoints, a bug in the unhandled exception handler where detailed error messages are ignored, redundant normalization steps in the cardnews rendering logic, and a missing fallback to ensure at least one image is marked as the main image when only media CDN URLs are present.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| @router.post( | ||
| "/crawl", | ||
| response_model=SuccessEnvelope[ContestCrawlResult], | ||
| summary="1단계: Linkareer 크롤링 → contest_documents.jsonl 저장", | ||
| description=( | ||
| "Playwright로 Linkareer 공모전 목록·상세를 수집해 " | ||
| "rag/output/contest_documents.jsonl에 저장합니다. " | ||
| "서버에 Chromium(playwright install chromium)이 필요하며 요청이 수 분 걸릴 수 있습니다." | ||
| ), | ||
| ) | ||
| async def crawl_contests_from_linkareer( |
There was a problem hiding this comment.
There was a problem hiding this comment.
해당엔드포인트는 prod배포에서 disable할예정입니다.
| async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: | ||
| logger.exception("Unhandled exception on %s %s", request.method, request.url.path) | ||
| ec = ErrorCode.INTERNAL_SERVER_ERROR | ||
| return _error_response(http_status=status.HTTP_500_INTERNAL_SERVER_ERROR, code=ec) | ||
| message = ec.message | ||
| if settings.env in ("local", "dev"): | ||
| detail = str(exc).strip() or repr(exc) | ||
| message = f"{ec.message}: {detail}" | ||
| return _error_response( | ||
| http_status=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| code=ec, | ||
| message=message, | ||
| ) |
| def collect_contest_pin_image_specs(image_urls: list[str]) -> list[dict[str, Any]]: | ||
| """attachments 첫 URL만 is_main=True, 이후 attachments 스킵, media-cdn은 전부 is_main=False.""" | ||
| specs: list[dict[str, Any]] = [] | ||
| main_assigned = False | ||
|
|
||
| for raw in image_urls: | ||
| url = str(raw or "").strip() | ||
| if not url: | ||
| continue | ||
| if _is_attachment_url(url): | ||
| if main_assigned: | ||
| continue | ||
| main_assigned = True | ||
| specs.append({"pin_image_url": url, "is_main": True}) | ||
| elif _is_media_cdn_url(url): | ||
| specs.append({"pin_image_url": url, "is_main": False}) | ||
|
|
||
| return normalize_pin_images_for_db(specs) |
There was a problem hiding this comment.
image_urls에 attachment URL이 없고 media-cdn URL만 존재하는 경우, 모든 이미지의 is_main이 False로 설정됩니다. 핀 이미지 중 최소 하나는 대표 이미지(is_main=True)로 지정되는 것이 안전하므로, 루프가 끝난 후에도 main_assigned가 False이고 specs가 비어있지 않다면 첫 번째 요소의 is_main을 True로 설정해주는 폴백 로직을 추가하는 것이 좋습니다.
def collect_contest_pin_image_specs(image_urls: list[str]) -> list[dict[str, Any]]:
"""attachments 첫 URL만 is_main=True, 이후 attachments 스킵, media-cdn은 전부 is_main=False."""
specs: list[dict[str, Any]] = []
main_assigned = False
for raw in image_urls:
url = str(raw or "").strip()
if not url:
continue
if _is_attachment_url(url):
if main_assigned:
continue
main_assigned = True
specs.append({"pin_image_url": url, "is_main": True})
elif _is_media_cdn_url(url):
specs.append({"pin_image_url": url, "is_main": False})
if not main_assigned and specs:
specs[0]["is_main"] = True
return normalize_pin_images_for_db(specs)| prepared = prepare_contest_slides(slides) | ||
| for row in prepared: | ||
| row["use_image"] = False | ||
| prepared = apply_deck_palette(prepared, rng=rng, contentid=contentid) | ||
| prepared = [normalize_contest_slide_copy(s) for s in prepared] | ||
| slides_to_render = [s for s in prepared if not is_contest_slide_empty(s)] |
There was a problem hiding this comment.
prepare_contest_slides 함수 내부에서 이미 normalize_contest_slide_copy 호출 및 is_contest_slide_empty 필터링을 모두 수행하여 반환합니다. 따라서 render_contest_cardnews_slides에서 prepare_contest_slides를 호출한 이후에 다시 동일한 정규화와 필터링을 반복하는 것은 불필요한 중복 연산입니다. 이를 제거하여 코드를 단순화할 수 있습니다.
| prepared = prepare_contest_slides(slides) | |
| for row in prepared: | |
| row["use_image"] = False | |
| prepared = apply_deck_palette(prepared, rng=rng, contentid=contentid) | |
| prepared = [normalize_contest_slide_copy(s) for s in prepared] | |
| slides_to_render = [s for s in prepared if not is_contest_slide_empty(s)] | |
| prepared = prepare_contest_slides(slides) | |
| for row in prepared: | |
| row["use_image"] = False | |
| slides_to_render = apply_deck_palette(prepared, rng=rng, contentid=contentid) |
공모전 글 기능을 배포합니다.