Skip to content

[deploy] policy 기능 배포#119

Merged
selnem merged 38 commits into
mainfrom
develop
Jun 13, 2026
Merged

[deploy] policy 기능 배포#119
selnem merged 38 commits into
mainfrom
develop

Conversation

@selnem

@selnem selnem commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

[deploy] policy 기능 배포

qkwltkwkd1 and others added 30 commits May 26, 2026 01:21
정책뉴스 수집→LLM 가공→DB INSERT 일괄 파이프라인(sync_pipeline)과
/policy-admin 엔드포인트(sync, transform-batch, import-batch, pipeline-status)를 추가한다.
KST 기준 자동 sync 스케줄러, 카드뉴스 S3 업로드·CardnewsImageS3 적재,
중복 건 스킵·파이프라인 정리, Community/EventPin(policy_api_id) 모델 확장을 포함한다.
sync_pipeline 다배치 실행 시 import errors/items/pin_ids가 마지막 배치만 반영되던 문제를 누적하도록 수정하고, 스케줄러 간격 비교에 1시간 버퍼를 적용해 실행 시각 오차로 sync가 하루 밀리는 현상을 방지한다. 동작하지 않던 cardnews_image_urls fallback을 제거하고, 카드뉴스 handoff_path 절대 경로 처리를 보완한다.
selnem and others added 8 commits June 12, 2026 23:24
/policy-admin 접근 제어와 정책 파이프라인 캡슐화를 보강하고, 카드뉴스 정리 및 nested transaction 실패 처리의 데이터 유실 위험을 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
/policy-admin 날짜 검증 예외가 400으로 처리되도록 보완하고, Community.pin_id nullable 변경으로 기존 데이터 마이그레이션 리스크를 줄인다.
handoff 적재와 sync_pipeline의 중복 DB 조회를 줄이고, 카드뉴스 파일 읽기·파이프라인 정리 작업을 스레드 풀에서 실행해 이벤트 루프 블로킹을 방지한다.
sync가 remaining_pending 기준으로 배치를 끝까지 돌고, import 후 캐시 정리로 handoff가 비어도 DB 적재 완료로 처리하도록 status·hint를 보강한다.
feat: 정책 핀 sync·배치 가공·DB 적재 파이프라인 및 관리자 API 추가
@selnem selnem self-assigned this Jun 13, 2026
@selnem selnem added the 🚀 deploy 배포 label Jun 13, 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 collecting, transforming, and importing government policy news to generate card news slides, adding new API clients, database models, rendering templates, and background schedulers. The review feedback focuses on asynchronous best practices and performance optimizations. Key recommendations include reusing a single httpx.AsyncClient instance for concurrent image downloads, standardizing layout names for consistency, and offloading CPU-bound rendering and synchronous file I/O operations to a thread pool using asyncio.to_thread or to_thread.run_sync to prevent blocking the event loop.

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 +14 to +39
async def download_cardnews_image(
url: str,
*,
timeout: float = 20.0,
referer: str = "",
) -> Image.Image | None:
if not url.startswith(("http://", "https://")):
return None
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
),
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
if referer:
headers["Referer"] = referer
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client:
response = await client.get(url)
response.raise_for_status()
with Image.open(BytesIO(response.content)) as img:
return img.convert("RGBA")
except (httpx.HTTPError, UnidentifiedImageError, OSError):
logger.warning("카드뉴스 배경 이미지 다운로드 실패: %s", url)
return None

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

Reusing a single httpx.AsyncClient instance across concurrent image downloads is highly recommended to avoid the overhead of repeatedly establishing TCP/TLS connections. Let's update download_cardnews_image to accept an optional client parameter.

