From ceb8e8dd6d47d21dcc35a27c74974fbf8c85de49 Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Mon, 4 May 2026 23:09:24 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(web):=20editorial=20article=20events=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20shape=20=E2=80=94=20Vec=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20(Rust=20API=20=EC=A0=95=EB=A0=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust list_events handler 가 Vec 를 직접 JSON 으로 반환하지만 hook 타입이 {events: ArticleEvent[]} 객체 형태로 잘못 정의돼 있어서 eventsQuery.data.events 가 undefined → /admin/editorial/magazine/drafts/[id] 에서 .length 호출로 TypeError, 500. - useEditorialArticles.ts: ArticleEventsResponse 를 ArticleEvent[] 별칭으로 변경 - drafts/[id]/page.tsx: eventsQuery.data.events → eventsQuery.data 이전 fix(web): editorial 매거진 응답 shape — data → items 와 같은 부류 패턴. Typecheck pass. #429 --- .../web/app/admin/editorial/magazine/drafts/[id]/page.tsx | 4 ++-- packages/web/lib/hooks/admin/useEditorialArticles.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx index 3d09951d..631a40e2 100644 --- a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx +++ b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx @@ -180,9 +180,9 @@ function ArticleDetailContent({ id }: { id: string }) {
Failed to load events
- ) : eventsQuery.data && eventsQuery.data.events.length > 0 ? ( + ) : eventsQuery.data && eventsQuery.data.length > 0 ? (
    - {eventsQuery.data.events.map((ev) => ( + {eventsQuery.data.map((ev) => (
  1. ` directly (no wrapper). We type the hook + * as `ArticleEvent[]` to match the wire shape. + */ +export type ArticleEventsResponse = ArticleEvent[]; export type ArticleStatusUpdate = | "published" From aed5449e74674acf97e7aa5b09e0b580b48a2066 Mon Sep 17 00:00:00 2001 From: Raf Date: Wed, 6 May 2026 11:32:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(editorial):=20Stage=203=20chat=20+=20a?= =?UTF-8?q?ssets=20=EA=B2=A9=EB=A6=AC=20+=20publish=20snapshot=20+=20thumb?= =?UTF-8?q?nail=20+=20commercial=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editorial): chat sessions / messages 테이블 (Stage 3) editorial_article 의 대화형 편집을 위한 2 테이블. - editorial_article_chat_sessions: 한 article 에 여러 세션 가능 (article_id FK CASCADE + created_by + last_message_at). - editorial_article_chat_messages: role (user/assistant/tool/system) + content + tool_calls (JSONB) + tool_call_id/name + tool_result (JSONB). agent 의 tool 실행 audit 보존. RLS: admin-only ALL (채팅 내용은 운영 audit). #429 * feat(editorial): Stage 3 — 매거진 article 대화형 편집 (chat agent) 컨텐츠 매니저가 채팅으로 매거진 layout 을 다듬는 agent. LangGraph 대신 google-genai function calling + 단순 loop. Vercel AI SDK / Claude 도입 안 함 — 기존 Gemini stack 일관성. ai-server (editorial_article_chat 모듈): - tools.py: 7 tools (update_title/subtitle, update_section_body/title, remove_section, regenerate_section_body, get_current_layout). 각 tool 은 in-memory layout 을 mutate, 마지막에 한 번 DB persist. regenerate_section_body 는 별도 Gemini 호출로 카피 생성. - agent.py: run_turn — 기존 messages 로드 → contents 구성 → Gemini Pro 호출 → function_call 있으면 ToolExecutor → 결과 model 에 다시 → 반복 (max 6 step). 모든 messages DB 영속화. layout 변경 있으면 트랜잭션. - repository.py: chat_sessions / chat_messages CRUD + article load. - api.py: FastAPI router (/api/v1/editorial-article-chat/*) — sessions list/create, messages list/send. #416 gRPC-only 컨벤션 예외 (chat 은 multi-turn / admin UI 호출 패턴이라 HTTP 자연스러움). - bootstrap.py: chat router include. web: - Next.js proxy routes /api/admin/editorial-article-chat/{sessions,messages} → ai-server (admin auth + bearer 패턴). AI_SERVER_HTTP_URL env (server-env.ts). - useEditorialChat hook: useChatSessions / useCreateChatSession / useChatMessages / useSendChatMessage. Mutation 성공 시 article query invalidate → MagazineRenderer 자동 리렌더. - ChatPanel.tsx: drafts/[id] 페이지 우측 sidebar 에 통합. 가장 최근 세션 자동 로드/생성. user/assistant/tool 메시지 differentiated. Enter 전송, Shift+Enter 줄바꿈. 검증: - curl 로 'get_current_layout' tool 호출 → agent 가 4섹션 인식 정확 - 'remove_section' → DB 4→3 섹션, layout_changed=true 확인 #429 * refactor(editorial): cycle_seconds → cycle_minutes (UX 직관성) 운영자가 admin UI 에서 '21600초' 같은 값 보면 직관 X. 분 단위가 자연스러움. editorial_pipeline_settings + editorial_discovery_settings 두 settings 모두 일관성 있게 변경. DB migration (20260505010000): - 두 테이블의 cycle_seconds 컬럼 → cycle_minutes (CEIL(/60) 변환) - pipeline: DEFAULT 1, CHECK >=1 (이전 60초 = 1분) - discovery: DEFAULT 360, CHECK >=30 (이전 21600초 = 6h, 최소 30분) - Idempotent: 컬럼이 이미 cycle_minutes 면 skip. ai-server: - repository dataclass field rename - scheduler _interval_elapsed: cycle_minutes * 60 으로 내부 계산 - default fallback values 도 분 단위 (1, 360) api-server (Rust): - editorial_pipeline_settings.rs / editorial_discovery_settings.rs 의 JSON field + SQL column + validation 모두 cycle_minutes 로 rename - min validation: pipeline >=1, discovery >=30 web: - useEditorialPipelineSettings / useDiscoverySettings 의 type rename - AutoPipelineSettingsPanel / DiscoverySettingsPanel: state var (cycleMinutes), label 'Cycle (s)' → 'Cycle (min)', min input value 분 단위로 조정 검증: GET /admin/editorial-{pipeline,discovery}/settings → cycle_minutes 1 / 360 정상 응답. raw_posts 의 cycle_seconds 는 별도 feature 라 이번 PR scope 아님. #429 * fix(editorial): discovery cycle_minutes 최소 30 → 1 (pipeline 과 통일) 이전 cycle_seconds >= 1800 (30분) 제약을 그대로 옮기다 보니 cycle_minutes >= 30 이 됐는데, pipeline 의 minimum 은 1분 — 비대칭. 30분 floor 는 임의 보호값 이고 운영자가 직접 정해야 할 값이지 시스템이 강제할 이유 없음. - migration: editorial_discovery_settings cycle_minutes CHECK >= 1 - Rust: validation if c < 1 (메시지는 이미 'must be >= 1' 였는데 sed 누락) - Web: DiscoverySettingsPanel min={1}, setCycleMinutes Math.max(1, ...) 검증: PATCH cycle_minutes=1 → 204. #429 * feat(editorial): hard delete buttons — recommendations + articles 테스트 garbage / 잘못 만든 row 정리 위해 hard DELETE 추가. 기존 soft archive (status='archived') 와는 별개 — UI 에서 X 아이콘 옆 휴지통 아이콘. api-server (Rust): - DELETE /api/v1/admin/editorial-recommendations/{id} — 직접 row 삭제 (article 의 recommendation_id 는 FK SET NULL, article 자체는 보존) - DELETE /api/v1/admin/editorial-articles/{id} — 직접 row 삭제 (events / chat_sessions 는 FK CASCADE 같이 삭제, recommendation 의 article_id 는 SET NULL) web: - Next.js proxy DELETE handlers (admin auth + bearer) - useDeleteRecommendation / useDeleteArticle (TanStack mutation, list invalidate) - RecommendationTable / ArticleTable: 휴지통 아이콘 + 확인 dialog (browser confirm — 단순) 검증: DELETE non-existent id → 404 'not found' 정상 (routing OK). #429 * feat(editorial): approve 시점 즉시 article INSERT (Drafts 즉시 가시성) 이전: Approve 클릭 → Rust PATCH (recommendation status=approved 만) → ArticlePickupScheduler 가 1분 polling 으로 article INSERT + ARQ enqueue → 사용자가 1분 내내 silence 봄. 현재: Rust PATCH 가 같은 트랜잭션 안에서: 1. recommendation status='approved' + approved_by/_at 2. editorial_articles INSERT (status='generating', source_post_ids 복사, title 임시 = angle_title) 3. recommendation.article_id 연결 4. event INSERT ('created' / 'admin approved') → 클릭 즉시 Drafts 탭에 'generating' 표시. ArticlePickupScheduler 단순화 — enqueue 만 담당: editorial_articles.status='generating' AND no event note='enqueued' → ARQ enqueue → event INSERT note='enqueued'. (이전 INSERT 로직 제거.) 검증: PATCH approve → 204 → DB 즉시 article 'generating' 확인 → 22초 후 scheduler 가 enqueued event 추가. #429 * feat(editorial): assets 격리 + operation publish 스냅샷 (DB schema) 매거진 staging (검증 안 된 자산) 을 operation 에서 assets 로 이동. publish 시점에만 operation 의 신규 editorial_articles 테이블로 snapshot 복사. raw_posts 패턴 정합성. #429 supabase-assets/migrations/20260505020000_editorial_staging.sql: - editorial_recommendations / editorial_articles (status draft 등 + thumbnail_url) / editorial_article_events / editorial_discovery_settings (cycle_minutes default 360, min 1) / editorial_article_chat_sessions / chat_messages - assets RLS pattern (enable + no policies, service_role bypass — raw_posts 동일) - 모든 FK 는 assets 안에서만 (cross-DB FK X). source_post_ids 는 logical uuid[]. supabase/migrations/20260505020001_editorial_articles_published.sql: - operation editorial_articles (publish 전용 snapshot). status 컬럼 없음. - id = assets article id 그대로. source_article_id (audit, FK 없음). - title / subtitle / hero_image_url / thumbnail_url / layout_json / source_post_ids / slug (UNIQUE, NULL 가능) / published_at / published_by. - RLS: 공개 SELECT (운영 매거진 reader), admin-only mutation (is_admin). supabase/migrations/20260505020002_drop_editorial_staging.sql: - operation 의 staging 5 테이블 DROP CASCADE. - 기존 staging editorial_articles 잔재 처리 — 'status' 컬럼 있고 'source_article_id' 없으면 staging 잔재로 판단해 DROP + 신 publish 스키마 재생성. - 마이그레이션 순서: 20260505020001 (publish 생성) → 20260505020002 (staging drop). 로컬: supabase-assets/ 의 마이그레이션은 dev 에서 별도 'assets' DB ( postgres:5432/assets) 에 적용. .env.backend.dev 의 ASSETS_DATABASE_URL 을 localhost 로 override (gitignored). * feat(editorial): assets 격리 + operation publish + nano-banana hero/thumb #429 architectural refactor + 산출물 품질 개선. ═══ assets 격리 ═══ 매거진 staging 데이터 (recommendations / draft articles / events / chat / discovery_settings) 를 operation 에서 assets DB 로 이동. operation 위생 보호 — 검증 안 된 자산이 prod 데이터와 섞이지 않게. ai-server: - _container.py: EditorialDiscoveryContainer 가 assets+operation 두 풀 받음. ArticlePickupScheduler 는 assets 만. nano_banana_client 를 RawPostsContainer → InfrastructureContainer 로 승격 (raw_posts + editorial 공용). - editorial_discovery/repository.py: settings/recommendations 는 assets, posts/ spots/solutions 는 operation. used_posts CTE 분리 — 두 풀 분리 호출. - editorial_article/nodes/fetch_sources.py: recommendations 는 assets, posts 는 operation 분리 호출. - editorial_article/nodes/publish.py: assets 의 editorial_articles UPDATE + recommendation status='drafted' + event INSERT. - editorial_article_service.py: ctx 에서 assets+operation 두 풀 + nano_banana + r2 받아 graph config 주입. - bootstrap.py: chat router 에 assets pool 주입. - worker.py: ctx 에 nano_banana_client + r2_client 추가 (#429 공용). api-server: - 모든 admin editorial handler (recommendations / articles / discovery_settings) 의 state.db → state.assets_db. - approve_in_tx: assets 트랜잭션으로 동작 (이미 변경됨). ═══ Publish 복사 (assets → operation snapshot) ═══ api-server: - editorial_articles.rs PATCH 의 'published' 분기 → publish_to_operation(): 1. assets 에서 article 로드 2. operation 의 신규 editorial_articles 테이블에 INSERT (id 동일, source_article_id = id, layout_json + hero/thumb URL + source_post_ids + slug NULL 가능 + published_at + published_by). ON CONFLICT (id) DO UPDATE. 3. assets 의 status='published' UPDATE + event INSERT. 두 DB 라 분산 트랜잭션 X — best-effort 순서. - editorial_articles_published/{handlers,mod}.rs: 신규 공개 read endpoints. GET /api/v1/editorial-articles?page&per_page (목록), /{id_or_slug} (상세). state.db (operation) 사용. RLS public_can_select_editorial_articles 에 의존. - domains/mod.rs + router.rs: 신규 도메인 등록. ═══ Nano-banana hero (16:9) + thumbnail (4:5) ═══ ai-server: - editorial_article/nodes/generate_hero_thumbnail.py (신규): source 이미지 1장 다운로드 → nano-banana reframe 16:9 + 4:5 병렬 호출 → R2 업로드 (editorial-magazines/{article_id}/{hero,thumbnail}.png) → layout.{hero_image_url, thumbnail_url} 갱신. graceful — 실패해도 publish 진행. - editorial_article/graph.py: compose_layout → generate_hero_thumbnail → publish. - editorial_article/models.py: MagazineLayout.thumbnail_url 추가. - nodes/publish.py SQL: thumbnail_url 컬럼도 함께 UPDATE. web: - useEditorialArticles.ts: ArticleListItem.thumbnail_url + MagazineLayout.thumbnail_url 타입. - ArticleTable.tsx: 4:5 썸네일 컬럼 추가 (thumbnail_url 우선, fallback hero_image_url, placeholder). ═══ 검증 (로컬 풀 사이클) ═══ 1. assets 마이그레이션 적용 (별도 supabase-assets/migrations/20260505020000) 2. operation publish snapshot + staging drop 마이그레이션 3. .env.backend.dev / packages/api-server/.env.dev 에서 ASSETS_DATABASE_URL 을 localhost:54322/assets 로 override (gitignored, 로컬 dev 만) 4. Sample recommendation INSERT (assets) → Approve via Rust PATCH → 즉시 assets editorial_articles INSERT (status='generating') 5. ArticlePickupScheduler 1분 내 ARQ enqueue → 그래프 실행 (fetch_sources from assets+operation → compose → generate_hero_thumbnail → publish to assets) — 약 2분 후 status='draft', hero+thumbnail R2 업로드 확인 6. PATCH publish → operation editorial_articles INSERT, assets status='published' 7. GET /api/v1/editorial-articles → 200, 운영 매거진 반환 #429 * fix(editorial): multi-line state.db → state.assets_db (#429) 이전 perl 단일 라인 sed 가 'state\n.db' (멀티라인) 패턴 놓침 — query_one / query_all / execute 의 chained call 4-5곳 미변환. operation pool 가리키니 'relation editorial_recommendations does not exist' 500. 추가: ArticleListItem / ArticleDetailResponse 에 thumbnail_url 필드 + list/detail SQL 에 컬럼 포함 — web 의 ArticleTable 썸네일 표시용. 검증: GET /api/v1/admin/editorial-recommendations 200, GET .../editorial-articles 200 (thumbnail_url 응답 확인). #429 * feat(editorial): hero 제거, thumbnail 만 + 강화 prompt (#429) 매거진 viewer 에서 hero image 안 보여줌 (사용자 결정 — 운영자가 매거진 본문에 hero banner 불필요라 판단). thumbnail 만 list / OG 카드용으로 생성. 이전 generate_hero_thumbnail (16:9 + 4:5 두 reframe 호출) → generate_thumbnail 로 단순화. 4:5 한 번만 호출. prompt 강화 — 단순 reframe / re-crop 결과 ('input 그대로') 가 아니라 적극적 editorial transformation 을 명시: - 'INSPIRATION, not literal source' — input 을 영감으로 사용, 재해석 - composition / lighting / color grading 디렉티브 명시 - 'should look DIFFERENT from the source — magazine art director's interpretation' - 'add visual sophistication: grain, color shifts, contrast, lighting drama' - 'avoid generic stock-photo / social-media filter look' frontend (MagazineRenderer): header 의 hero block 제거. title + subtitle 만. DB schema 의 hero_image_url 컬럼은 유지 (compose_layout 이 source URL 로 채울 수 있고, OG meta 등 다른 용도 가능). 안 쓰는 필드 schema migration 제거는 향후 별도 PR. #429 * feat(editorial): gpt-image-2 thumbnail + 한글 title + DECODED MAG 워터마크 매거진 thumbnail 생성을 nano-banana → gpt-image-2 로 전환. nano-banana 의 한글 text rendering 약해서 typo 잦음 — gpt-image-2 가 한글 + DECODED MAG 워터마크 모두 정확하게 렌더링. ai-server (신규): - managers/llm/adapters/openai_image.py: OpenAIImageClient (edit / generate), gpt-image-2 default, timeout 300s. crop_to_4_5 helper 도 같이 (현재는 미사용 — 2:3 portrait 직접 사용). - _container.py: openai_image_client Singleton (InfrastructureContainer). - worker.py: ctx 에 openai_image_client 추가. - editorial_article_service.py: graph config 에 openai_image_client 주입. generate_thumbnail.py 변경: - nano-banana → openai_image_client 사용 - 1024x1536 (2:3 portrait) 직접 — 4:5 center crop 제거 (crop 이 watermark + title 양쪽 잘라냈음) - prompt: - 한글 title 을 bottom-left 에 magazine 스타일로 overlay - 'DECODED MAG' wordmark top-right 에 lime green (#eafd67 — brand --mag-accent) - editorial color grading - 053 magazine / W Korea / Vogue Korea Instagram covers reference prompts.py (compose_layout): hero 섹션 만들지 말 것 + hero_image_url null 유지 명시 (이미 적용됐으나 schema/order 정리). MagazineRenderer.tsx: header 의 hero block 제거 + SectionView 의 case 'hero' 도 null (기존 article 의 hero section 안 보여줌). 비용: gpt-image-2 ~$0.04/image (vs nano-banana ~$0.01-0.04). 한글 quality trade-off. #429 * feat(editorial): commercial — 상품 링크 (Shop) + try-on 버튼 매거진의 각 solution 카드에 두 가지 commercial CTA 추가: 1. 상품 링크 (Shop) - solutions.original_url (현재 65% 채워짐) / affiliate_url (향후) 을 layout 에 포함 - ai-server: SourceSolution + SectionSolution pydantic 에 original_url / affiliate_url 필드 추가 - fetch_sources_node: SQL 에 original_url, affiliate_url SELECT 추가 - prompts.py: solutions 페이로드 에 두 url 포함 + LLM 에게 그대로 복사하도록 명시 (변형 X). 출력 schema 에도 추가. - web: MagazineSolution 타입 + SolutionCard 에 검은 Shop 버튼 (ShoppingBag icon) — 새 탭으로 상품 페이지 열기. affiliate_url 우선, 없으면 original_url. 2. Try-on 버튼 - 기존 vton 인프라 (VtonModal / vtonStore / useVtonTryOn) 재사용 - vtonStore.openWithItems(post_id, items) 호출 — VtonModal 이 글로벌 mount 돼있어 즉시 표시. API 가 thumbnail_url 만 사용해서 solution.image_url 그대로 전달. - SolutionCard 에 Shirt icon 버튼 추가 (image_url 있을 때만 노출) 검증 (article a39116b8-...): - layout_json sections[].solutions[].original_url 정상 채움 (Celine, Carousell 등) - 풀 사이클 (approve → graph → publish snapshot) 정상 #429 * fix(web): SolutionCard try-on 버튼 좁은 카드에서도 항상 노출 이전: flex + flex-1 Shop → try-on 이 카드 너비 초과 시 잘림. 변경: grid grid-cols-[1fr_auto] — Shop 가변 (남은 공간 + truncate), try-on icon shrink-0 으로 항상 자기 너비. 카드 너비 무관 둘 다 보임. #429 * feat(magazine): try-on 버튼 단일 post 매거진에도 적용 기존 1 post → 1 magazine (post_magazines) 의 MagazineItemsSection 에도 multi-source 매거진과 동일하게 Try-on 버튼 추가: - Compact view: 'Shop' 텍스트 링크 옆에 'Try-on' inline 버튼 (셔츠 icon) - Full view: 'Shop Now' rounded 버튼 옆에 같은 스타일 'Try-on' rounded 버튼 vtonStore.openWithItems(spot_id, [{id, title, thumbnail_url, ...}]) 호출 패턴 재사용. image_url 있을 때만 노출. accentColor 적용 일관. #429 --- packages/ai-server/src/bootstrap.py | 6 + packages/ai-server/src/config/_container.py | 37 +- .../ai-server/src/editorial_article/graph.py | 6 +- .../ai-server/src/editorial_article/models.py | 7 +- .../editorial_article/nodes/fetch_sources.py | 108 ++--- .../nodes/generate_thumbnail.py | 152 +++++++ .../src/editorial_article/nodes/publish.py | 17 +- .../src/editorial_article/prompts.py | 25 +- .../src/editorial_article_chat/__init__.py | 1 + .../src/editorial_article_chat/agent.py | 271 ++++++++++++ .../src/editorial_article_chat/api.py | 165 ++++++++ .../src/editorial_article_chat/repository.py | 272 ++++++++++++ .../src/editorial_article_chat/tools.py | 387 ++++++++++++++++++ .../src/managers/llm/adapters/openai_image.py | 132 ++++++ .../ai-server/src/managers/queue/worker.py | 6 + .../editorial_article_service.py | 26 +- .../services/editorial_article/scheduler.py | 113 +++-- .../editorial_discovery/repository.py | 44 +- .../services/editorial_discovery/scheduler.py | 6 +- .../src/services/post_editorial/repository.py | 8 +- .../src/services/post_editorial/scheduler.py | 6 +- .../src/domains/admin/editorial_articles.rs | 187 ++++++++- .../admin/editorial_discovery_settings.rs | 26 +- .../admin/editorial_pipeline_settings.rs | 24 +- .../admin/editorial_recommendations.rs | 129 +++++- .../editorial_articles_published/handlers.rs | 203 +++++++++ .../editorial_articles_published/mod.rs | 8 + packages/api-server/src/domains/mod.rs | 1 + packages/api-server/src/router.rs | 4 + .../editorial/magazine/drafts/[id]/page.tsx | 3 + .../admin/editorial/magazine/drafts/page.tsx | 19 +- .../web/app/admin/editorial/magazine/page.tsx | 14 + .../messages/[sessionId]/route.ts | 90 ++++ .../sessions/[articleId]/route.ts | 95 +++++ .../admin/editorial-articles/[id]/route.ts | 37 ++ .../editorial-recommendations/[id]/route.ts | 37 ++ .../editorial/AutoPipelineSettingsPanel.tsx | 16 +- .../admin/editorial/magazine/ArticleTable.tsx | 57 ++- .../admin/editorial/magazine/ChatPanel.tsx | 211 ++++++++++ .../magazine/DiscoverySettingsPanel.tsx | 18 +- .../editorial/magazine/MagazineRenderer.tsx | 66 ++- .../magazine/RecommendationTable.tsx | 16 +- .../detail/magazine/MagazineItemsSection.tsx | 85 +++- .../lib/hooks/admin/useDiscoverySettings.ts | 4 +- .../lib/hooks/admin/useEditorialArticles.ts | 23 ++ .../web/lib/hooks/admin/useEditorialChat.ts | 143 +++++++ .../admin/useEditorialPipelineSettings.ts | 4 +- .../admin/useEditorialRecommendations.ts | 19 + packages/web/lib/server-env.ts | 7 + .../20260505020000_editorial_staging.sql | 163 ++++++++ .../20260505000000_editorial_article_chat.sql | 83 ++++ ...20260505010000_editorial_cycle_minutes.sql | 57 +++ ...505020001_editorial_articles_published.sql | 48 +++ .../20260505020002_drop_editorial_staging.sql | 76 ++++ 54 files changed, 3465 insertions(+), 303 deletions(-) create mode 100644 packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py create mode 100644 packages/ai-server/src/editorial_article_chat/__init__.py create mode 100644 packages/ai-server/src/editorial_article_chat/agent.py create mode 100644 packages/ai-server/src/editorial_article_chat/api.py create mode 100644 packages/ai-server/src/editorial_article_chat/repository.py create mode 100644 packages/ai-server/src/editorial_article_chat/tools.py create mode 100644 packages/ai-server/src/managers/llm/adapters/openai_image.py create mode 100644 packages/api-server/src/domains/editorial_articles_published/handlers.rs create mode 100644 packages/api-server/src/domains/editorial_articles_published/mod.rs create mode 100644 packages/web/app/api/admin/editorial-article-chat/messages/[sessionId]/route.ts create mode 100644 packages/web/app/api/admin/editorial-article-chat/sessions/[articleId]/route.ts create mode 100644 packages/web/lib/components/admin/editorial/magazine/ChatPanel.tsx create mode 100644 packages/web/lib/hooks/admin/useEditorialChat.ts create mode 100644 supabase-assets/migrations/20260505020000_editorial_staging.sql create mode 100644 supabase/migrations/20260505000000_editorial_article_chat.sql create mode 100644 supabase/migrations/20260505010000_editorial_cycle_minutes.sql create mode 100644 supabase/migrations/20260505020001_editorial_articles_published.sql create mode 100644 supabase/migrations/20260505020002_drop_editorial_staging.sql diff --git a/packages/ai-server/src/bootstrap.py b/packages/ai-server/src/bootstrap.py index 5d60bd13..04dc791e 100644 --- a/packages/ai-server/src/bootstrap.py +++ b/packages/ai-server/src/bootstrap.py @@ -96,6 +96,12 @@ async def health_check(): } # #416 — 모든 비즈니스 routes 가 gRPC 로 옮겨짐. router include 없음. + # 예외: editorial article chat (#429 Stage 3) — multi-turn / admin UI 호출 패턴. + # #429: chat sessions / messages / article load 모두 assets staging. + from src.editorial_article_chat.api import create_router as create_chat_router + + assets_db = application.infrastructure().assets_database_manager() + app.include_router(create_chat_router(assets_db)) return app diff --git a/packages/ai-server/src/config/_container.py b/packages/ai-server/src/config/_container.py index 07bd856e..610fa83e 100644 --- a/packages/ai-server/src/config/_container.py +++ b/packages/ai-server/src/config/_container.py @@ -21,6 +21,7 @@ from src.services.metadata.management.failed_items_manager import FailedItemsManager from src.services.raw_posts.adapters import build_default_adapters from src.managers.llm.adapters.nano_banana import NanoBananaClient +from src.managers.llm.adapters.openai_image import OpenAIImageClient from src.services.raw_posts.discovery.composite_filter import CompositeFilter from src.services.raw_posts.pipeline import RawPostsPipeline from src.services.raw_posts.processors import ( @@ -75,6 +76,20 @@ 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), + ) + + # OpenAI gpt-image-1 — magazine thumbnail 전용 (#429, 한글 text rendering 우수) + openai_image_client: Singleton[OpenAIImageClient] = Singleton( + OpenAIImageClient, + api_key=Callable(lambda env: env.OPENAI_API_KEY, environment), + ) + # #214 raw_posts_callback_client removed — ai-server writes DB directly. # ─── Postgres asyncpg pool — assets (raw_posts) vs operation (운영 DB) 분리 (#333, #369) ─── @@ -207,17 +222,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( @@ -235,7 +243,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, ) @@ -344,9 +352,13 @@ class EditorialDiscoveryContainer(DeclarativeContainer): DependenciesContainer() ) + # #429 architectural fix: editorial staging 은 assets DB. + # posts/spots/solutions read 는 별도로 operation pool 사용 + # (gather_discovery_input 안에서 두 풀 모두 acquire). repository: Singleton[EditorialDiscoveryRepository] = Singleton( EditorialDiscoveryRepository, - database_manager=infrastructure.operation_database_manager, + assets_db=infrastructure.assets_database_manager, + operation_db=infrastructure.operation_database_manager, ) scheduler: Singleton[DiscoveryScheduler] = Singleton( @@ -365,9 +377,10 @@ class EditorialArticlePickupContainer(DeclarativeContainer): DependenciesContainer() ) + # #429: assets pool — editorial_articles staging scheduler: Singleton[ArticlePickupScheduler] = Singleton( ArticlePickupScheduler, - database_manager=infrastructure.operation_database_manager, + database_manager=infrastructure.assets_database_manager, queue_manager=infrastructure.queue_manager, ) diff --git a/packages/ai-server/src/editorial_article/graph.py b/packages/ai-server/src/editorial_article/graph.py index 2590234e..2012e64c 100644 --- a/packages/ai-server/src/editorial_article/graph.py +++ b/packages/ai-server/src/editorial_article/graph.py @@ -10,6 +10,7 @@ from .nodes.compose_layout import compose_layout_node from .nodes.fetch_sources import fetch_sources_node +from .nodes.generate_thumbnail import generate_thumbnail_node from .nodes.publish import publish_node from .state import EditorialArticleState @@ -23,6 +24,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_thumbnail", generate_thumbnail_node) builder.add_node("publish", publish_node) builder.add_edge(START, "fetch_sources") @@ -34,8 +36,10 @@ def create_editorial_article_graph(): builder.add_conditional_edges( "compose_layout", _route_after, - {"ok": "publish", "failed": END}, + # generate_thumbnail 는 graceful — 실패해도 publish 로 진행 + {"ok": "generate_thumbnail", "failed": END}, ) + builder.add_edge("generate_thumbnail", "publish") builder.add_edge("publish", END) return builder.compile() diff --git a/packages/ai-server/src/editorial_article/models.py b/packages/ai-server/src/editorial_article/models.py index 2a8d6f50..3f940294 100644 --- a/packages/ai-server/src/editorial_article/models.py +++ b/packages/ai-server/src/editorial_article/models.py @@ -15,6 +15,8 @@ class SectionSolution(BaseModel): brand: Optional[str] = None price: Optional[str] = None image_url: Optional[str] = None + original_url: Optional[str] = None # 상품 페이지 (#429 commercial) + affiliate_url: Optional[str] = None # 제휴 링크 (현재 미수집, 향후) class MagazineSection(BaseModel): @@ -42,7 +44,8 @@ class MagazineLayout(BaseModel): schema_version: str = "1.0" title: str subtitle: Optional[str] = None - hero_image_url: Optional[str] = None + hero_image_url: Optional[str] = None # 16:9 — nano-banana 결과 (#429) + thumbnail_url: Optional[str] = None # 4:5 — nano-banana 결과 (#429) sections: list[MagazineSection] @@ -58,6 +61,8 @@ class SourceSolution(BaseModel): price: Optional[str] = None image_url: Optional[str] = None sub_category: Optional[str] = None + original_url: Optional[str] = None # 상품 페이지 (#429 commercial) + affiliate_url: Optional[str] = None class SourcePost(BaseModel): diff --git a/packages/ai-server/src/editorial_article/nodes/fetch_sources.py b/packages/ai-server/src/editorial_article/nodes/fetch_sources.py index e1a60532..140a363e 100644 --- a/packages/ai-server/src/editorial_article/nodes/fetch_sources.py +++ b/packages/ai-server/src/editorial_article/nodes/fetch_sources.py @@ -16,9 +16,12 @@ async def _load_context( - db: DatabaseManager, recommendation_id: str + assets_db: DatabaseManager, + operation_db: DatabaseManager, + recommendation_id: str, ) -> RecommendationContext: - async with db.acquire() as conn: + # #429: recommendations 는 assets, posts/spots/solutions 는 operation. + async with assets_db.acquire() as conn: rec = await conn.fetchrow( """ SELECT id, angle_title, angle_description, rationale, @@ -28,14 +31,15 @@ async def _load_context( """, recommendation_id, ) - if rec is None: - raise ValueError(f"recommendation {recommendation_id} not found") + if rec is None: + raise ValueError(f"recommendation {recommendation_id} not found") - post_ids = list(rec["source_post_ids"] or []) - sol_id_filter = set(str(x) for x in (rec["source_solution_ids"] or [])) + post_ids = list(rec["source_post_ids"] or []) + sol_id_filter = set(str(x) for x in (rec["source_solution_ids"] or [])) - posts: list[SourcePost] = [] - if post_ids: + posts: list[SourcePost] = [] + if post_ids: + async with operation_db.acquire() as conn: post_rows = await conn.fetch( """ SELECT id, artist_name, group_name, context, image_url, style_tags @@ -48,7 +52,8 @@ async def _load_context( sol_rows = await conn.fetch( """ SELECT s.post_id, - sol.id, sol.title, sol.thumbnail_url, sol.metadata + sol.id, sol.title, sol.thumbnail_url, sol.metadata, + sol.original_url, sol.affiliate_url FROM public.spots s JOIN public.solutions sol ON sol.spot_id = s.id WHERE s.post_id = ANY($1::uuid[]) @@ -80,59 +85,64 @@ async def _load_context( price=str(meta.get("price")) if meta.get("price") else None, image_url=sr["thumbnail_url"], sub_category=meta.get("sub_category"), + original_url=sr["original_url"], + affiliate_url=sr["affiliate_url"], ) ) - for pr in post_rows: - pid = str(pr["id"]) - style_tags = pr["style_tags"] or [] - if isinstance(style_tags, str): - try: - style_tags = json.loads(style_tags) - except Exception: - style_tags = [] - posts.append( - SourcePost( - post_id=pid, - artist_name=pr["artist_name"], - group_name=pr["group_name"], - context=pr["context"], - image_url=pr["image_url"], - style_tags=list(style_tags) if isinstance(style_tags, list) else [], - solutions=sols_by_post.get(pid, []), - ) + for pr in post_rows: + pid = str(pr["id"]) + style_tags = pr["style_tags"] or [] + if isinstance(style_tags, str): + try: + style_tags = json.loads(style_tags) + except Exception: + style_tags = [] + posts.append( + SourcePost( + post_id=pid, + artist_name=pr["artist_name"], + group_name=pr["group_name"], + context=pr["context"], + image_url=pr["image_url"], + style_tags=list(style_tags) if isinstance(style_tags, list) else [], + solutions=sols_by_post.get(pid, []), ) + ) - keywords = rec["keywords"] or [] - if isinstance(keywords, str): - try: - keywords = json.loads(keywords) - except Exception: - keywords = [] - - return RecommendationContext( - recommendation_id=str(rec["id"]), - angle_title=rec["angle_title"], - angle_description=rec["angle_description"], - rationale=rec["rationale"], - keywords=list(keywords) if isinstance(keywords, list) else [], - source_posts=posts, - ) + keywords = rec["keywords"] or [] + if isinstance(keywords, str): + try: + keywords = json.loads(keywords) + except Exception: + keywords = [] + + return RecommendationContext( + recommendation_id=str(rec["id"]), + angle_title=rec["angle_title"], + angle_description=rec["angle_description"], + rationale=rec["rationale"], + keywords=list(keywords) if isinstance(keywords, list) else [], + source_posts=posts, + ) async def fetch_sources_node(state: dict, config: RunnableConfig) -> dict: - """recommendation_id 로 posts + 솔루션 모두 로드.""" - db: DatabaseManager | None = (config or {}).get("configurable", {}).get( - "operation_database_manager" - ) - if db is None: + """recommendation (assets) + posts/spots/solutions (operation) 로드.""" + cfg = (config or {}).get("configurable", {}) + assets_db: DatabaseManager | None = cfg.get("assets_database_manager") + operation_db: DatabaseManager | None = cfg.get("operation_database_manager") + if assets_db is None or operation_db is None: return { "pipeline_status": "failed", - "error_log": ["fetch_sources: operation_database_manager missing"], + "error_log": [ + "fetch_sources: db pools missing " + f"(assets={assets_db}, operation={operation_db})" + ], } try: - ctx = await _load_context(db, state["recommendation_id"]) + ctx = await _load_context(assets_db, operation_db, state["recommendation_id"]) except Exception as exc: logger.exception("fetch_sources failed") return { diff --git a/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py new file mode 100644 index 00000000..a019f4ad --- /dev/null +++ b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py @@ -0,0 +1,152 @@ +"""generate_thumbnail node — gpt-image-2 magazine-style 2:3 thumbnail. + +source post 1장 + 한글 title overlay → OpenAI gpt-image-1 edit → R2 업로드. +nano-banana 는 한글 text rendering 약해서 이번 노드에서는 OpenAI 사용 (#429). + +흐름: + 1. layout 의 첫 curation_card image_url 다운로드 + 2. gpt-image-1 edit: source 이미지 + magazine-cover prompt (한글 title 포함) + 3. 결과는 1024x1536 (2:3) — 1024x1280 (4:5) center-crop + 4. R2: editorial-magazines/{article_id}/thumbnail.png + +graceful — 실패 시 layout.thumbnail_url 미설정. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +import httpx +from langchain_core.runnables import RunnableConfig + +from src.managers.llm.adapters.openai_image import ( + OpenAIImageClient, + OpenAIImageError, +) +from src.managers.storage.r2_client import R2Client + +from ..models import MagazineLayout + + +logger = logging.getLogger(__name__) + + +def _build_prompt(title: str, subtitle: Optional[str]) -> str: # noqa: ARG001 + return f"""Transform this photograph into a Korean fashion magazine Instagram-style thumbnail +(2:3 portrait, 1024x1536). + +KEEP the subject (person, pose, outfit) recognizable from the source photo. + +ADD design treatment over the photograph: + +1. KOREAN TITLE TEXT OVERLAY at bottom-left: + "{title}" + + Typography: + - Bold sans-serif Korean (Pretendard Bold / Noto Sans CJK Bold style) + - WHITE color, large readable thumbnail size + - Break into 2-3 lines at natural Korean phrase boundaries (commas, particles) + - Position in lower-left third with proper margin from image edge + +2. "DECODED MAG" wordmark in TOP-RIGHT corner — REQUIRED: + - Color: bright lime green (#eafd67) — the brand magazine accent color + - Bold sans-serif uppercase (modern editorial typography — think Pretendard / + Inter / Helvetica Bold) + - Small but readable size — magazine masthead style + - Position: top-right with proper margin (matches title margin on bottom-left) + - Render text PERFECTLY — exactly "DECODED MAG" (uppercase, with single space) + +3. Editorial color grading: slight desaturation, sophisticated cinematic tone. + +4. If text legibility needs help, add a subtle dark gradient ONLY in the text area (lower-left). + +CRITICAL: +- Render the Korean text PERFECTLY — no typos, no character substitutions +- 2:3 portrait (1024x1536) strictly +- Subject must remain visible behind/around text overlay + +Style reference: 053 Magazine, W Korea, Vogue Korea Instagram covers. + +Output: the photograph with Korean text overlay applied.""" + + +def _pick_source_url(layout: MagazineLayout) -> Optional[str]: + for sec in layout.sections: + if sec.type == "curation_card" and sec.image_url: + return sec.image_url + for sec in layout.sections: + if sec.image_url: + return sec.image_url + return layout.hero_image_url + + +async def _download_image(url: str) -> tuple[bytes, str]: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get(url) + resp.raise_for_status() + ct = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip() + return resp.content, ct or "image/jpeg" + + +async def generate_thumbnail_node(state: dict, config: RunnableConfig) -> dict: + layout: Optional[MagazineLayout] = state.get("layout") + if layout is None: + return {} + + cfg = (config or {}).get("configurable", {}) or {} + openai_client: Optional[OpenAIImageClient] = cfg.get("openai_image_client") + r2: Optional[R2Client] = cfg.get("r2_client") + + if openai_client is None or r2 is None or not r2.is_configured(): + logger.info("generate_thumbnail: OpenAI / R2 not configured — skip") + return {} + + article_id = state["article_id"] + + src_url = _pick_source_url(layout) + if not src_url: + logger.info("generate_thumbnail: no source image — skip") + return {} + + try: + src_bytes, src_mime = await _download_image(src_url) + except Exception as exc: + logger.warning("generate_thumbnail: source download failed (%s)", exc) + return {} + + prompt = _build_prompt(layout.title or "", layout.subtitle) + + try: + # 1024x1536 (2:3 portrait) — center crop 제거 (#429): crop 이 watermark + # (top) 와 title (bottom) 양쪽 잘라냈음. 2:3 portrait 으로 직접 사용. + out_bytes = await openai_client.edit( + image_bytes=src_bytes, + image_mime_type=src_mime, + prompt=prompt, + size="1024x1536", + quality="high", + ) + except OpenAIImageError as exc: + logger.warning("generate_thumbnail: OpenAI failed (%s)", exc) + return {"error_log": [f"generate_thumbnail: openai: {exc}"]} + except Exception as exc: + logger.exception("generate_thumbnail: unexpected error") + return {"error_log": [f"generate_thumbnail: {type(exc).__name__}: {exc}"]} + + key = f"editorial-magazines/{article_id}/thumbnail.png" + try: + result = await asyncio.to_thread(r2.put, key, out_bytes, "image/png") + except Exception as exc: + logger.warning("generate_thumbnail: R2 upload failed (%s)", exc) + return {"error_log": [f"generate_thumbnail: R2: {exc}"]} + + if not result.url: + return {} + + new_layout = layout.model_copy(update={"thumbnail_url": result.url}) + logger.info( + "generate_thumbnail: ok article=%s url=%s", article_id, result.url + ) + return {"layout": new_layout} diff --git a/packages/ai-server/src/editorial_article/nodes/publish.py b/packages/ai-server/src/editorial_article/nodes/publish.py index 835cdb89..ac2ddfed 100644 --- a/packages/ai-server/src/editorial_article/nodes/publish.py +++ b/packages/ai-server/src/editorial_article/nodes/publish.py @@ -25,16 +25,17 @@ async def _persist( layout_json = layout.model_dump(mode="json") async with db.acquire() as conn: async with conn.transaction(): - # 1) editorial_articles: status=draft, layout_json + title/subtitle/hero + # 1) editorial_articles: status=draft, layout_json + title/subtitle/hero/thumb prev_row = await conn.fetchrow( """ UPDATE public.editorial_articles SET title = $2, subtitle = $3, - hero_image_url = $4, - layout_json = $5::jsonb, - status = 'draft', - updated_at = now() + hero_image_url = $4, + thumbnail_url = $5, + layout_json = $6::jsonb, + status = 'draft', + updated_at = now() WHERE id = $1::uuid RETURNING id """, @@ -42,6 +43,7 @@ async def _persist( layout.title or "Untitled", layout.subtitle, layout.hero_image_url, + layout.thumbnail_url, json.dumps(layout_json), ) if prev_row is None: @@ -72,13 +74,14 @@ async def _persist( async def publish_node(state: dict, config: RunnableConfig) -> dict: + # #429: editorial_articles + recommendations + events 모두 assets staging. db: DatabaseManager | None = (config or {}).get("configurable", {}).get( - "operation_database_manager" + "assets_database_manager" ) if db is None: return { "pipeline_status": "failed", - "error_log": ["publish: operation_database_manager missing"], + "error_log": ["publish: assets_database_manager missing"], } layout: MagazineLayout | None = state.get("layout") diff --git a/packages/ai-server/src/editorial_article/prompts.py b/packages/ai-server/src/editorial_article/prompts.py index 7825904d..2b29f232 100644 --- a/packages/ai-server/src/editorial_article/prompts.py +++ b/packages/ai-server/src/editorial_article/prompts.py @@ -26,6 +26,8 @@ def build_compose_layout_prompt(ctx: RecommendationContext) -> str: "price": s.price, "image_url": s.image_url, "sub_category": s.sub_category, + "original_url": s.original_url, + "affiliate_url": s.affiliate_url, } for s in p.solutions ], @@ -50,25 +52,30 @@ def build_compose_layout_prompt(ctx: RecommendationContext) -> str: 요구사항: - title: angle_title 을 다듬은 매거진 제목 (한국어, TAGGED 톤). - subtitle: 한 문장 부제. -- hero_image_url: source posts 중 가장 적합한 image_url 1개. -- sections: 다음 순서로 구성. - 1. hero (title/subtitle/hero_image_url 반복) - 2. intro (200-300자 도입 카피, 이 angle 이 왜 지금 의미있는지) - 3. curation_card N개 — source post 마다 1개. body 에 100-200자 카피, post_id / +- hero_image_url: null. (#429 — 매거진 본문에 hero banner 안 씀, OG 메타 등은 + thumbnail_url 사용. LLM 은 hero_image_url 채우지 마라.) +- sections: 다음 순서로 구성. **hero 섹션 만들지 마라** — title/subtitle 은 layout + 의 최상위 필드에서 이미 표시됨. + 1. intro (200-300자 도입 카피, 이 angle 이 왜 지금 의미있는지) + 2. curation_card N개 — source post 마다 1개. body 에 100-200자 카피, post_id / image_url / solutions (그 post 의 솔루션 카드들) 포함. - 4. (선택) spotlight 1개 — 가장 강조하고 싶은 솔루션 / 브랜드. - 5. closing (100-150자 마무리 카피) + 3. (선택) spotlight 1개 — 가장 강조하고 싶은 솔루션 / 브랜드. + 4. closing (100-150자 마무리 카피) - 모든 post_id / solution_id 는 입력에 등장한 것만 사용. 새 id 만들지 마라. +- solutions 배열에는 input solution 의 **original_url / affiliate_url 그대로 + 복사** (있으면). 사용자가 상품 페이지로 바로 갈 수 있어야 함. URL 변형 X. - "Hypebeast" / "Tagged" 라는 단어는 출력에 절대 포함 금지. Decoded 로 표기. - 카피는 한국어, 자연스럽고 트렌디한 매거진 톤. JSON 출력만. MagazineLayout 스키마: {{"schema_version": "1.0", "title": "...", "subtitle": "...", "hero_image_url": "...", - "sections": [{{"type": "hero|intro|curation_card|spotlight|closing", + "sections": [{{"type": "intro|curation_card|spotlight|closing", "title": "...", "body": "...", "post_id": "...", "image_url": "...", "solutions": [{{"solution_id": "...", "title": "...", "brand": "...", "price": "...", - "image_url": "..."}}]}}]}} + "image_url": "...", + "original_url": "...", + "affiliate_url": "..."}}]}}]}} """ diff --git a/packages/ai-server/src/editorial_article_chat/__init__.py b/packages/ai-server/src/editorial_article_chat/__init__.py new file mode 100644 index 00000000..058fb751 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/__init__.py @@ -0,0 +1 @@ +"""Stage 3: editorial article 대화형 편집 — Gemini function calling 기반 agent.""" diff --git a/packages/ai-server/src/editorial_article_chat/agent.py b/packages/ai-server/src/editorial_article_chat/agent.py new file mode 100644 index 00000000..449cb9e5 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/agent.py @@ -0,0 +1,271 @@ +"""Chat agent — Gemini function calling loop. + +흐름: + 1. session 의 기존 messages 로드 → Gemini contents 형태로 변환 + 2. 새 user message append + DB 저장 + 3. Gemini 호출 (tools 바인딩) + 4. function_call 있으면 → ToolExecutor 로 실행 → tool result 를 Gemini 에 다시 + 보내고 step 반복 (max_steps 까지) + 5. function_call 없으면 → 최종 assistant text 반환 + 6. 마지막 layout 변경 있으면 DB persist (트랜잭션) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from google import genai +from google.genai import types as genai_types + +from src.editorial_article.models import MagazineLayout +from src.managers.database import DatabaseManager +from src.post_editorial.config import get_settings +from src.post_editorial.gemini_retry import call_gemini_with_fallback + +from .repository import ChatMessage, EditorialArticleChatRepository +from .tools import TOOL_DECLARATIONS, ToolExecutor + + +logger = logging.getLogger(__name__) + + +_MAX_STEPS = 6 # tool calling round trip 최대 횟수 (안전 가드) + + +@dataclass +class TurnResult: + assistant_text: str + tool_calls_made: int + layout_changed: bool + + +def _system_prompt(article_title: str, sections_brief: str) -> str: + return f"""너는 Decoded 매거진의 시니어 에디터를 돕는 편집 AI다. +컨텐츠 매니저와 한국어로 대화하며 주어진 매거진 article 의 layout 을 다듬는다. + +[현재 매거진] +title: {article_title} +sections: +{sections_brief} + +[행동 원칙] +1. 사용자 의도를 정확히 파악 — 어떤 섹션을, 어떻게 바꾸고 싶은지. +2. 모호하면 한 번 물어보고, 명확하면 즉시 도구 호출. +3. 한 번에 여러 섹션 수정 가능 — 도구를 연속 호출. +4. 작은 텍스트 수정은 update_section_body / update_title 직접. + 톤·길이 변경 등 LLM 도움 필요한 수정은 regenerate_section_body. +5. 잘못된 인물 / 부적절한 카드는 remove_section. +6. 모든 변경 후 한국어로 무엇을 했는지 1-2 문장 요약. +7. layout 구조를 모르면 먼저 get_current_layout. + +[금기] +- "Hypebeast" / "Tagged" 같은 단어 출력 금지 (Decoded 톤). +- 사용자가 명시 안 한 변경 임의로 하지 말 것. +- 도구 호출 결과가 실패면 사용자에게 솔직히 알리고 다음 행동 제안.""" + + +def _build_contents( + history: list[ChatMessage], new_user_text: str +) -> list[genai_types.Content]: + """DB 메시지 → Gemini Content[] 변환.""" + contents: list[genai_types.Content] = [] + for msg in history: + if msg.role == "user" and msg.content: + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part.from_text(text=msg.content)] + ) + ) + elif msg.role == "assistant": + parts: list[genai_types.Part] = [] + if msg.content: + parts.append(genai_types.Part.from_text(text=msg.content)) + for fc in msg.tool_calls or []: + parts.append( + genai_types.Part.from_function_call( + name=fc["name"], args=fc.get("args", {}) + ) + ) + if parts: + contents.append(genai_types.Content(role="model", parts=parts)) + elif msg.role == "tool": + contents.append( + genai_types.Content( + role="user", + parts=[ + genai_types.Part.from_function_response( + name=msg.tool_name or "unknown", + response=msg.tool_result or {}, + ) + ], + ) + ) + contents.append( + genai_types.Content( + role="user", parts=[genai_types.Part.from_text(text=new_user_text)] + ) + ) + return contents + + +def _summarize_sections(layout: MagazineLayout) -> str: + lines = [] + for i, sec in enumerate(layout.sections): + title = sec.title or "(no title)" + lines.append(f" [{i}] {sec.type}: {title}") + return "\n".join(lines) if lines else " (sections 없음)" + + +def _extract_function_calls( + response: genai_types.GenerateContentResponse, +) -> list[tuple[str, dict, Optional[str]]]: + """response 의 function_call 들 추출 — (name, args, call_id) tuple list.""" + calls: list[tuple[str, dict, Optional[str]]] = [] + for candidate in response.candidates or []: + content = candidate.content + for part in (content.parts if content else []) or []: + fc = getattr(part, "function_call", None) + if fc and fc.name: + args = dict(fc.args) if fc.args else {} + call_id = getattr(fc, "id", None) + calls.append((fc.name, args, call_id)) + return calls + + +def _extract_text(response: genai_types.GenerateContentResponse) -> str: + parts_text: list[str] = [] + for candidate in response.candidates or []: + content = candidate.content + for part in (content.parts if content else []) or []: + t = getattr(part, "text", None) + if t: + parts_text.append(t) + return "\n".join(parts_text).strip() + + +async def run_turn( + *, + db: DatabaseManager, + repo: EditorialArticleChatRepository, + article_id: str, + session_id: str, + user_text: str, +) -> TurnResult: + """한 턴 실행 — user_text 받아서 모든 tool 호출 후 final text 반환.""" + layout, meta = await repo.load_article(article_id) + if layout is None: + raise ValueError(f"article {article_id} has no layout_json") + + history = await repo.list_messages(session_id) + contents = _build_contents(history, user_text) + + settings = get_settings() + client = genai.Client(api_key=settings.gemini_api_key) + executor = ToolExecutor(db=db, article_id=article_id, layout=layout) + + config = genai_types.GenerateContentConfig( + temperature=0.4, + system_instruction=_system_prompt( + meta.get("title") or "Untitled", _summarize_sections(layout) + ), + tools=[genai_types.Tool(function_declarations=TOOL_DECLARATIONS)], + ) + + # Persist user message immediately so client can poll + await repo.insert_user_message(session_id, user_text) + + tool_calls_made = 0 + final_text = "" + for step in range(_MAX_STEPS): + async def _generate(model: str) -> genai_types.GenerateContentResponse: + return await client.aio.models.generate_content( + model=model, contents=contents, config=config + ) + + try: + response = await call_gemini_with_fallback( + settings.gemini_pro_model, settings.gemini_model, _generate + ) + except Exception as exc: + logger.exception("chat agent: Gemini call failed") + await repo.insert_assistant_message( + session_id, content=f"(에이전트 오류: {exc})" + ) + await repo.touch_session(session_id) + return TurnResult(f"(에이전트 오류: {exc})", tool_calls_made, False) + + function_calls = _extract_function_calls(response) + text_part = _extract_text(response) + + # assistant message 저장 (tool_calls 포함) + tool_call_payloads = [ + {"name": name, "args": args, "id": cid or f"tc_{step}_{i}"} + for i, (name, args, cid) in enumerate(function_calls) + ] + await repo.insert_assistant_message( + session_id, + content=text_part if text_part else None, + tool_calls=tool_call_payloads if tool_call_payloads else None, + ) + + if not function_calls: + # 종료 — 최종 텍스트 반환 + final_text = text_part or "" + break + + # 다음 step 을 위해 model turn 도 contents 에 append + model_parts: list[genai_types.Part] = [] + if text_part: + model_parts.append(genai_types.Part.from_text(text=text_part)) + for name, args, _ in function_calls: + model_parts.append( + genai_types.Part.from_function_call(name=name, args=args) + ) + contents.append(genai_types.Content(role="model", parts=model_parts)) + + # 각 tool 실행 + tool message 저장 + Gemini 에 결과 전달 + tool_response_parts: list[genai_types.Part] = [] + for i, (name, args, _cid) in enumerate(function_calls): + tool_calls_made += 1 + result = await executor.execute(name, args) + tc_id = tool_call_payloads[i]["id"] + await repo.insert_tool_message( + session_id=session_id, + tool_call_id=tc_id, + tool_name=name, + tool_result=result.to_dict(), + ) + tool_response_parts.append( + genai_types.Part.from_function_response( + name=name, response=result.to_dict() + ) + ) + contents.append( + genai_types.Content(role="user", parts=tool_response_parts) + ) + else: + # max steps 도달 + final_text = ( + "(편집 사이클 한도 도달 — 안전상 중단. " + "더 진행하려면 다시 메시지 보내주세요.)" + ) + await repo.insert_assistant_message(session_id, content=final_text) + + # layout 변경 있으면 DB persist + layout_changed = executor.layout != layout + if layout_changed: + try: + await executor.persist_layout() + except Exception: + logger.exception("chat agent: persist_layout failed") + final_text += "\n\n(주의: DB 저장 중 오류 발생.)" + + await repo.touch_session(session_id) + + return TurnResult( + assistant_text=final_text, + tool_calls_made=tool_calls_made, + layout_changed=layout_changed, + ) diff --git a/packages/ai-server/src/editorial_article_chat/api.py b/packages/ai-server/src/editorial_article_chat/api.py new file mode 100644 index 00000000..43bc67e8 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/api.py @@ -0,0 +1,165 @@ +"""HTTP endpoints for editorial article chat (FastAPI router). + +ai-server 의 다른 routes 는 #416 이후 gRPC 로 옮겨갔지만, chat 은 multi-turn / +stream / 단순한 admin UI 호출 패턴이라 HTTP 가 더 자연스럽다 (예외). + +Routes (모두 prefix `/api/v1/editorial-article-chat`): + GET /sessions/{article_id} — 세션 리스트 + POST /sessions/{article_id} — 새 세션 생성 + GET /sessions/{session_id}/messages — 메시지 리스트 + POST /sessions/{session_id}/messages — 새 user 메시지 → agent 실행 +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from .agent import run_turn +from .repository import EditorialArticleChatRepository +from src.managers.database import DatabaseManager + + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# Request / Response models +# ───────────────────────────────────────────────────────────────────────────── + + +class SessionResponse(BaseModel): + id: str + article_id: str + created_by: Optional[str] = None + title: Optional[str] = None + last_message_at: Optional[str] = None + created_at: str + + +class CreateSessionRequest(BaseModel): + created_by: Optional[str] = None + + +class MessageResponse(BaseModel): + id: int + session_id: str + role: str + content: Optional[str] = None + tool_calls: Optional[list[dict]] = None + tool_call_id: Optional[str] = None + tool_name: Optional[str] = None + tool_result: Optional[dict] = None + created_at: str + + +class SendMessageRequest(BaseModel): + text: str = Field(min_length=1, max_length=4000) + + +class SendMessageResponse(BaseModel): + assistant_text: str + tool_calls_made: int + layout_changed: bool + + +# ───────────────────────────────────────────────────────────────────────────── +# Router factory (DI 용 — operation_db / repo 주입) +# ───────────────────────────────────────────────────────────────────────────── + + +def create_router(db: DatabaseManager) -> APIRouter: + repo = EditorialArticleChatRepository(db) + router = APIRouter(prefix="/api/v1/editorial-article-chat", tags=["editorial-chat"]) + + @router.get("/sessions/{article_id}") + async def list_sessions(article_id: str) -> list[SessionResponse]: + sessions = await repo.list_sessions(article_id) + return [ + SessionResponse( + id=s.id, + article_id=s.article_id, + created_by=s.created_by, + title=s.title, + last_message_at=s.last_message_at.isoformat() + if s.last_message_at + else None, + created_at=s.created_at.isoformat(), + ) + for s in sessions + ] + + @router.post("/sessions/{article_id}") + async def create_session( + article_id: str, body: CreateSessionRequest + ) -> SessionResponse: + # article 존재 확인 + layout, _ = await repo.load_article(article_id) + if layout is None: + raise HTTPException(404, f"article {article_id} not found / no layout") + sess = await repo.create_session(article_id, body.created_by) + return SessionResponse( + id=sess.id, + article_id=sess.article_id, + created_by=sess.created_by, + title=sess.title, + last_message_at=None, + created_at=sess.created_at.isoformat(), + ) + + @router.get("/sessions/{session_id}/messages") + async def list_messages(session_id: str) -> list[MessageResponse]: + messages = await repo.list_messages(session_id) + return [ + MessageResponse( + id=m.id, + session_id=m.session_id, + role=m.role, + content=m.content, + tool_calls=m.tool_calls, + tool_call_id=m.tool_call_id, + tool_name=m.tool_name, + tool_result=m.tool_result, + created_at=m.created_at.isoformat(), + ) + for m in messages + ] + + @router.post("/sessions/{session_id}/messages") + async def send_message( + session_id: str, body: SendMessageRequest + ) -> SendMessageResponse: + # session 의 article_id 확인 + async with db.acquire() as conn: + row = await conn.fetchrow( + "SELECT article_id FROM public.editorial_article_chat_sessions WHERE id = $1::uuid", + session_id, + ) + if row is None: + raise HTTPException(404, f"session {session_id} not found") + article_id = str(row["article_id"]) + + try: + result = await run_turn( + db=db, + repo=repo, + article_id=article_id, + session_id=session_id, + user_text=body.text, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: + logger.exception("send_message: agent crashed") + raise HTTPException(500, f"agent error: {exc}") from exc + + return SendMessageResponse( + assistant_text=result.assistant_text, + tool_calls_made=result.tool_calls_made, + layout_changed=result.layout_changed, + ) + + return router diff --git a/packages/ai-server/src/editorial_article_chat/repository.py b/packages/ai-server/src/editorial_article_chat/repository.py new file mode 100644 index 00000000..996e0315 --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/repository.py @@ -0,0 +1,272 @@ +"""DB access for chat sessions / messages + article layout load.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from src.editorial_article.models import MagazineLayout +from src.managers.database import DatabaseManager + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ChatSession: + id: str + article_id: str + created_by: Optional[str] + title: Optional[str] + last_message_at: Optional[datetime] + created_at: datetime + + +@dataclass(frozen=True) +class ChatMessage: + id: int + session_id: str + role: str # user|assistant|tool|system + content: Optional[str] + tool_calls: Optional[list[dict[str, Any]]] + tool_call_id: Optional[str] + tool_name: Optional[str] + tool_result: Optional[dict[str, Any]] + created_at: datetime + + +class EditorialArticleChatRepository: + def __init__(self, database_manager: DatabaseManager) -> None: + self._db = database_manager + + # ---- Article load ---------------------------------------------------- + + async def load_article( + self, article_id: str + ) -> tuple[Optional[MagazineLayout], dict]: + """현재 article 의 layout_json + 메타 (title/status) 로드.""" + async with self._db.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT title, subtitle, hero_image_url, layout_json, status + FROM public.editorial_articles + WHERE id = $1::uuid + """, + article_id, + ) + if row is None: + return None, {} + layout_data = row["layout_json"] + if isinstance(layout_data, str): + try: + layout_data = json.loads(layout_data) + except Exception: + layout_data = None + layout = ( + MagazineLayout.model_validate(layout_data) + if isinstance(layout_data, dict) + else None + ) + meta = { + "title": row["title"], + "subtitle": row["subtitle"], + "hero_image_url": row["hero_image_url"], + "status": row["status"], + } + return layout, meta + + # ---- Sessions -------------------------------------------------------- + + async def create_session( + self, + article_id: str, + created_by: Optional[str] = None, + ) -> ChatSession: + async with self._db.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO public.editorial_article_chat_sessions + (article_id, created_by) + VALUES ($1::uuid, $2::uuid) + RETURNING id, article_id, created_by, title, last_message_at, created_at + """, + article_id, + UUID(created_by) if created_by else None, + ) + return ChatSession( + id=str(row["id"]), + article_id=str(row["article_id"]), + created_by=str(row["created_by"]) if row["created_by"] else None, + title=row["title"], + last_message_at=row["last_message_at"], + created_at=row["created_at"], + ) + + async def list_sessions( + self, article_id: str, limit: int = 20 + ) -> list[ChatSession]: + async with self._db.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, article_id, created_by, title, last_message_at, created_at + FROM public.editorial_article_chat_sessions + WHERE article_id = $1::uuid + ORDER BY created_at DESC + LIMIT $2 + """, + article_id, + limit, + ) + return [ + ChatSession( + id=str(r["id"]), + article_id=str(r["article_id"]), + created_by=str(r["created_by"]) if r["created_by"] else None, + title=r["title"], + last_message_at=r["last_message_at"], + created_at=r["created_at"], + ) + for r in rows + ] + + async def get_or_create_latest_session( + self, article_id: str, created_by: Optional[str] = None + ) -> ChatSession: + sessions = await self.list_sessions(article_id, limit=1) + if sessions: + return sessions[0] + return await self.create_session(article_id, created_by) + + async def touch_session(self, session_id: str) -> None: + async with self._db.acquire() as conn: + await conn.execute( + """ + UPDATE public.editorial_article_chat_sessions + SET last_message_at = now(), updated_at = now() + WHERE id = $1::uuid + """, + session_id, + ) + + # ---- Messages -------------------------------------------------------- + + async def list_messages(self, session_id: str) -> list[ChatMessage]: + async with self._db.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, session_id, role, content, tool_calls, + tool_call_id, tool_name, tool_result, created_at + FROM public.editorial_article_chat_messages + WHERE session_id = $1::uuid + ORDER BY id ASC + """, + session_id, + ) + return [_row_to_message(r) for r in rows] + + async def insert_user_message( + self, session_id: str, content: str + ) -> ChatMessage: + return await self._insert( + session_id=session_id, + role="user", + content=content, + tool_calls=None, + tool_call_id=None, + tool_name=None, + tool_result=None, + ) + + async def insert_assistant_message( + self, + session_id: str, + content: Optional[str], + tool_calls: Optional[list[dict[str, Any]]] = None, + ) -> ChatMessage: + return await self._insert( + session_id=session_id, + role="assistant", + content=content, + tool_calls=tool_calls, + tool_call_id=None, + tool_name=None, + tool_result=None, + ) + + async def insert_tool_message( + self, + session_id: str, + tool_call_id: str, + tool_name: str, + tool_result: dict[str, Any], + ) -> ChatMessage: + return await self._insert( + session_id=session_id, + role="tool", + content=None, + tool_calls=None, + tool_call_id=tool_call_id, + tool_name=tool_name, + tool_result=tool_result, + ) + + async def _insert( + self, + *, + session_id: str, + role: str, + content: Optional[str], + tool_calls: Optional[list[dict[str, Any]]], + tool_call_id: Optional[str], + tool_name: Optional[str], + tool_result: Optional[dict[str, Any]], + ) -> ChatMessage: + async with self._db.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO public.editorial_article_chat_messages + (session_id, role, content, tool_calls, + tool_call_id, tool_name, tool_result) + VALUES ($1::uuid, $2, $3, $4::jsonb, $5, $6, $7::jsonb) + RETURNING id, session_id, role, content, tool_calls, + tool_call_id, tool_name, tool_result, created_at + """, + session_id, + role, + content, + json.dumps(tool_calls) if tool_calls is not None else None, + tool_call_id, + tool_name, + json.dumps(tool_result) if tool_result is not None else None, + ) + return _row_to_message(row) + + +def _row_to_message(row: Any) -> ChatMessage: + tool_calls = row["tool_calls"] + if isinstance(tool_calls, str): + try: + tool_calls = json.loads(tool_calls) + except Exception: + tool_calls = None + tool_result = row["tool_result"] + if isinstance(tool_result, str): + try: + tool_result = json.loads(tool_result) + except Exception: + tool_result = None + return ChatMessage( + id=row["id"], + session_id=str(row["session_id"]), + role=row["role"], + content=row["content"], + tool_calls=tool_calls, + tool_call_id=row["tool_call_id"], + tool_name=row["tool_name"], + tool_result=tool_result, + created_at=row["created_at"], + ) diff --git a/packages/ai-server/src/editorial_article_chat/tools.py b/packages/ai-server/src/editorial_article_chat/tools.py new file mode 100644 index 00000000..f363703a --- /dev/null +++ b/packages/ai-server/src/editorial_article_chat/tools.py @@ -0,0 +1,387 @@ +"""Tool definitions for editorial article chat agent. + +Tools 는 두 가지 카테고리: + 1. Direct edits — agent 가 layout_json 의 특정 필드를 직접 교체 (text 변경) + 2. LLM-assisted regenerations — agent 가 hint 와 함께 호출하면 별도 Gemini 가 + 새 카피를 생성해서 반영 (e.g., regenerate_section_body) + +각 tool 의 declaration 은 google-genai 의 FunctionDeclaration 형식. +실행은 ToolExecutor 가 담당 — DB write 까지 책임지므로 article_id 와 db 를 +context 로 받음. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from google import genai +from google.genai import types as genai_types + +from src.editorial_article.models import MagazineLayout, MagazineSection +from src.managers.database import DatabaseManager +from src.post_editorial.config import get_settings +from src.post_editorial.gemini_retry import call_gemini_with_fallback + + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# Tool 선언 (Gemini function declarations) +# ───────────────────────────────────────────────────────────────────────────── + +TOOL_DECLARATIONS: list[genai_types.FunctionDeclaration] = [ + genai_types.FunctionDeclaration( + name="update_title", + description=( + "매거진 article 의 title 을 새 문자열로 교체합니다. " + "subtitle 변경에는 update_subtitle 을 사용하세요." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "title": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 매거진 title (한국어, TAGGED 톤).", + ), + }, + required=["title"], + ), + ), + genai_types.FunctionDeclaration( + name="update_subtitle", + description="매거진 article 의 subtitle (부제) 를 교체합니다.", + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "subtitle": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 subtitle. 빈 문자열이면 subtitle 제거.", + ), + }, + required=["subtitle"], + ), + ), + genai_types.FunctionDeclaration( + name="update_section_body", + description=( + "특정 섹션의 body 카피를 새 문자열로 교체합니다. " + "현재 body 를 유지하고 약간만 다듬을 때 사용. " + "전체 톤을 LLM 에게 다시 생성시키려면 regenerate_section_body 사용." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema( + type=genai_types.Type.INTEGER, + description="섹션 0-based index. get_current_layout 결과 참조.", + ), + "body": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 body 카피.", + ), + }, + required=["section_idx", "body"], + ), + ), + genai_types.FunctionDeclaration( + name="update_section_title", + description="특정 섹션의 title 을 교체합니다.", + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + "title": genai_types.Schema(type=genai_types.Type.STRING), + }, + required=["section_idx", "title"], + ), + ), + genai_types.FunctionDeclaration( + name="remove_section", + description=( + "특정 섹션을 매거진에서 제거합니다. " + "예: 잘못된 인물의 curation_card 또는 부적절한 섹션. " + "hero / closing 도 제거 가능 — 매거진 구조 자체를 바꿀 때." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + }, + required=["section_idx"], + ), + ), + genai_types.FunctionDeclaration( + name="regenerate_section_body", + description=( + "특정 섹션의 body 카피를 LLM 으로 다시 생성합니다. " + "사용자가 톤 / 길이 / 강조점 변경을 요구할 때 사용. " + "hint 에 어떻게 다시 쓸지 구체 지침 (예: '더 차분하게', " + "'브랜드 가격을 강조', '문장 짧게')." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "section_idx": genai_types.Schema(type=genai_types.Type.INTEGER), + "hint": genai_types.Schema( + type=genai_types.Type.STRING, + description="새 카피 작성 지침 (한국어).", + ), + }, + required=["section_idx", "hint"], + ), + ), + genai_types.FunctionDeclaration( + name="get_current_layout", + description=( + "현재 매거진 layout 의 요약 (각 섹션의 idx, type, title, body 의 첫 200자) " + "을 반환합니다. 사용자 요청을 수행하기 전 어떤 섹션이 있는지 확인할 때 사용." + ), + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, properties={} + ), + ), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# Tool 실행 +# ───────────────────────────────────────────────────────────────────────────── + + +@dataclass +class ToolResult: + success: bool + summary: str + layout_changed: bool = False + error: Optional[str] = None + + def to_dict(self) -> dict: + return { + "success": self.success, + "summary": self.summary, + "layout_changed": self.layout_changed, + "error": self.error, + } + + +class ToolExecutor: + """Layout 을 메모리에 들고 있다가 각 tool 호출에 대해 mutate + DB persist.""" + + def __init__( + self, + db: DatabaseManager, + article_id: str, + layout: MagazineLayout, + ) -> None: + self._db = db + self._article_id = article_id + self._layout = layout + + @property + def layout(self) -> MagazineLayout: + return self._layout + + async def execute(self, name: str, args: dict) -> ToolResult: + handler: Optional[Callable[..., Any]] = _HANDLERS.get(name) + if handler is None: + return ToolResult(False, f"Unknown tool: {name}", error="unknown_tool") + + try: + return await handler(self, **args) + except TypeError as exc: + logger.warning("ToolExecutor: bad args for %s — %s", name, exc) + return ToolResult(False, f"Bad arguments: {exc}", error=str(exc)) + except Exception as exc: + logger.exception("ToolExecutor: %s crashed", name) + return ToolResult(False, f"Tool crashed: {exc}", error=str(exc)) + + async def persist_layout(self) -> None: + layout_json = self._layout.model_dump(mode="json") + async with self._db.acquire() as conn: + await conn.execute( + """ + UPDATE public.editorial_articles + SET title = $2, + subtitle = $3, + hero_image_url = $4, + layout_json = $5::jsonb, + updated_at = now() + WHERE id = $1::uuid + """, + self._article_id, + self._layout.title or "Untitled", + self._layout.subtitle, + self._layout.hero_image_url, + json.dumps(layout_json), + ) + + # ---- Handlers -------------------------------------------------------- + + async def _update_title(self, title: str) -> ToolResult: + old = self._layout.title + self._layout = self._layout.model_copy(update={"title": title}) + return ToolResult(True, f"Title: '{old}' → '{title}'", layout_changed=True) + + async def _update_subtitle(self, subtitle: str) -> ToolResult: + new = subtitle if subtitle else None + old = self._layout.subtitle + self._layout = self._layout.model_copy(update={"subtitle": new}) + return ToolResult( + True, f"Subtitle: '{old}' → '{new}'", layout_changed=True + ) + + def _check_idx(self, section_idx: int) -> Optional[ToolResult]: + if section_idx < 0 or section_idx >= len(self._layout.sections): + return ToolResult( + False, + f"section_idx {section_idx} out of range (0..{len(self._layout.sections)-1}).", + error="out_of_range", + ) + return None + + async def _update_section_body( + self, section_idx: int, body: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + sec = sections[section_idx].model_copy(update={"body": body}) + sections[section_idx] = sec + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({sec.type}) body 교체 — {len(body)} 자", + layout_changed=True, + ) + + async def _update_section_title( + self, section_idx: int, title: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + old = sections[section_idx].title + sections[section_idx] = sections[section_idx].model_copy( + update={"title": title} + ) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} title: '{old}' → '{title}'", + layout_changed=True, + ) + + async def _remove_section(self, section_idx: int) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sections = list(self._layout.sections) + removed = sections.pop(section_idx) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({removed.type}, '{removed.title}') 제거. 새 길이 {len(sections)}.", + layout_changed=True, + ) + + async def _regenerate_section_body( + self, section_idx: int, hint: str + ) -> ToolResult: + if err := self._check_idx(section_idx): + return err + sec = self._layout.sections[section_idx] + new_body = await _llm_regenerate_body(self._layout, sec, hint) + sections = list(self._layout.sections) + sections[section_idx] = sec.model_copy(update={"body": new_body}) + self._layout = self._layout.model_copy(update={"sections": sections}) + return ToolResult( + True, + f"Section {section_idx} ({sec.type}) body 재생성 (hint: {hint[:60]}{'…' if len(hint) > 60 else ''})", + layout_changed=True, + ) + + async def _get_current_layout(self) -> ToolResult: + lines = [ + f"Title: {self._layout.title}", + f"Subtitle: {self._layout.subtitle or '(none)'}", + f"Sections ({len(self._layout.sections)}):", + ] + for i, sec in enumerate(self._layout.sections): + preview = (sec.body or "")[:200].replace("\n", " ") + lines.append( + f" [{i}] type={sec.type} title='{sec.title or ''}' body='{preview}'" + ) + return ToolResult(True, "\n".join(lines)) + + +_HANDLERS: dict[str, Callable[..., Any]] = { + "update_title": ToolExecutor._update_title, + "update_subtitle": ToolExecutor._update_subtitle, + "update_section_body": ToolExecutor._update_section_body, + "update_section_title": ToolExecutor._update_section_title, + "remove_section": ToolExecutor._remove_section, + "regenerate_section_body": ToolExecutor._regenerate_section_body, + "get_current_layout": ToolExecutor._get_current_layout, +} + + +# ───────────────────────────────────────────────────────────────────────────── +# LLM helper — section body 재생성 +# ───────────────────────────────────────────────────────────────────────────── + + +async def _llm_regenerate_body( + layout: MagazineLayout, section: MagazineSection, hint: str +) -> str: + settings = get_settings() + client = genai.Client(api_key=settings.gemini_api_key) + prompt = _build_regenerate_prompt(layout, section, hint) + + async def _generate(model: str) -> str: + resp = await client.aio.models.generate_content( + model=model, + contents=prompt, + config=genai_types.GenerateContentConfig(temperature=0.7), + ) + return (resp.text or "").strip() + + return await call_gemini_with_fallback( + settings.gemini_model, # flash 면 충분 + settings.gemini_fallback_model, + _generate, + ) + + +def _build_regenerate_prompt( + layout: MagazineLayout, section: MagazineSection, hint: str +) -> str: + return f"""너는 Decoded 매거진의 시니어 에디터다. +아래 매거진 한 섹션의 body 카피를 다시 작성해라. + +[매거진 컨텍스트] +title: {layout.title} +subtitle: {layout.subtitle or ''} + +[수정할 섹션] +type: {section.type} +title: {section.title or ''} +현재 body: +{section.body or '(empty)'} + +[수정 지침] +{hint} + +요구사항: +- 한국어, 트렌디하면서도 자연스러운 매거진 톤 +- "Hypebeast" / "Tagged" 라는 단어 절대 사용 금지 (Decoded 로) +- 섹션 type 에 적합한 길이: + - intro: 200-300자 + - curation_card: 100-200자 + - spotlight: 150-200자 + - closing: 100-150자 + - hero: 50자 이내 (있을 경우) + +새 body 카피만 출력. 따옴표 / JSON / 추가 설명 없이 plain text 로.""" diff --git a/packages/ai-server/src/managers/llm/adapters/openai_image.py b/packages/ai-server/src/managers/llm/adapters/openai_image.py new file mode 100644 index 00000000..27d73744 --- /dev/null +++ b/packages/ai-server/src/managers/llm/adapters/openai_image.py @@ -0,0 +1,132 @@ +"""Async wrapper for OpenAI image gen (gpt-image-1) — magazine thumbnail용 (#429). + +nano-banana 가 한글 text rendering 약해서 thumbnail 은 gpt-image-1 로 전환. +edits / generate 두 모드 지원. 결과는 PNG bytes. + +OpenAI image API 의 size 지원: 1024x1024 / 1024x1536 (2:3 portrait) / 1536x1024 +/ auto. 4:5 는 직접 지원 X — caller 가 1024x1536 로 받아서 PIL crop 처리. +""" + +from __future__ import annotations + +import base64 +import io +import logging +from typing import Optional + +from openai import AsyncOpenAI + + +logger = logging.getLogger(__name__) + + +class OpenAIImageError(RuntimeError): + pass + + +class OpenAIImageClient: + """Thin async wrapper around `gpt-image-1` for magazine thumbnails.""" + + def __init__( + self, + api_key: str, + model: str = "gpt-image-2", + timeout_seconds: int = 300, + ) -> None: + if not api_key: + raise OpenAIImageError("OpenAIImageClient: api_key is empty") + self._model = model + self._timeout = timeout_seconds + self._client = AsyncOpenAI(api_key=api_key, timeout=timeout_seconds) + + async def edit( + self, + *, + image_bytes: bytes, + image_mime_type: str, + prompt: str, + size: str = "1024x1536", + quality: str = "high", + ) -> bytes: + """Edit an existing image with a text prompt. Returns PNG bytes. + + size: '1024x1024' | '1024x1536' | '1536x1024' | 'auto'. + 4:5 thumbnail 은 1024x1536 받아 caller 가 1024x1280 으로 crop. + """ + suffix = "png" if "png" in image_mime_type else "jpg" + image_file = io.BytesIO(image_bytes) + image_file.name = f"input.{suffix}" + + try: + resp = await self._client.images.edit( + model=self._model, + image=image_file, + prompt=prompt, + size=size, + n=1, + quality=quality, + ) + except Exception as exc: + raise OpenAIImageError(f"openai images.edit failed: {exc}") from exc + + return _extract_png_bytes(resp) + + async def generate( + self, + *, + prompt: str, + size: str = "1024x1536", + quality: str = "high", + ) -> bytes: + """Pure text-to-image (no input). Returns PNG bytes.""" + try: + resp = await self._client.images.generate( + model=self._model, + prompt=prompt, + size=size, + n=1, + quality=quality, + ) + except Exception as exc: + raise OpenAIImageError(f"openai images.generate failed: {exc}") from exc + + return _extract_png_bytes(resp) + + @property + def model(self) -> str: + return self._model + + +def _extract_png_bytes(resp) -> bytes: + if not resp.data: + raise OpenAIImageError("openai response has no data") + item = resp.data[0] + b64 = getattr(item, "b64_json", None) + if b64: + return base64.b64decode(b64) + url = getattr(item, "url", None) + if url: + # gpt-image-1 returns b64 by default; url path is fallback + import httpx + + with httpx.Client(timeout=60) as client: + r = client.get(url) + r.raise_for_status() + return r.content + raise OpenAIImageError("openai response item has neither b64_json nor url") + + +def crop_to_4_5(png_bytes: bytes, target_height: Optional[int] = None) -> bytes: + """1024x1536 → 1024x1280 (4:5) center crop. PIL 사용.""" + from PIL import Image + + img = Image.open(io.BytesIO(png_bytes)) + w, h = img.size + target_h = target_height or int(w * 5 / 4) # 4:5 portrait + if h <= target_h: + return png_bytes + top = (h - target_h) // 2 + cropped = img.crop((0, top, w, top + target_h)) + out = io.BytesIO() + cropped.save(out, format="PNG") + return out.getvalue() diff --git a/packages/ai-server/src/managers/queue/worker.py b/packages/ai-server/src/managers/queue/worker.py index 655e13eb..6d7a9535 100644 --- a/packages/ai-server/src/managers/queue/worker.py +++ b/packages/ai-server/src/managers/queue/worker.py @@ -122,6 +122,12 @@ async def create_worker( ) # #397 — pipeline 실패 알림 fire-and-forget (env 비면 no-op). ctx["telegram_notifier"] = infrastructure_container.telegram_notifier() + # #429 — editorial_article 의 generate_thumbnail 노드용 + ctx["nano_banana_client"] = infrastructure_container.nano_banana_client() + ctx["openai_image_client"] = ( + infrastructure_container.openai_image_client() + ) + ctx["r2_client"] = infrastructure_container.r2_client() # Create worker with settings worker = Worker( diff --git a/packages/ai-server/src/services/editorial_article/editorial_article_service.py b/packages/ai-server/src/services/editorial_article/editorial_article_service.py index 9d0145a5..e3702879 100644 --- a/packages/ai-server/src/services/editorial_article/editorial_article_service.py +++ b/packages/ai-server/src/services/editorial_article/editorial_article_service.py @@ -26,11 +26,21 @@ async def editorial_article_job( article_id: str, recommendation_id: str, ) -> Dict[str, Any]: + # #429: assets = editorial staging (recommendations / articles / events / chat), + # operation = posts/spots/solutions read + publish snapshot. 두 pool 모두 주입. + assets_db: DatabaseManager | None = ctx.get("assets_database_manager") operation_db: DatabaseManager | None = ctx.get("operation_database_manager") - - if operation_db is None: - logger.error("editorial_article_job: operation_database_manager missing") - return {"success": False, "error": "operation_db missing"} + nano_banana = ctx.get("nano_banana_client") + openai_image = ctx.get("openai_image_client") + r2 = ctx.get("r2_client") + + if assets_db is None or operation_db is None: + logger.error( + "editorial_article_job: db pools missing (assets=%s operation=%s)", + assets_db, + operation_db, + ) + return {"success": False, "error": "db pools missing"} graph = create_editorial_article_graph() @@ -45,7 +55,11 @@ async def editorial_article_job( config = { "configurable": { + "assets_database_manager": assets_db, "operation_database_manager": operation_db, + "nano_banana_client": nano_banana, + "openai_image_client": openai_image, + "r2_client": r2, } } @@ -53,7 +67,7 @@ async def editorial_article_job( final_state = await graph.ainvoke(initial_state, config=config) except Exception as exc: logger.exception("editorial_article_job: graph crashed") - await _mark_failed(operation_db, article_id, f"graph crash: {exc}") + await _mark_failed(assets_db, article_id, f"graph crash: {exc}") return {"success": False, "error": str(exc)} status = final_state.get("pipeline_status", "") @@ -65,7 +79,7 @@ async def editorial_article_job( status, err, ) - await _mark_failed(operation_db, article_id, err[:500]) + await _mark_failed(assets_db, article_id, err[:500]) return {"success": False, "error": err} return {"success": True, "article_id": article_id} diff --git a/packages/ai-server/src/services/editorial_article/scheduler.py b/packages/ai-server/src/services/editorial_article/scheduler.py index b57cf54a..8834c355 100644 --- a/packages/ai-server/src/services/editorial_article/scheduler.py +++ b/packages/ai-server/src/services/editorial_article/scheduler.py @@ -1,17 +1,18 @@ -"""ArticlePickupScheduler — Stage 2 자동 트리거. +"""ArticlePickupScheduler — Stage 2 enqueue (#429 후 단순화). -api-server 가 approve 클릭에서 추가 enqueue 책임을 지지 않도록, ai-server 에서 -다음 패턴으로 self-pickup 한다: +이전 (PR #440): approve 시점에 recommendation status 만 갱신 → scheduler 가 +INSERT article + ARQ enqueue 모두 담당. UI 가 1분 정도 'silence' 봤음. - 1. 매 분 polling — `editorial_recommendations` 중 `status='approved'` AND - `article_id IS NULL` 인 row 1건 픽 - 2. `editorial_articles` row INSERT (status='generating', source_post_ids 복사) - 3. recommendation.article_id 연결 - 4. ARQ enqueue `editorial_article_job(article_id, recommendation_id)` +현재: api-server 가 approve 트랜잭션 안에서 article INSERT 까지 처리 → UI 즉시 +Drafts 탭에서 'generating' 표시. scheduler 는 enqueue 만 담당. + + 1. 매 분 polling — editorial_articles.status='generating' AND 아직 enqueue + event 없는 row 1건 픽 + 2. ARQ enqueue editorial_article_job(article_id, recommendation_id) + 3. event INSERT (note='enqueued') — 중복 enqueue 방지 운영 약속: - - 동시 진행 1건 (max_instances=1) — 다음 tick 에서 다음 1건 픽 - - lock = `editorial_articles.status='generating'` 존재 여부 + - 동시 진행 1건 (max_instances=1) - 실패 자동 재시도 X (status='failed' 후 admin 수동 재시도) """ @@ -20,7 +21,6 @@ import logging from datetime import datetime, timedelta, timezone from typing import Optional -from uuid import uuid4 from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -74,84 +74,65 @@ async def _cycle(self) -> None: logger.exception("ArticlePickupScheduler: cycle crashed") async def _run_one(self) -> None: - # lock 체크 + 다음 후보 픽 (단일 SQL) + # api-server 가 approve 시점에 article INSERT 까지 처리 (#429 — Drafts + # 즉시 가시성). 이 scheduler 는 enqueue 만 담당: + # editorial_articles.status='generating' AND 아직 enqueue event 없는 + # article 1건 픽 → ARQ enqueue + event 기록. + # ARQ pool 동시성 1 이라 한 번에 하나씩 처리. async with self._db.acquire() as conn: row = await conn.fetchrow( """ - WITH lock_check AS ( - SELECT EXISTS ( - SELECT 1 FROM public.editorial_articles - WHERE status = 'generating' - ) AS is_locked - ) - SELECT (SELECT is_locked FROM lock_check) AS is_locked, - r.id, r.source_post_ids - FROM public.editorial_recommendations r - WHERE r.status = 'approved' - AND r.article_id IS NULL - ORDER BY r.approved_at ASC NULLS LAST, r.created_at ASC + SELECT a.id, a.recommendation_id + FROM public.editorial_articles a + WHERE a.status = 'generating' + AND NOT EXISTS ( + SELECT 1 FROM public.editorial_article_events e + WHERE e.article_id = a.id + AND e.note = 'enqueued' + ) + ORDER BY a.created_at ASC LIMIT 1 """ ) if row is None: return - if row["is_locked"]: - logger.debug("ArticlePickupScheduler: lock held — skip") - return - recommendation_id = str(row["id"]) - source_post_ids = list(row["source_post_ids"] or []) - - article_id = uuid4() - # editorial_articles INSERT + recommendation 연결 (트랜잭션) - async with self._db.acquire() as conn: - async with conn.transaction(): - await conn.execute( - """ - INSERT INTO public.editorial_articles - (id, recommendation_id, source_post_ids, status) - VALUES ($1::uuid, $2::uuid, $3::uuid[], 'generating') - """, - article_id, - recommendation_id, - source_post_ids, - ) - await conn.execute( - """ - UPDATE public.editorial_recommendations - SET article_id = $1::uuid, - updated_at = now() - WHERE id = $2::uuid - """, - article_id, - recommendation_id, - ) - await conn.execute( - """ - INSERT INTO public.editorial_article_events - (article_id, from_status, to_status, step, note) - VALUES ($1::uuid, NULL, 'generating', NULL, 'pickup') - """, - article_id, - ) + article_id = str(row["id"]) + recommendation_id = str(row["recommendation_id"]) if row["recommendation_id"] else "" # ARQ enqueue try: await self._queue.enqueue_job( "editorial_article_job", - str(article_id), + article_id, recommendation_id, _job_id=f"editorial_article_{article_id}", ) except Exception: logger.exception("ArticlePickupScheduler: enqueue failed — marking failed") - await self._mark_failed(str(article_id), "enqueue failed") + await self._mark_failed(article_id, "enqueue failed") return + # event 기록 — 다음 cycle 에서 동일 article 다시 enqueue 안 되도록 + try: + async with self._db.acquire() as conn: + await conn.execute( + """ + INSERT INTO public.editorial_article_events + (article_id, from_status, to_status, step, note) + VALUES ($1::uuid, 'generating', 'generating', 'pickup', 'enqueued') + """, + article_id, + ) + except Exception: + logger.exception( + "ArticlePickupScheduler: enqueue event INSERT failed (job 이미 enqueue 됨, 다음 cycle 중복 enqueue 가능)" + ) + logger.info( - "ArticlePickupScheduler: picked recommendation=%s article=%s", - recommendation_id, + "ArticlePickupScheduler: enqueued article=%s recommendation=%s", article_id, + recommendation_id, ) async def _mark_failed(self, article_id: str, error: str) -> None: diff --git a/packages/ai-server/src/services/editorial_discovery/repository.py b/packages/ai-server/src/services/editorial_discovery/repository.py index 807a1103..c2e44312 100644 --- a/packages/ai-server/src/services/editorial_discovery/repository.py +++ b/packages/ai-server/src/services/editorial_discovery/repository.py @@ -29,7 +29,7 @@ class DiscoverySettings: """`public.editorial_discovery_settings` singleton row.""" enabled: bool - cycle_seconds: int + cycle_minutes: int lookback_days: int max_recommendations_per_run: int min_posts_per_angle: int @@ -44,8 +44,17 @@ class DiscoverySettings: class EditorialDiscoveryRepository: - def __init__(self, database_manager: DatabaseManager) -> None: - self._db = database_manager + """#429 architectural fix: settings/recommendations 는 assets, posts/spots/ + solutions read 는 operation. 두 풀 분리 주입. + """ + + def __init__( + self, + assets_db: DatabaseManager, + operation_db: DatabaseManager, + ) -> None: + self._db = assets_db # backward compat — settings/recommendations 가 assets + self._operation_db = operation_db # ---- Settings -------------------------------------------------------- @@ -54,7 +63,7 @@ async def fetch_settings(self) -> DiscoverySettings: row = await conn.fetchrow( """ SELECT enabled, - cycle_seconds, + cycle_minutes, lookback_days, max_recommendations_per_run, min_posts_per_angle, @@ -72,7 +81,7 @@ async def fetch_settings(self) -> DiscoverySettings: ) return DiscoverySettings( enabled=False, - cycle_seconds=21600, + cycle_minutes=360, lookback_days=7, max_recommendations_per_run=5, min_posts_per_angle=3, @@ -83,7 +92,7 @@ async def fetch_settings(self) -> DiscoverySettings: ) return DiscoverySettings( enabled=row["enabled"], - cycle_seconds=row["cycle_seconds"], + cycle_minutes=row["cycle_minutes"], lookback_days=row["lookback_days"], max_recommendations_per_run=row["max_recommendations_per_run"], min_posts_per_angle=row["min_posts_per_angle"], @@ -137,15 +146,23 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: - 이미 다른 recommendation 의 source_post_ids 에 포함된 post 도 제외 (중복 angle 방지) """ + # #429: used_posts 는 assets 의 editorial_recommendations 조회. posts / + # spots / solutions 는 operation. 두 풀 분리 호출. async with self._db.acquire() as conn: + used_rows = await conn.fetch( + """ + SELECT DISTINCT unnest(source_post_ids) AS post_id + FROM public.editorial_recommendations + WHERE status IN ('pending', 'approved', 'drafted') + AND created_at >= now() - ($1::int * interval '1 day') + """, + lookback_days, + ) + used_post_ids = [r["post_id"] for r in used_rows] + + async with self._operation_db.acquire() as conn: post_rows = await conn.fetch( """ - WITH used_posts AS ( - SELECT DISTINCT unnest(source_post_ids) AS post_id - FROM public.editorial_recommendations - WHERE status IN ('pending', 'approved', 'drafted') - AND created_at >= now() - ($1::int * interval '1 day') - ) SELECT p.id, p.artist_name, p.group_name, @@ -159,7 +176,7 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: AND p.status = 'active' AND p.image_url IS NOT NULL AND p.post_magazine_id IS NULL - AND p.id NOT IN (SELECT post_id FROM used_posts) + AND ($3::uuid[] = '{}' OR p.id != ALL($3::uuid[])) ORDER BY COALESCE(p.trending_score, 0) DESC, p.view_count DESC, p.created_at DESC @@ -167,6 +184,7 @@ async def gather_discovery_input(self, *, lookback_days: int) -> DiscoveryInput: """, lookback_days, _MAX_POSTS_FOR_MINING, + used_post_ids, ) posts: list[PostSummary] = [] diff --git a/packages/ai-server/src/services/editorial_discovery/scheduler.py b/packages/ai-server/src/services/editorial_discovery/scheduler.py index d74b7b96..7476b7db 100644 --- a/packages/ai-server/src/services/editorial_discovery/scheduler.py +++ b/packages/ai-server/src/services/editorial_discovery/scheduler.py @@ -69,13 +69,13 @@ async def shutdown(self) -> None: @staticmethod def _interval_elapsed( - last_run_at: Optional[datetime], cycle_seconds: int + last_run_at: Optional[datetime], cycle_minutes: int ) -> bool: if last_run_at is None: return True return ( datetime.now(timezone.utc) - last_run_at - ).total_seconds() >= cycle_seconds + ).total_seconds() >= cycle_minutes * 60 async def _cycle(self) -> None: try: @@ -86,7 +86,7 @@ async def _cycle(self) -> None: if not settings.enabled: return - if not self._interval_elapsed(settings.last_run_at, settings.cycle_seconds): + if not self._interval_elapsed(settings.last_run_at, settings.cycle_minutes): return try: diff --git a/packages/ai-server/src/services/post_editorial/repository.py b/packages/ai-server/src/services/post_editorial/repository.py index ce29c977..9fda7342 100644 --- a/packages/ai-server/src/services/post_editorial/repository.py +++ b/packages/ai-server/src/services/post_editorial/repository.py @@ -29,7 +29,7 @@ class EditorialPipelineSettings: """`public.editorial_pipeline_settings` singleton row.""" enabled: bool - cycle_seconds: int + cycle_minutes: int min_spots: int min_solutions_per_spot: int last_run_at: Optional[datetime] @@ -62,7 +62,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: row = await conn.fetchrow( """ SELECT enabled, - cycle_seconds, + cycle_minutes, min_spots, min_solutions_per_spot, last_run_at, @@ -80,7 +80,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: ) return EditorialPipelineSettings( enabled=False, - cycle_seconds=60, + cycle_minutes=1, min_spots=4, min_solutions_per_spot=1, last_run_at=None, @@ -90,7 +90,7 @@ async def fetch_settings(self) -> EditorialPipelineSettings: ) return EditorialPipelineSettings( enabled=row["enabled"], - cycle_seconds=row["cycle_seconds"], + cycle_minutes=row["cycle_minutes"], min_spots=row["min_spots"], min_solutions_per_spot=row["min_solutions_per_spot"], last_run_at=row["last_run_at"], diff --git a/packages/ai-server/src/services/post_editorial/scheduler.py b/packages/ai-server/src/services/post_editorial/scheduler.py index 9394763c..1c6172d3 100644 --- a/packages/ai-server/src/services/post_editorial/scheduler.py +++ b/packages/ai-server/src/services/post_editorial/scheduler.py @@ -103,13 +103,13 @@ async def shutdown(self) -> None: @staticmethod def _interval_elapsed( - last_run_at: Optional[datetime], cycle_seconds: int + last_run_at: Optional[datetime], cycle_minutes: int ) -> bool: if last_run_at is None: return True return ( datetime.now(timezone.utc) - last_run_at - ).total_seconds() >= cycle_seconds + ).total_seconds() >= cycle_minutes * 60 async def _cycle(self) -> None: # Sweep 은 settings.enabled / cycle 과 무관하게 매 tick 실행. @@ -127,7 +127,7 @@ async def _cycle(self) -> None: if not settings.enabled: return - if not self._interval_elapsed(settings.last_run_at, settings.cycle_seconds): + if not self._interval_elapsed(settings.last_run_at, settings.cycle_minutes): return try: diff --git a/packages/api-server/src/domains/admin/editorial_articles.rs b/packages/api-server/src/domains/admin/editorial_articles.rs index 88d3f880..4e1d5d68 100644 --- a/packages/api-server/src/domains/admin/editorial_articles.rs +++ b/packages/api-server/src/domains/admin/editorial_articles.rs @@ -42,6 +42,7 @@ pub struct ArticleListItem { pub title: String, pub subtitle: Option, pub hero_image_url: Option, + pub thumbnail_url: Option, pub source_post_ids: Vec, pub status: String, pub published_at: Option, @@ -64,6 +65,7 @@ pub struct ArticleDetailResponse { pub title: String, pub subtitle: Option, pub hero_image_url: Option, + pub thumbnail_url: Option, pub layout_json: Option, pub source_post_ids: Vec, pub status: String, @@ -125,7 +127,7 @@ pub async fn list( let count_sql = format!("SELECT COUNT(*)::bigint AS c FROM public.editorial_articles {where_sql}"); let total: i64 = state - .db + .assets_db .query_one(Statement::from_sql_and_values( DatabaseBackend::Postgres, &count_sql, @@ -142,13 +144,13 @@ pub async fn list( "$1 OFFSET $2" }; let list_sql = format!( - "SELECT id, recommendation_id, title, subtitle, hero_image_url, source_post_ids, \ - status, published_at, created_at, updated_at \ + "SELECT id, recommendation_id, title, subtitle, hero_image_url, thumbnail_url, \ + source_post_ids, status, published_at, created_at, updated_at \ FROM public.editorial_articles {where_sql} \ ORDER BY created_at DESC LIMIT {limit_offset}" ); let rows = state - .db + .assets_db .query_all(Statement::from_sql_and_values( DatabaseBackend::Postgres, &list_sql, @@ -164,6 +166,7 @@ pub async fn list( let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; let subtitle: Option = row.try_get("", "subtitle").ok(); let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); let source_post_ids: Vec = row .try_get("", "source_post_ids") .map_err(AppError::DatabaseError)?; @@ -183,6 +186,7 @@ pub async fn list( title, subtitle, hero_image_url, + thumbnail_url, source_post_ids, status, published_at: published_at.map(|t| t.to_rfc3339()), @@ -207,15 +211,15 @@ pub async fn get_one( ) -> AppResult> { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT id, recommendation_id, title, subtitle, hero_image_url, layout_json, \ - source_post_ids, status, review_summary, error_log, rejection_reason, \ - approved_by, published_at, created_at, updated_at \ + "SELECT id, recommendation_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, status, review_summary, error_log, \ + rejection_reason, approved_by, published_at, created_at, updated_at \ FROM public.editorial_articles \ WHERE id = $1::uuid", vec![id.into()], ); let row = state - .db + .assets_db .query_one(stmt) .await .map_err(AppError::DatabaseError)? @@ -225,6 +229,7 @@ pub async fn get_one( let recommendation_id: Option = row.try_get("", "recommendation_id").ok(); let subtitle: Option = row.try_get("", "subtitle").ok(); let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); let layout_json: Option = row.try_get("", "layout_json").ok(); let source_post_ids: Vec = row .try_get("", "source_post_ids") @@ -251,6 +256,7 @@ pub async fn get_one( title, subtitle, hero_image_url, + thumbnail_url, layout_json, source_post_ids, status, @@ -280,10 +286,132 @@ pub async fn patch( )); } - apply_patch(state.db.as_ref(), id, target, &body, user.id).await?; + if target == "published" { + // #429: publish 는 assets staging → operation snapshot 복사 + assets status update. + publish_to_operation( + state.assets_db.as_ref(), + state.db.as_ref(), + id, + &body, + user.id, + ) + .await?; + return Ok(StatusCode::NO_CONTENT); + } + + apply_patch(state.assets_db.as_ref(), id, target, &body, user.id).await?; Ok(StatusCode::NO_CONTENT) } +/// publish — assets editorial_articles 의 article 을 operation editorial_articles +/// snapshot 으로 INSERT + assets status='published' UPDATE. +/// +/// 두 DB 라 분산 트랜잭션 X. 순서: +/// 1. assets 에서 article 로드 (status check 포함) +/// 2. operation 에 INSERT (실패 시 abort, assets 변경 없음) +/// 3. assets status='published' UPDATE (best-effort — 실패 시 operation 에는 이미 들어감, +/// log 로 inconsistency 추적) +async fn publish_to_operation( + assets_db: &DatabaseConnection, + operation_db: &DatabaseConnection, + id: Uuid, + body: &PatchArticleRequest, + actor: Uuid, +) -> AppResult<()> { + // 1) assets 에서 article 로드 + let load_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT title, subtitle, hero_image_url, thumbnail_url, layout_json, \ + source_post_ids, status \ + FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into()], + ); + let row = assets_db + .query_one(load_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::not_found(format!("editorial_article {id} not found")))?; + + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let layout_json: serde_json::Value = row + .try_get("", "layout_json") + .unwrap_or(serde_json::Value::Null); + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let status: String = row.try_get("", "status").map_err(AppError::DatabaseError)?; + + if status == "generating" || status == "failed" { + return Err(AppError::bad_request(format!( + "article status='{status}' — cannot publish (must be draft / in_review / rejected)", + ))); + } + + // 2) operation INSERT (id = assets id 그대로, source_article_id 도 동일 — audit) + let insert_op = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_articles \ + (id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at, published_by) \ + VALUES ($1::uuid, $1::uuid, $2, $3, $4, $5, $6::jsonb, $7::uuid[], NULL, now(), $8::uuid) \ + ON CONFLICT (id) DO UPDATE \ + SET title = EXCLUDED.title, \ + subtitle = EXCLUDED.subtitle, \ + hero_image_url = EXCLUDED.hero_image_url, \ + thumbnail_url = EXCLUDED.thumbnail_url, \ + layout_json = EXCLUDED.layout_json, \ + source_post_ids = EXCLUDED.source_post_ids, \ + published_at = EXCLUDED.published_at, \ + published_by = EXCLUDED.published_by, \ + updated_at = now()", + vec![ + id.into(), + title.into(), + subtitle.into(), + hero_image_url.into(), + thumbnail_url.into(), + layout_json.into(), + source_post_ids.into(), + actor.into(), + ], + ); + operation_db + .execute(insert_op) + .await + .map_err(AppError::DatabaseError)?; + + // 3) assets status='published' UPDATE (best-effort) + let upd_assets = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_articles \ + SET status = 'published', \ + approved_by = $2::uuid, \ + published_at = now(), \ + review_summary = COALESCE($3, review_summary), \ + updated_at = now() \ + WHERE id = $1::uuid", + vec![id.into(), actor.into(), body.review_summary.clone().into()], + ); + assets_db + .execute(upd_assets) + .await + .map_err(AppError::DatabaseError)?; + + // 4) event 기록 (assets) + let ev_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_events \ + (article_id, from_status, to_status, step, actor, note) \ + VALUES ($1::uuid, $2, 'published', 'publish', $3::uuid, 'snapshot to operation')", + vec![id.into(), status.into(), actor.into()], + ); + let _ = assets_db.execute(ev_stmt).await; + Ok(()) +} + async fn apply_patch( db: &DatabaseConnection, id: Uuid, @@ -291,17 +419,8 @@ async fn apply_patch( body: &PatchArticleRequest, actor: Uuid, ) -> AppResult<()> { + // 'published' 는 publish_to_operation 에서 처리 — 여기 안 옴. let (sql, values): (&str, Vec) = match target { - "published" => ( - "UPDATE public.editorial_articles \ - SET status = 'published', \ - approved_by = $2::uuid, \ - published_at = now(), \ - review_summary = COALESCE($3, review_summary), \ - updated_at = now() \ - WHERE id = $1::uuid", - vec![id.into(), actor.into(), body.review_summary.clone().into()], - ), "rejected" => ( "UPDATE public.editorial_articles \ SET status = 'rejected', \ @@ -359,7 +478,7 @@ pub async fn list_events( vec![id.into()], ); let rows = state - .db + .assets_db .query_all(stmt) .await .map_err(AppError::DatabaseError)?; @@ -396,10 +515,36 @@ pub async fn list_events( Ok(Json(events)) } +/// DELETE /api/v1/admin/editorial-articles/{id} — hard delete. +/// editorial_article_events / chat_sessions 는 FK CASCADE 로 같이 삭제, +/// recommendation 의 article_id 는 FK SET NULL. +pub async fn delete_one( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "DELETE FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into()], + ); + let res = state + .assets_db + .execute(stmt) + .await + .map_err(AppError::DatabaseError)?; + if res.rows_affected() == 0 { + return Err(AppError::not_found(format!( + "editorial_article {id} not found" + ))); + } + Ok(StatusCode::NO_CONTENT) +} + pub fn router(state: AppState, app_config: AppConfig) -> Router { Router::new() .route("/", get(list)) - .route("/{id}", get(get_one).patch(patch)) + .route("/{id}", get(get_one).patch(patch).delete(delete_one)) .route("/{id}/events", get(list_events)) .layer(axum::middleware::from_fn_with_state( state, diff --git a/packages/api-server/src/domains/admin/editorial_discovery_settings.rs b/packages/api-server/src/domains/admin/editorial_discovery_settings.rs index 995dec29..308c77d2 100644 --- a/packages/api-server/src/domains/admin/editorial_discovery_settings.rs +++ b/packages/api-server/src/domains/admin/editorial_discovery_settings.rs @@ -17,7 +17,7 @@ use crate::{ #[derive(Debug, Serialize)] pub struct EditorialDiscoverySettingsResponse { pub enabled: bool, - pub cycle_seconds: i32, + pub cycle_minutes: i32, pub lookback_days: i32, pub max_recommendations_per_run: i32, pub min_posts_per_angle: i32, @@ -30,7 +30,7 @@ pub struct EditorialDiscoverySettingsResponse { #[derive(Debug, Deserialize)] pub struct UpdateEditorialDiscoverySettingsRequest { pub enabled: Option, - pub cycle_seconds: Option, + pub cycle_minutes: Option, pub lookback_days: Option, pub max_recommendations_per_run: Option, pub min_posts_per_angle: Option, @@ -41,7 +41,7 @@ pub async fn get_settings( State(state): State, _user: axum::Extension, ) -> AppResult> { - let row = fetch_settings(state.db.as_ref()).await?; + let row = fetch_settings(state.assets_db.as_ref()).await?; Ok(Json(row)) } @@ -51,9 +51,9 @@ pub async fn update_settings( _user: axum::Extension, Json(body): Json, ) -> AppResult { - if let Some(c) = body.cycle_seconds { - if c < 1800 { - return Err(AppError::bad_request("cycle_seconds must be >= 1800")); + if let Some(c) = body.cycle_minutes { + if c < 1 { + return Err(AppError::bad_request("cycle_minutes must be >= 1")); } } if let Some(d) = body.lookback_days { @@ -74,14 +74,14 @@ pub async fn update_settings( } } - apply_update(state.db.as_ref(), &body).await?; + apply_update(state.assets_db.as_ref(), &body).await?; Ok(StatusCode::NO_CONTENT) } async fn fetch_settings(db: &DatabaseConnection) -> AppResult { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT enabled, cycle_seconds, lookback_days, max_recommendations_per_run, \ + "SELECT enabled, cycle_minutes, lookback_days, max_recommendations_per_run, \ min_posts_per_angle, last_run_at, last_success_at, last_error, last_error_at \ FROM public.editorial_discovery_settings \ WHERE id = 1", @@ -96,8 +96,8 @@ async fn fetch_settings(db: &DatabaseConnection) -> AppResult AppResult, @@ -30,7 +30,7 @@ pub struct EditorialPipelineSettingsResponse { #[derive(Debug, Deserialize)] pub struct UpdateEditorialPipelineSettingsRequest { pub enabled: Option, - pub cycle_seconds: Option, + pub cycle_minutes: Option, pub min_spots: Option, pub min_solutions_per_spot: Option, } @@ -50,9 +50,9 @@ pub async fn update_settings( _user: axum::Extension, Json(body): Json, ) -> AppResult { - if let Some(c) = body.cycle_seconds { - if c < 30 { - return Err(AppError::bad_request("cycle_seconds must be >= 30")); + if let Some(c) = body.cycle_minutes { + if c < 1 { + return Err(AppError::bad_request("cycle_minutes must be >= 1")); } } if let Some(s) = body.min_spots { @@ -73,7 +73,7 @@ pub async fn update_settings( async fn fetch_settings(db: &DatabaseConnection) -> AppResult { let stmt = Statement::from_sql_and_values( DatabaseBackend::Postgres, - "SELECT enabled, cycle_seconds, min_spots, min_solutions_per_spot, \ + "SELECT enabled, cycle_minutes, min_spots, min_solutions_per_spot, \ last_run_at, last_success_at, last_error, last_error_at \ FROM public.editorial_pipeline_settings \ WHERE id = 1", @@ -88,8 +88,8 @@ async fn fetch_settings(db: &DatabaseConnection) -> AppResult AppResult AppResult<()> { + // approved 는 article INSERT 까지 같은 트랜잭션 — UI 가 즉시 Drafts 탭에서 + // 'generating' 상태를 볼 수 있게. ArticlePickupScheduler 는 enqueue 만 담당. + if target_status == "approved" { + return approve_in_tx(db, id, actor).await; + } + let sql = match target_status { - "approved" => { - "UPDATE public.editorial_recommendations \ - SET status = 'approved', \ - approved_by = $2::uuid, \ - approved_at = now(), \ - updated_at = now() \ - WHERE id = $1::uuid \ - AND status IN ('pending')" - } "rejected" => { "UPDATE public.editorial_recommendations \ SET status = 'rejected', \ @@ -235,7 +232,6 @@ async fn apply_patch( }; let values: Vec = match target_status { - "approved" => vec![id.into(), actor.into()], "rejected" => vec![ id.into(), actor.into(), @@ -249,10 +245,113 @@ async fn apply_patch( Ok(()) } +/// approve + INSERT editorial_articles + 연결 + event — 단일 트랜잭션. +async fn approve_in_tx(db: &DatabaseConnection, id: Uuid, actor: Uuid) -> AppResult<()> { + let txn = db.begin().await.map_err(AppError::DatabaseError)?; + + // 1) recommendation 업데이트 (status pending → approved). status 가 pending + // 아니면 0 row affected — 멱등. + let upd_rec = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_recommendations \ + SET status = 'approved', \ + approved_by = $2::uuid, \ + approved_at = now(), \ + updated_at = now() \ + WHERE id = $1::uuid \ + AND status = 'pending' \ + RETURNING source_post_ids, angle_title", + vec![id.into(), actor.into()], + ); + let row = txn + .query_one(upd_rec) + .await + .map_err(AppError::DatabaseError)?; + let Some(row) = row else { + // 이미 approved/rejected/drafted — no-op (멱등) + txn.commit().await.map_err(AppError::DatabaseError)?; + return Ok(()); + }; + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let angle_title: String = row + .try_get("", "angle_title") + .map_err(AppError::DatabaseError)?; + + // 2) editorial_articles INSERT (status='generating', source_post_ids 복사, + // title 임시 = angle_title — 나중에 compose_layout 이 덮어씀) + let ins_art = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_articles \ + (recommendation_id, title, source_post_ids, status) \ + VALUES ($1::uuid, $2, $3::uuid[], 'generating') \ + RETURNING id", + vec![id.into(), angle_title.into(), source_post_ids.into()], + ); + let art_row = txn + .query_one(ins_art) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("editorial_articles INSERT returned no row"))?; + let article_id: Uuid = art_row.try_get("", "id").map_err(AppError::DatabaseError)?; + + // 3) recommendation 의 article_id 연결 + let link_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.editorial_recommendations \ + SET article_id = $1::uuid, updated_at = now() \ + WHERE id = $2::uuid", + vec![article_id.into(), id.into()], + ); + txn.execute(link_stmt) + .await + .map_err(AppError::DatabaseError)?; + + // 4) event 기록 (admin approve → article 생성) + let ev_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.editorial_article_events \ + (article_id, from_status, to_status, step, actor, note) \ + VALUES ($1::uuid, NULL, 'generating', 'created', $2::uuid, 'admin approved')", + vec![article_id.into(), actor.into()], + ); + txn.execute(ev_stmt) + .await + .map_err(AppError::DatabaseError)?; + + txn.commit().await.map_err(AppError::DatabaseError)?; + Ok(()) +} + +/// DELETE /api/v1/admin/editorial-recommendations/{id} — hard delete (테스트 garbage / 잘못 만든 row 정리용). +pub async fn delete_one( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "DELETE FROM public.editorial_recommendations WHERE id = $1::uuid", + vec![id.into()], + ); + let res = state + .assets_db + .execute(stmt) + .await + .map_err(AppError::DatabaseError)?; + if res.rows_affected() == 0 { + return Err(AppError::not_found(format!( + "editorial_recommendation {id} not found" + ))); + } + Ok(StatusCode::NO_CONTENT) +} + pub fn router(state: AppState, app_config: AppConfig) -> Router { Router::new() .route("/", get(list)) - .route("/{id}", axum::routing::patch(patch)) + .route("/{id}", axum::routing::patch(patch).delete(delete_one)) .layer(axum::middleware::from_fn_with_state( state, crate::middleware::admin_db_middleware, diff --git a/packages/api-server/src/domains/editorial_articles_published/handlers.rs b/packages/api-server/src/domains/editorial_articles_published/handlers.rs new file mode 100644 index 00000000..2949dde3 --- /dev/null +++ b/packages/api-server/src/domains/editorial_articles_published/handlers.rs @@ -0,0 +1,203 @@ +//! Public editorial-articles handlers — operation editorial_articles read. +//! +//! GET /api/v1/editorial-articles?page&per_page — 발행 매거진 목록 (최신순) +//! GET /api/v1/editorial-articles/{id_or_slug} — 매거진 상세 (layout_json 포함) +//! +//! 인증 없음 (공개). RLS 의 'public_can_select_editorial_articles' 가 보장. + +use axum::{ + extract::{Path, Query, State}, + routing::get, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::AppState, + error::{AppError, AppResult}, +}; + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_per_page")] + pub per_page: u64, +} + +fn default_page() -> u64 { + 1 +} +fn default_per_page() -> u64 { + 20 +} + +#[derive(Debug, Serialize)] +pub struct PublishedArticleListItem { + pub id: Uuid, + pub title: String, + pub subtitle: Option, + pub hero_image_url: Option, + pub thumbnail_url: Option, + pub slug: Option, + pub published_at: String, +} + +#[derive(Debug, Serialize)] +pub struct ListResponse { + pub items: Vec, + pub total: i64, + pub page: u64, + pub per_page: u64, +} + +#[derive(Debug, Serialize)] +pub struct PublishedArticleDetail { + pub id: Uuid, + pub source_article_id: Uuid, + pub title: String, + pub subtitle: Option, + pub hero_image_url: Option, + pub thumbnail_url: Option, + pub layout_json: serde_json::Value, + pub source_post_ids: Vec, + pub slug: Option, + pub published_at: String, +} + +pub async fn list( + State(state): State, + Query(q): Query, +) -> AppResult> { + let page = q.page.max(1); + let per_page = q.per_page.clamp(1, 100); + let offset = (page - 1) * per_page; + + let total: i64 = state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::bigint AS c FROM public.editorial_articles", + Vec::::new(), + )) + .await + .map_err(AppError::DatabaseError)? + .map(|r| r.try_get::("", "c").unwrap_or(0)) + .unwrap_or(0); + + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, title, subtitle, hero_image_url, thumbnail_url, slug, published_at \ + FROM public.editorial_articles \ + ORDER BY published_at DESC \ + LIMIT $1 OFFSET $2", + vec![(per_page as i64).into(), (offset as i64).into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let slug: Option = row.try_get("", "slug").ok(); + let published_at: chrono::DateTime = + row.try_get("", "published_at") + .map_err(AppError::DatabaseError)?; + + items.push(PublishedArticleListItem { + id, + title, + subtitle, + hero_image_url, + thumbnail_url, + slug, + published_at: published_at.to_rfc3339(), + }); + } + + Ok(Json(ListResponse { + items, + total, + page, + per_page, + })) +} + +pub async fn get_one( + State(state): State, + Path(id_or_slug): Path, +) -> AppResult> { + // UUID 파싱 시도 → 실패면 slug 로 조회 + let row = if let Ok(uuid) = Uuid::parse_str(&id_or_slug) { + state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at \ + FROM public.editorial_articles WHERE id = $1::uuid", + vec![uuid.into()], + )) + .await + .map_err(AppError::DatabaseError)? + } else { + state + .db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, source_article_id, title, subtitle, hero_image_url, thumbnail_url, \ + layout_json, source_post_ids, slug, published_at \ + FROM public.editorial_articles WHERE slug = $1", + vec![id_or_slug.clone().into()], + )) + .await + .map_err(AppError::DatabaseError)? + } + .ok_or_else(|| AppError::not_found(format!("editorial_article '{id_or_slug}' not found")))?; + + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let source_article_id: Uuid = row + .try_get("", "source_article_id") + .map_err(AppError::DatabaseError)?; + let title: String = row.try_get("", "title").map_err(AppError::DatabaseError)?; + let subtitle: Option = row.try_get("", "subtitle").ok(); + let hero_image_url: Option = row.try_get("", "hero_image_url").ok(); + let thumbnail_url: Option = row.try_get("", "thumbnail_url").ok(); + let layout_json: serde_json::Value = row + .try_get("", "layout_json") + .unwrap_or(serde_json::Value::Null); + let source_post_ids: Vec = row + .try_get("", "source_post_ids") + .map_err(AppError::DatabaseError)?; + let slug: Option = row.try_get("", "slug").ok(); + let published_at: chrono::DateTime = row + .try_get("", "published_at") + .map_err(AppError::DatabaseError)?; + + Ok(Json(PublishedArticleDetail { + id, + source_article_id, + title, + subtitle, + hero_image_url, + thumbnail_url, + layout_json, + source_post_ids, + slug, + published_at: published_at.to_rfc3339(), + })) +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list)) + .route("/{id_or_slug}", get(get_one)) +} diff --git a/packages/api-server/src/domains/editorial_articles_published/mod.rs b/packages/api-server/src/domains/editorial_articles_published/mod.rs new file mode 100644 index 00000000..ca7a0432 --- /dev/null +++ b/packages/api-server/src/domains/editorial_articles_published/mod.rs @@ -0,0 +1,8 @@ +//! Public read endpoints for published editorial magazines (#429). +//! +//! operation 의 editorial_articles 테이블 — assets staging 에서 publish 시 +//! snapshot 복사된 운영 매거진. 공개 reader (web magazine page) 가 호출. + +pub mod handlers; + +pub use handlers::router; diff --git a/packages/api-server/src/domains/mod.rs b/packages/api-server/src/domains/mod.rs index 00bc38fb..5bed8955 100644 --- a/packages/api-server/src/domains/mod.rs +++ b/packages/api-server/src/domains/mod.rs @@ -7,6 +7,7 @@ pub mod badges; pub mod categories; pub mod comments; pub mod earnings; +pub mod editorial_articles_published; pub mod events; pub mod feed; pub mod post_likes; diff --git a/packages/api-server/src/router.rs b/packages/api-server/src/router.rs index 8d74181d..74125123 100644 --- a/packages/api-server/src/router.rs +++ b/packages/api-server/src/router.rs @@ -34,6 +34,10 @@ pub fn build_api_router(state: AppState) -> Router { ) // Config가 필요 없는 라우터들 .nest("/post-magazines", domains::post_magazines::router()) + .nest( + "/editorial-articles", + domains::editorial_articles_published::router(), + ) .nest("/categories", domains::categories::router()) .nest("/subcategories", domains::subcategories::handlers::router()) .nest("/warehouse", domains::warehouse::router()) diff --git a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx index 631a40e2..e37b6509 100644 --- a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx +++ b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/hooks/admin/useEditorialArticles"; import { MagazineRenderer } from "@/lib/components/admin/editorial/magazine/MagazineRenderer"; import { ArticleActions } from "@/lib/components/admin/editorial/magazine/ArticleActions"; +import { ChatPanel } from "@/lib/components/admin/editorial/magazine/ChatPanel"; interface PageProps { params: Promise<{ id: string }>; @@ -90,6 +91,8 @@ function ArticleDetailContent({ id }: { id: string }) {