Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions packages/ai-server/src/config/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ class InfrastructureContainer(DeclarativeContainer):
environment=environment,
)

# Generic LLM image-gen adapter (raw_posts + editorial_article 모두 사용, #429)
nano_banana_client: Singleton[NanoBananaClient] = Singleton(
NanoBananaClient,
api_key=Callable(lambda env: env.GEMINI_API_KEY, environment),
model=Callable(lambda env: env.NANO_BANANA_MODEL, environment),
timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment),
)

# #214 raw_posts_callback_client removed — ai-server writes DB directly.

# ─── Postgres asyncpg pool — assets (raw_posts) vs operation (운영 DB) 분리 (#333, #369) ───
Expand Down Expand Up @@ -207,17 +215,10 @@ class RawPostsContainer(DeclarativeContainer):
database_manager=infrastructure.assets_database_manager,
)

# #348 — processor stack (Nano Banana + Gemini Pro + Gemini Flash)
nano_banana_client: Singleton[NanoBananaClient] = Singleton(
NanoBananaClient,
api_key=Callable(lambda env: env.GEMINI_API_KEY, environment),
model=Callable(lambda env: env.NANO_BANANA_MODEL, environment),
timeout_seconds=Callable(lambda env: env.NANO_BANANA_TIMEOUT, environment),
)

# #348 — processor stack uses InfrastructureContainer.nano_banana_client (#429 이후 공유)
hero_reframe_service: Singleton[HeroReframeService] = Singleton(
HeroReframeService,
client=nano_banana_client,
client=infrastructure.nano_banana_client,
)

items_parser: Singleton[ItemsParser] = Singleton(
Expand All @@ -235,7 +236,7 @@ class RawPostsContainer(DeclarativeContainer):
# #352 — composite crop + nano-banana 1:1 catalog photo per item.
items_thumbnail_service: Singleton[ItemsThumbnailService] = Singleton(
ItemsThumbnailService,
client=nano_banana_client,
client=infrastructure.nano_banana_client,
r2=infrastructure.r2_client,
)

Expand Down
11 changes: 8 additions & 3 deletions packages/ai-server/src/editorial_article/graph.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Stage 2 multi-source LangGraph 조립.

3-node MVP: fetch_sources → compose_layout → publish.
실패 시 즉시 종료, error_log 누적, status=failed 마킹은 ARQ task 가 담당.
4 nodes: fetch_sources → compose_layout → generate_hero → publish.
fetch / compose 는 fail-fast, generate_hero 는 graceful (실패해도 publish 진행
하고 source post 이미지를 fallback hero 로 사용).
"""

from __future__ import annotations
Expand All @@ -10,6 +11,7 @@

from .nodes.compose_layout import compose_layout_node
from .nodes.fetch_sources import fetch_sources_node
from .nodes.generate_hero import generate_hero_node
from .nodes.publish import publish_node
from .state import EditorialArticleState

Expand All @@ -23,6 +25,7 @@ def create_editorial_article_graph():

builder.add_node("fetch_sources", fetch_sources_node)
builder.add_node("compose_layout", compose_layout_node)
builder.add_node("generate_hero", generate_hero_node)
builder.add_node("publish", publish_node)

builder.add_edge(START, "fetch_sources")
Expand All @@ -34,8 +37,10 @@ def create_editorial_article_graph():
builder.add_conditional_edges(
"compose_layout",
_route_after,
{"ok": "publish", "failed": END},
# generate_hero 는 graceful — 실패해도 publish 로 진행
{"ok": "generate_hero", "failed": END},
)
builder.add_edge("generate_hero", "publish")
builder.add_edge("publish", END)

return builder.compile()
101 changes: 101 additions & 0 deletions packages/ai-server/src/editorial_article/nodes/generate_hero.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""generate_hero node — nano-banana 매거진 cover 생성 + R2 업로드.

compose_layout 결과의 angle_title / subtitle / 첫 섹션 텍스트 → 매거진 cover
prompt → nano-banana → R2 → layout.hero_image_url 갱신.

Graceful fallback: nano-banana / R2 실패 시 graph 는 계속 진행 (publish 가
기존 hero_image_url — compose_layout 이 source post 에서 픽한 이미지 — 를
그대로 저장). 실패는 error_log 에만 기록, status='failed' 로 보지 않는다.
"""

from __future__ import annotations

import asyncio
import logging

from langchain_core.runnables import RunnableConfig

from src.managers.llm.adapters.nano_banana import NanoBananaClient, NanoBananaError
from src.managers.storage.r2_client import R2Client

from ..models import MagazineLayout


logger = logging.getLogger(__name__)


def _build_hero_prompt(layout: MagazineLayout) -> str:
intro_section = next((s for s in layout.sections if s.type == "intro"), None)
intro_body = (intro_section.body if intro_section and intro_section.body else "")[
:400
]

return f"""Create a high-fashion editorial magazine cover image, 16:9 cinematic composition.

Title: {layout.title}
Subtitle: {layout.subtitle or ''}
Editorial mood: {intro_body}

Requirements:
- Sophisticated, magazine-cover aesthetic (Hypebeast TAGGED-style editorial photography)
- Strong visual hierarchy — single dominant subject or composition
- High-end color grading, sharp focus, professional fashion photography lighting
- NO text overlay, NO logos, NO watermarks — image only
- 16:9 aspect ratio, optimized for Open Graph / hero banner
- Avoid generic stock-photo look; aim for editorial intent

Generate the image only."""


async def generate_hero_node(state: dict, config: RunnableConfig) -> dict:
layout: MagazineLayout | None = state.get("layout")
if layout is None:
# compose_layout 이 실패했으면 graph 가 publish 로 안 가니 여기 안 옴.
return {}

cfg = (config or {}).get("configurable", {}) or {}
nano: NanoBananaClient | None = cfg.get("nano_banana_client")
r2: R2Client | None = cfg.get("r2_client")

if nano is None or r2 is None or not r2.is_configured():
logger.info(
"generate_hero: nano-banana / R2 not configured — skipping (fallback to source image)"
)
return {}

article_id = state["article_id"]

try:
prompt = _build_hero_prompt(layout)
image_bytes = await nano.generate(prompt=prompt, aspect_ratio="16:9")
except NanoBananaError as exc:
logger.warning("generate_hero: nano-banana failed (%s) — fallback", exc)
return {"error_log": [f"generate_hero: nano-banana: {exc}"]}
except Exception as exc:
logger.exception("generate_hero: unexpected error")
return {"error_log": [f"generate_hero: {type(exc).__name__}: {exc}"]}

key = f"editorial-magazines/{article_id}/hero.png"
try:
result = await asyncio.to_thread(r2.put, key, image_bytes, "image/png")
except Exception as exc:
logger.warning("generate_hero: R2 upload failed (%s) — fallback", exc)
return {"error_log": [f"generate_hero: R2 upload: {exc}"]}

if not result.url:
logger.warning("generate_hero: R2 returned no public URL — fallback")
return {}

updated_layout = layout.model_copy(update={"hero_image_url": result.url})

# hero 섹션이 있으면 같이 갱신
new_sections = []
for sec in updated_layout.sections:
if sec.type == "hero":
new_sections.append(sec.model_copy(update={"image_url": result.url}))
else:
new_sections.append(sec)
updated_layout = updated_layout.model_copy(update={"sections": new_sections})

logger.info("generate_hero: ok article=%s url=%s", article_id, result.url)
return {"layout": updated_layout}
34 changes: 34 additions & 0 deletions packages/ai-server/src/managers/llm/adapters/nano_banana.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ async def reframe(
"nano-banana returned no image (likely safety/policy block)"
)

async def generate(
self,
*,
prompt: str,
aspect_ratio: str = "16:9",
image_size: str = "2K",
) -> bytes:
"""Text-to-image generation (no input image). Used for editorial hero
covers (#429). Same model, same error semantics as `reframe`.
"""
try:
resp = await self._client.aio.models.generate_content(
model=self._model,
contents=[prompt],
config=genai_types.GenerateContentConfig(
response_modalities=["IMAGE"],
image_config=genai_types.ImageConfig(
aspect_ratio=aspect_ratio, image_size=image_size
),
),
)
except Exception as exc:
raise NanoBananaError(f"nano-banana request failed: {exc}") from exc

for candidate in resp.candidates or []:
content = candidate.content
for part in (content.parts if content else []) or []:
inline = getattr(part, "inline_data", None)
if inline and inline.data:
return inline.data
raise NanoBananaError(
"nano-banana returned no image (likely safety/policy block)"
)

@property
def model(self) -> str:
return self._model
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-server/src/managers/queue/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ async def create_worker(
)
# #397 — pipeline 실패 알림 fire-and-forget (env 비면 no-op).
ctx["telegram_notifier"] = infrastructure_container.telegram_notifier()
# #429 — editorial_article 의 generate_hero 노드용 (없으면 graceful fallback)
ctx["nano_banana_client"] = infrastructure_container.nano_banana_client()
ctx["r2_client"] = infrastructure_container.r2_client()

# Create worker with settings
worker = Worker(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ async def editorial_article_job(
recommendation_id: str,
) -> Dict[str, Any]:
operation_db: DatabaseManager | None = ctx.get("operation_database_manager")
# optional — 없으면 generate_hero 가 graceful fallback (source post 이미지 사용)
nano_banana = ctx.get("nano_banana_client")
r2 = ctx.get("r2_client")

if operation_db is None:
logger.error("editorial_article_job: operation_database_manager missing")
Expand All @@ -46,6 +49,8 @@ async def editorial_article_job(
config = {
"configurable": {
"operation_database_manager": operation_db,
"nano_banana_client": nano_banana,
"r2_client": r2,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,75 @@ interface MagazineRendererProps {
layout: MagazineLayout;
}

/**
* Collage hero — curation_card 섹션의 image_url 들을 그리드로 묶어 단일 hero
* 자리에 배치. 단일 AI hero (nano-banana 생성) 보다:
* - source 이미지 fidelity 보존 → 매거진 내부와 시각적 일관성
* - 추가 LLM 비용 / 시간 0
* - editorial 톤이 자연스럽게 multi-subject 구성
*
* Fallback: curation_card 이미지가 < 1 개면 단일 hero_image_url 사용.
*/
function pickCollageImages(layout: MagazineLayout): string[] {
const fromCards = layout.sections
.filter((s) => s.type === "curation_card" && Boolean(s.image_url))
.map((s) => s.image_url as string);
if (fromCards.length > 0) return fromCards.slice(0, 4);
return layout.hero_image_url ? [layout.hero_image_url] : [];
}

function CollageHero({
images,
title,
}: {
images: string[];
title: string;
}) {
if (images.length === 0) return null;

// 이미지 개수별 grid 패턴 — 1: 단일, 2: 2-up, 3: 1+2, 4: 2x2
const layout =
images.length === 1
? "grid-cols-1"
: images.length === 2
? "grid-cols-2"
: images.length === 3
? "grid-cols-3"
: "grid-cols-2 grid-rows-2";

return (
<div
className={`relative w-full aspect-[16/9] overflow-hidden rounded-lg bg-muted grid gap-1 ${layout}`}
>
{images.map((src, i) => (
<div
key={`${src}-${i}`}
className={
images.length === 3 && i === 0
? "relative row-span-1 col-span-1"
: "relative"
}
>
<Image
src={src}
alt={`${title} ${i + 1}`}
fill
className="object-cover"
sizes="(max-width: 1024px) 50vw, 512px"
unoptimized
/>
</div>
))}
</div>
);
}

export function MagazineRenderer({ layout }: MagazineRendererProps) {
const collage = pickCollageImages(layout);
return (
<article className="space-y-12 pb-12">
<header className="space-y-3">
{layout.hero_image_url && (
<div className="relative w-full aspect-[16/9] overflow-hidden rounded-lg bg-muted">
<Image
src={layout.hero_image_url}
alt={layout.title}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 1024px"
unoptimized
/>
</div>
)}
<CollageHero images={collage} title={layout.title} />
<h1 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
{layout.title}
</h1>
Expand All @@ -67,8 +120,9 @@ function SectionView({
index: number;
}) {
switch (section.type) {
// hero 는 header 의 collage 가 담당 — 본문에서 또 렌더하면 중복
case "hero":
return <HeroSection section={section} />;
return null;
case "intro":
return <ProseSection section={section} variant="intro" />;
case "curation_card":
Expand All @@ -82,35 +136,6 @@ function SectionView({
}
}

function HeroSection({ section }: { section: MagazineSection }) {
return (
<section className="space-y-3">
{section.image_url && (
<div className="relative w-full aspect-[16/9] overflow-hidden rounded-lg bg-muted">
<Image
src={section.image_url}
alt={section.title ?? "Hero"}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 1024px"
unoptimized
/>
</div>
)}
{section.title && (
<h2 className="text-2xl font-semibold text-foreground">
{section.title}
</h2>
)}
{section.body && (
<p className="text-base text-muted-foreground max-w-prose whitespace-pre-line">
{section.body}
</p>
)}
</section>
);
}

function ProseSection({
section,
variant,
Expand Down
Loading