async def download_cardnews_image(
    url: str,
    *,
    timeout: float = 20.0,
    referer: str = "",
    client: httpx.AsyncClient | None = None,
) -> Image.Image | None:
    if not url.startswith(("http://", "https://")):
        return None
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
        ),
        "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
    }
    if referer:
        headers["Referer"] = referer
    try:
        if client is not None:
            response = await client.get(url, headers=headers)
            response.raise_for_status()
            with Image.open(BytesIO(response.content)) as img:
                return img.convert("RGBA")
        else:
            async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as local_client:
                response = await local_client.get(url, headers=headers)
                response.raise_for_status()
                with Image.open(BytesIO(response.content)) as img:
                    return img.convert("RGBA")
    except (httpx.HTTPError, UnidentifiedImageError, OSError):
        logger.warning("카드뉴스 배경 이미지 다운로드 실패: %s", url)
        return None

Comment on lines +42 to +66
async def download_cardnews_images(
urls: list[str],
*,
timeout: float = 20.0,
referer: str = "",
) -> list[Image.Image]:
import asyncio

unique_urls: list[str] = []
seen: set[str] = set()
for url in urls:
url = (url or "").strip()
if not url or url in seen:
continue
seen.add(url)
unique_urls.append(url)

if not unique_urls:
return []

tasks = [
download_cardnews_image(url, timeout=timeout, referer=referer) for url in unique_urls
]
results = await asyncio.gather(*tasks)
return [img for img in results if img is not None]

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

Reusing a single httpx.AsyncClient instance across concurrent image downloads is highly recommended to avoid the overhead of repeatedly establishing TCP/TLS connections. Let's update download_cardnews_images to reuse a single client.

async def download_cardnews_images(
    urls: list[str],
    *,
    timeout: float = 20.0,
    referer: str = "",
) -> list[Image.Image]:
    import asyncio

    unique_urls: list[str] = []
    seen: set[str] = set()
    for url in urls:
        url = (url or "").strip()
        if not url or url in seen:
            continue
        seen.add(url)
        unique_urls.append(url)

    if not unique_urls:
        return []

    async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
        tasks = [
            download_cardnews_image(url, timeout=timeout, referer=referer, client=client) for url in unique_urls
        ]
        results = await asyncio.gather(*tasks)
    return [img for img in results if img is not None]

Comment on lines +202 to +205
working = [dict(s) for s in slides[:3]]
working[0]["layout_type"] = "cover_big_typo"
if len(working) > 1:
working[-1]["layout_type"] = "cta"

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

Using the standardized layout names (template_cover and template_cta) defined in TEMPLATE_LAYOUTS instead of legacy/inconsistent names (cover_big_typo and cta) improves code consistency and prevents potential mismatches in layout checks.

Suggested change
working = [dict(s) for s in slides[:3]]
working[0]["layout_type"] = "cover_big_typo"
if len(working) > 1:
working[-1]["layout_type"] = "cta"
working = [dict(s) for s in slides[:3]]
working[0]["layout_type"] = "template_cover"
if len(working) > 1:
working[-1]["layout_type"] = "template_cta"

Comment on lines +303 to +306
async def get_pipeline_status(self) -> PolicyPipelineStatusResult:
meta = self._load_sync_meta()
documents = load_jsonl_rows(POLICY_DOCUMENTS_PATH)
handoff_by_id = load_rows_by_content_id(POLICY_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

Since get_pipeline_status is called directly by the /status API endpoint, performing synchronous file I/O operations (load_jsonl_rows and load_rows_by_content_id) on the main thread will block the event loop. Offloading these operations to a thread pool using to_thread.run_sync is highly recommended.

Suggested change
async def get_pipeline_status(self) -> PolicyPipelineStatusResult:
meta = self._load_sync_meta()
documents = load_jsonl_rows(POLICY_DOCUMENTS_PATH)
handoff_by_id = load_rows_by_content_id(POLICY_HANDOFF_PATH)
async def get_pipeline_status(self) -> PolicyPipelineStatusResult:
meta = await to_thread.run_sync(self._load_sync_meta)
documents = await to_thread.run_sync(load_jsonl_rows, POLICY_DOCUMENTS_PATH)
handoff_by_id = await to_thread.run_sync(load_rows_by_content_id, POLICY_HANDOFF_PATH)

Comment on lines +177 to +179
if settings.policy_prune_pipeline_after_import:
db_ids_before = await ingest_service.get_imported_policy_api_ids()
prune_pipeline_imported(db_ids_before)

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

Calling prune_pipeline_imported synchronously inside the async sync_pipeline function will block the event loop because it performs synchronous file I/O on POLICY_DOCUMENTS_PATH and POLICY_HANDOFF_PATH. Let's offload this operation to a thread pool using asyncio.to_thread.

Suggested change
if settings.policy_prune_pipeline_after_import:
db_ids_before = await ingest_service.get_imported_policy_api_ids()
prune_pipeline_imported(db_ids_before)
if settings.policy_prune_pipeline_after_import:
db_ids_before = await ingest_service.get_imported_policy_api_ids()
await asyncio.to_thread(prune_pipeline_imported, db_ids_before)

content_id = row_content_id(row)
if not content_id or content_id in handoff_by_id:
continue
policy_id = parse_policy_api_id(row)

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

Performing synchronous file I/O operations (write_handoff_map) directly on the main thread inside the async transform_documents_jsonl function will block the event loop. Let's offload this operation to a thread pool using asyncio.to_thread.

    await asyncio.to_thread(write_handoff_map, handoff_by_id, dst)

Comment on lines +49 to +51
def _maybe_cleanup_local_cardnews(content_id: str) -> None:
if not settings.policy_cardnews_keep_local_files:
cleanup_local_cardnews_dir(content_id)

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

Since _maybe_cleanup_local_cardnews is called inside the async generate_cardnews_s3_images function, performing synchronous directory deletion (shutil.rmtree) directly on the main thread will block the event loop. Let's make this function async and offload the deletion to a thread pool using to_thread.run_sync.

Suggested change
def _maybe_cleanup_local_cardnews(content_id: str) -> None:
if not settings.policy_cardnews_keep_local_files:
cleanup_local_cardnews_dir(content_id)
async def _maybe_cleanup_local_cardnews(content_id: str) -> None:
if not settings.policy_cardnews_keep_local_files:
await to_thread.run_sync(cleanup_local_cardnews_dir, content_id)

),
)

_maybe_cleanup_local_cardnews(content_id)

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

Since _maybe_cleanup_local_cardnews has been updated to be an async function, we should await its execution.

Suggested change
_maybe_cleanup_local_cardnews(content_id)
await _maybe_cleanup_local_cardnews(content_id)

Comment on lines +201 to +207
save_slide_image_bytes(
contentid=content_id,
slide_no=slide_no,
image_bytes=image_bytes,
output_dir=target_dir,
)
uploaded.append(

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

Performing synchronous file I/O operations (save_slide_image_bytes) directly on the main thread inside the async generate_cardnews_s3_images function will block the event loop. Let's offload this operation to a thread pool using to_thread.run_sync.

                if settings.policy_cardnews_keep_local_files:
                    await to_thread.run_sync(
                        lambda: save_slide_image_bytes(
                            contentid=content_id,
                            slide_no=slide_no,
                            image_bytes=image_bytes,
                            output_dir=target_dir,
                        )
                    )

Comment on lines +170 to +171
image = _render_slide_image(ctx=ctx)
image.save(out_path, format="PNG")

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

Pillow image rendering (_render_slide_image) and file saving (image.save) are CPU-bound and I/O-bound operations. Executing them directly on the main thread inside the async render_policy_cardnews_slides function will block the event loop. Let's offload these operations to a thread pool using asyncio.to_thread.

Suggested change
image = _render_slide_image(ctx=ctx)
image.save(out_path, format="PNG")
import asyncio
image = await asyncio.to_thread(_render_slide_image, ctx=ctx)
await asyncio.to_thread(image.save, out_path, format="PNG")

@selnem selnem merged commit 9efbe5c into main Jun 13, 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