Skip to content
Merged
59 changes: 56 additions & 3 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,59 @@ class Settings(BaseSettings):
alias="POLICY_SYNC_LOOKBACK_DAYS",
description="배치 수집 시 오늘 포함 N일 (API 3일 제한 고려)",
)
policy_sync_interval_days: int = Field(
default=3,
ge=1,
le=30,
alias="POLICY_SYNC_INTERVAL_DAYS",
description="자동 sync 최소 간격(일)",
)
policy_sync_schedule_hour_kst: int = Field(
default=1,
ge=0,
le=23,
alias="POLICY_SYNC_SCHEDULE_HOUR_KST",
description="정책 핀 sync 스케줄 확인 시각 (KST)",
)
policy_admin_user_name: str = Field(
default="admin",
validation_alias=AliasChoices("POLICY_ADMIN_USER_NAME", "POLICY_ADMIN_NICKNAME"),

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

설정 필드 policy_admin_user_name에서 POLICY_ADMIN_NICKNAME을 별칭(alias)으로 허용하고 있지만, PolicyEventIngestService.resolve_admin_uid에서는 이 값을 항상 user_name 컬럼 기준으로만 조회(get_by_user_name)하고 있습니다. 만약 사용자가 POLICY_ADMIN_NICKNAME 환경 변수를 설정할 경우, 실제 DB의 nickname 컬럼이 아닌 user_name 컬럼에서 조회되어 사용자를 찾지 못하는 런타임 오류가 발생할 수 있습니다. 별칭에서 POLICY_ADMIN_NICKNAME을 제거하거나, 조회 시 닉네임과 유저명을 모두 고려하도록 로직을 수정하는 것이 안전합니다.

Suggested change
validation_alias=AliasChoices("POLICY_ADMIN_USER_NAME", "POLICY_ADMIN_NICKNAME"),
validation_alias=AliasChoices("POLICY_ADMIN_USER_NAME"),

description="정책 핀 등록에 사용할 user.user_name",
)
policy_sync_batch_size: int = Field(
default=5,
ge=1,
le=25,
alias="POLICY_SYNC_BATCH_SIZE",
description="sync/transform/import 1회 배치 건수",
)
policy_transform_concurrency: int = Field(
default=3,
ge=1,
le=10,
alias="POLICY_TRANSFORM_CONCURRENCY",
description="정책 pin_content·카드뉴스 Gemini 가공 동시 호출 수",
)
policy_prune_pipeline_after_import: bool = Field(
default=True,
alias="POLICY_PRUNE_PIPELINE_AFTER_IMPORT",
description="DB INSERT 성공 후 JSONL·로컬 카드뉴스 캐시 제거",
)
policy_cardnews_keep_local_files: bool = Field(
default=False,
alias="POLICY_CARDNEWS_KEEP_LOCAL_FILES",
description="True면 S3 업로드 후에도 rag/output/policy_cardnews 유지",
)
policy_sync_merge_documents: bool = Field(
default=False,
alias="POLICY_SYNC_MERGE_DOCUMENTS",
description="False면 sync 수집 시 policy_documents.jsonl을 이번 구간으로 덮어씀",
)
policy_cardnews_s3_prefix: str = Field(
default="policy-cardnews",
alias="POLICY_CARDNEWS_S3_PREFIX",
description="정책 카드뉴스 S3 object key prefix",
)
gemini_cardnews_image_model: str = Field(
default="gemini-2.5-flash-image",
alias="GEMINI_CARDNEWS_IMAGE_MODEL",
Expand Down Expand Up @@ -215,9 +268,9 @@ class Settings(BaseSettings):
rag_enable_rerank: bool = Field(default=False, alias="RAG_ENABLE_RERANK")
rag_vector_query_mode: str = Field(default="hybrid", alias="RAG_VECTOR_QUERY_MODE")
policy_cardnews_font_dir: str = Field(
default="app/assets/fonts",
default="../assets/fonts",
alias="POLICY_CARDNEWS_FONT_DIR",
description="Pretendard 등 TTF 폴더",
description="Pretendard 등 폰트 폴더 (app/policy_cardnews 기준 상대 경로)",
)

# 한국관광공사 TourAPI (공공데이터포털 활용신청 키)
Expand Down Expand Up @@ -277,7 +330,7 @@ class Settings(BaseSettings):
@classmethod
def _empty_string_policy_cardnews_font_dir(cls, value: object) -> object:
if value == "":
return "app/assets/fonts"
return "../assets/fonts"
return value

@field_validator("policy_cardnews_mascot_dir", mode="before")
Expand Down
47 changes: 47 additions & 0 deletions app/core/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.core.exceptions import raise_business_exception
from app.login.http_auth import get_current_user_id, get_optional_user_id
from app.models.User import User
from app.repositories.CardnewsImageS3Repo import CardnewsImageS3Repo
from app.repositories.CommunityRepo import CommunityRepo
from app.repositories.ComplaintPetitionRepo import ComplaintPetitionRepo
from app.repositories.DepartmentRepo import DepartmentRepo
Expand All @@ -29,7 +30,9 @@
from app.services.UserService import UserService
from app.services.ComplaintEmailService import ComplaintEmailService
from app.services.FestivalPinService import FestivalPinService
from app.services.PolicyEventIngestService import PolicyEventIngestService
from app.services.PolicyPinService import PolicyPinService
from app.services.internal.PolicyPinSchedulerService import PolicyPinSchedulerService
from app.services.ComplaintPetitionService import ComplaintPetitionService
from app.services.FestivalEventIngestService import FestivalEventIngestService
from app.services.RagRerankService import RagRerankService
Expand Down Expand Up @@ -519,3 +522,47 @@ def get_policy_pin_service() -> PolicyPinService:
PolicyPinService,
Depends(get_policy_pin_service),
]


def get_cardnews_image_s3_repo(session: DbSessionDep) -> CardnewsImageS3Repo:
return CardnewsImageS3Repo(session)


CardnewsImageS3RepoDep = Annotated[CardnewsImageS3Repo, Depends(get_cardnews_image_s3_repo)]


def get_policy_event_ingest_service(
pin_repo: PinRepoDep,
event_pin_repo: EventPinRepoDep,
community_repo: CommunityRepoDep,
cardnews_image_s3_repo: CardnewsImageS3RepoDep,
user_repo: UserRepoDep,
) -> PolicyEventIngestService:
return PolicyEventIngestService(
pin_repo=pin_repo,
event_pin_repo=event_pin_repo,
community_repo=community_repo,
cardnews_image_s3_repo=cardnews_image_s3_repo,
user_repo=user_repo,
)


PolicyEventIngestServiceDep = Annotated[
PolicyEventIngestService,
Depends(get_policy_event_ingest_service),
]


def get_policy_pin_scheduler(request: Request) -> PolicyPinSchedulerService | None:
scheduler = getattr(request.app.state, "policy_pin_scheduler", None)
if scheduler is None:
return None
if not isinstance(scheduler, PolicyPinSchedulerService):
raise RuntimeError("policy_pin_scheduler is not initialized correctly.")
return scheduler


PolicyPinSchedulerDep = Annotated[
PolicyPinSchedulerService | None,
Depends(get_policy_pin_scheduler),
]
30 changes: 23 additions & 7 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from app.services.RagRetrievalService import RagRetrievalService
from app.services.VectorStoreService import VectorStoreService
from app.services.internal.ComplaintPetitionSchedulerService import ComplaintPetitionSchedulerService
from app.services.internal.PolicyPinSchedulerService import PolicyPinSchedulerService
from app.services.internal.ai.ComplaintEmailLLMService import ComplaintEmailLLMService
from app.services.internal.ai.VLMService import VLMService
from app.services.internal.ai.gemini_retry import parse_gemini_model_list
Expand Down Expand Up @@ -154,7 +155,7 @@ async def lifespan(app: FastAPI):
exc,
)

scheduler = None
complaint_scheduler = None
if (
gemini_api_key_secret is not None
and getattr(app.state, "vector_store_service", None) is not None
Expand Down Expand Up @@ -193,24 +194,39 @@ async def lifespan(app: FastAPI):
complaint_llm_service=complaint_llm_service,
rag_retrieval_service=rag_retrieval_service,
)
scheduler = ComplaintPetitionSchedulerService(
complaint_scheduler = ComplaintPetitionSchedulerService(
complaint_email_service=complaint_email_service,
s3_util=app.state.s3_util,
)
scheduler.start()
app.state.complaint_scheduler = scheduler
complaint_scheduler.start()
app.state.complaint_scheduler = complaint_scheduler
except Exception as exc:
logger.warning("Complaint scheduler initialization failed: %s", exc)

policy_pin_scheduler = None
if settings.policy_news_service_key is not None and gemini_api_key_secret is not None:
try:
policy_pin_scheduler = PolicyPinSchedulerService(s3_util=app.state.s3_util)
policy_pin_scheduler.start()
app.state.policy_pin_scheduler = policy_pin_scheduler
except Exception as exc:
logger.warning("Policy pin scheduler initialization failed: %s", exc)

try:
yield
finally:
scheduler = getattr(app.state, "complaint_scheduler", None)
if scheduler is not None:
complaint_scheduler = getattr(app.state, "complaint_scheduler", None)
if complaint_scheduler is not None:
try:
await scheduler.stop()
await complaint_scheduler.stop()
except Exception as exc:
logger.warning("Complaint scheduler stop failed: %s", exc)
policy_pin_scheduler = getattr(app.state, "policy_pin_scheduler", None)
if policy_pin_scheduler is not None:
try:
await policy_pin_scheduler.stop()
except Exception as exc:
logger.warning("Policy pin scheduler stop failed: %s", exc)
try:
await ComplaintEmailPdfService.stop_playwright_browser()
except Exception as exc:
Expand Down
40 changes: 40 additions & 0 deletions app/models/CardnewsImageS3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from sqlalchemy import BigInteger, ForeignKey, Identity, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.core.database import Base
from app.models.Community import Community


class CardnewsImageS3(Base):
__tablename__ = "cardnews_image_s3"

cardnews_image_s3_id: Mapped[int] = mapped_column(
"cardnews_image_s3_id",
BigInteger,
Identity(),
primary_key=True,
)
community_id: Mapped[int] = mapped_column(
"community_id",
BigInteger,
ForeignKey("community.community_id"),
nullable=False,
)
community: Mapped[Community] = relationship(
"Community",
foreign_keys=[community_id],
back_populates="cardnews_images",
lazy="selectin",
)
cardnews_image_s3_key: Mapped[str] = mapped_column(
"cardnews_image_s3_key",
String(500),
nullable=False,
)
cardnews_image_s3_url: Mapped[str] = mapped_column(
"cardnews_image_s3_url",
String(500),
nullable=False,
)
16 changes: 14 additions & 2 deletions app/models/Community.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from sqlalchemy import BigInteger, ForeignKey, Identity
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Float, ForeignKey, Identity, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.BaseEntity import BaseEntity

Expand All @@ -21,3 +21,15 @@ class Community(BaseEntity):
ForeignKey("pin.pin_id"),
nullable=True,
)
community_type: Mapped[str] = mapped_column(
"community_type",
String(50),
nullable=False,
server_default="POLICY",
)
popularity: Mapped[float] = mapped_column("popularity", Float, nullable=False, default=0.0)
cardnews_images: Mapped[list["CardnewsImageS3"]] = relationship(
"CardnewsImageS3",
back_populates="community",
lazy="selectin",
)
8 changes: 7 additions & 1 deletion app/models/EventPin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ class EventPin(BaseEntity):
back_populates="event_pin",
lazy="selectin",
)
festival_api_id: Mapped[int] = mapped_column(
festival_api_id: Mapped[int | None] = mapped_column(
"festival_api_id",
BigInteger,
unique=True,
nullable=True,
)
policy_api_id: Mapped[int | None] = mapped_column(
"policy_api_id",
BigInteger,
unique=True,
nullable=True,
)
Comment thread
selnem marked this conversation as resolved.
event_start_time: Mapped[datetime] = mapped_column("event_start_time", DateTime, nullable=False)
event_end_time: Mapped[datetime] = mapped_column("event_end_time", DateTime, nullable=False)
discount: Mapped[str | None] = mapped_column("discount", String(255), nullable=True)
4 changes: 4 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from app.models.CardnewsImageS3 import CardnewsImageS3
from app.models.Community import Community
from app.models.ComplaintPetition import ComplaintPetition
from app.models.Department import Department
from app.models.DbConnectionCheck import DbConnectionCheck
Expand All @@ -24,6 +26,8 @@
from app.models.enum.UserRole import UserRole

__all__ = [
"CardnewsImageS3",
"Community",
"DbConnectionCheck",
"ComplaintPetition",
"Department",
Expand Down
11 changes: 10 additions & 1 deletion app/policy_cardnews/template/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,17 @@
MASCOT_LAYOUTS = {LAYOUT_COVER, LAYOUT_CTA, LAYOUT_THREE_COL, LAYOUT_NUMBERED}

_REPO_ROOT = Path(__file__).resolve().parents[3]
# JS import처럼 app/policy_cardnews 패키지 기준 상대 경로 해석
_POLICY_CARDNEWS_DIR = Path(__file__).resolve().parents[1]


def _font_dir() -> Path:
from app.core.config import settings

return Path(settings.policy_cardnews_font_dir)
raw = Path(settings.policy_cardnews_font_dir)
if raw.is_absolute():
return raw
return (_POLICY_CARDNEWS_DIR / raw).resolve()


@dataclass(frozen=True)
Expand Down Expand Up @@ -244,6 +249,10 @@ def _load_font(size: int, *, bold: bool = False, extra_bold: bool = False) -> Im
path = base_dir / name
if path.is_file():
return ImageFont.truetype(str(path), size=size)
logger.warning(
"Pretendard 폰트를 찾지 못해 기본 폰트로 렌더합니다 (font_dir=%s)",
base_dir,
)
return ImageFont.load_default()


Expand Down
27 changes: 27 additions & 0 deletions app/repositories/CardnewsImageS3Repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.CardnewsImageS3 import CardnewsImageS3
from app.repositories.BaseRepo import BaseRepo


class CardnewsImageS3Repo(BaseRepo[CardnewsImageS3]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, CardnewsImageS3)

async def list_by_community_id(self, community_id: int) -> list[CardnewsImageS3]:
result = await self.session.execute(
select(CardnewsImageS3)
.where(CardnewsImageS3.community_id == community_id)
.order_by(CardnewsImageS3.cardnews_image_s3_id.asc()),
)
return list(result.scalars().all())

async def delete_by_community_id(self, community_id: int) -> int:
result = await self.session.execute(
delete(CardnewsImageS3).where(CardnewsImageS3.community_id == community_id),
)
await self.session.flush()
return int(result.rowcount or 0)
6 changes: 6 additions & 0 deletions app/repositories/CommunityRepo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ async def get_community_id_by_pin_id(self, pin_id: int) -> int | None:
)
value = result.scalar_one_or_none()
return int(value) if value is not None else None

async def get_by_pin_id(self, pin_id: int) -> Community | None:
result = await self.session.execute(
select(Community).where(Community.pin_id == pin_id).limit(1),
)
return result.scalar_one_or_none()
Loading
Loading