Skip to content

[deploy] contest 기능 배포#121

Merged
selnem merged 14 commits into
mainfrom
develop
Jun 14, 2026
Merged

[deploy] contest 기능 배포#121
selnem merged 14 commits into
mainfrom
develop

Conversation

@selnem

@selnem selnem commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

공모전 글 기능을 배포합니다.

qkwltkwkd1 and others added 12 commits May 29, 2026 17:33
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
@selnem selnem self-assigned this Jun 14, 2026
@selnem selnem added the 🚀 deploy 배포 label Jun 14, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/contest_cardnews/copy.py Outdated
Comment on lines +366 to +377
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

while load_jsonl_rows(self.handoff_path()): 조건문은 루프가 돌 때마다 디스크에서 handoff JSONL 파일을 동기적으로 다시 읽어오므로 매우 비효율적이며 이벤트 루프를 블로킹합니다.

import_handoff_batch 내부에서 이미 파일을 읽고 처리한 후 남은 대기 건수(pending_import_count)를 반환하므로, 조건문을 while True로 변경하고 배치 처리 결과에 따라 루프를 종료하도록 수정하는 것이 성능상 훨씬 유리합니다.

Suggested change
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

Comment on lines +355 to +356
documents = load_jsonl_rows(CONTEST_DOCUMENTS_PATH)
handoff_by_id = load_rows_by_content_id(CONTEST_HANDOFF_PATH)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

async def get_pipeline_status 함수 내에서 load_jsonl_rowsload_rows_by_content_id와 같은 동기식 파일 I/O 작업을 이벤트 루프에서 직접 실행하고 있습니다. 이는 대용량 파일 처리 시 이벤트 루프를 블로킹하여 애플리케이션의 응답성을 저하시킬 수 있습니다.

anyio.to_thread.run_sync를 사용하여 이러한 블로킹 I/O 작업을 별도의 스레드 풀에서 실행하도록 개선하는 것을 권장합니다.

Suggested change
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)

selnem added 2 commits June 14, 2026 14:53
prepare_contest_slides에서 is_contest_slide_empty가 원본 슬라이드에 먼저 호출되어
items에 문자열이 있으면 AttributeError가 발생할 수 있고, value/content 키 항목이
비어 있다고 잘못 판단될 수 있었다. normalize_contest_slide_copy 후 필터링하도록
순서를 바꾸고, is_contest_slide_empty의 items 판별에 문자열 방어 처리를 추가한다.
hotfix: 공모전 슬라이드 빈 필터를 정규화 이후에 수행
@selnem

selnem commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +20 to +30
@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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

/contest-pins/crawl/contest-pins/cardnews 등의 엔드포인트는 Playwright 크롤링 및 Gemini LLM API 호출(비용 발생), S3 업로드 등 리소스를 많이 소모하는 작업을 수행합니다. 하지만 ContestAdminRoute와 달리 AdminUserIdDep 의존성이 누락되어 있어, 인증되지 않은 외부 사용자가 악의적으로 호출하여 API 할당량을 소진하거나 서버 리소스를 고갈시킬 위험이 있습니다. 보안을 위해 관리자 권한 검증(AdminUserIdDep)을 추가해야 합니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당엔드포인트는 prod배포에서 disable할예정입니다.

Comment thread app/core/handlers.py
Comment on lines 64 to +75
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

_error_response 함수가 message 매개변수를 전달받지만, 내부적으로 failure_response를 호출할 때 이를 사용하지 않고 누락하고 있습니다. 이로 인해 local이나 dev 환경에서도 상세 에러 메시지가 클라이언트에 전달되지 않는 문제가 발생합니다. _error_responsemessage를 반영하도록 수정하거나, failure_response 호출 시 message를 함께 넘겨주도록 변경해야 합니다.

Comment on lines +20 to +37
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

image_urlsattachment URL이 없고 media-cdn URL만 존재하는 경우, 모든 이미지의 is_mainFalse로 설정됩니다. 핀 이미지 중 최소 하나는 대표 이미지(is_main=True)로 지정되는 것이 안전하므로, 루프가 끝난 후에도 main_assignedFalse이고 specs가 비어있지 않다면 첫 번째 요소의 is_mainTrue로 설정해주는 폴백 로직을 추가하는 것이 좋습니다.

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)

Comment on lines +73 to +78
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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

prepare_contest_slides 함수 내부에서 이미 normalize_contest_slide_copy 호출 및 is_contest_slide_empty 필터링을 모두 수행하여 반환합니다. 따라서 render_contest_cardnews_slides에서 prepare_contest_slides를 호출한 이후에 다시 동일한 정규화와 필터링을 반복하는 것은 불필요한 중복 연산입니다. 이를 제거하여 코드를 단순화할 수 있습니다.

Suggested change
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)

@selnem selnem merged commit bdbe8c5 into main Jun 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants