feat(editorial): Stage 3 chat + assets 격리 + publish snapshot + thumbnail + commercial#446
Merged
Conversation
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
컨텐츠 매니저가 채팅으로 매거진 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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
운영자가 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
이전 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
테스트 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
이전: 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
매거진 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).
#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
이전 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
매거진 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
매거진 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
매거진의 각 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
이전: flex + flex-1 Shop → try-on 이 카드 너비 초과 시 잘림. 변경: grid grid-cols-[1fr_auto] — Shop 가변 (남은 공간 + truncate), try-on icon shrink-0 으로 항상 자기 너비. 카드 너비 무관 둘 다 보임. #429
기존 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
5 tasks
cocoyoon
added a commit
that referenced
this pull request
May 6, 2026
…#450) * fix(db): editorial_articles publish migration drop staging first #446 의 020001 (publish editorial_articles 생성) 이 020002 (drop staging) 보다 먼저 돌면서 staging 의 editorial_articles 가 살아있는 채로 publish 의 slug 컬럼 인덱스 만들려다 SQLSTATE 42703 (column does not exist) 로 실패. operation cloud 배포 시 발견. DROP TABLE IF EXISTS public.editorial_articles CASCADE 를 020001 시작 부분에 추가해서 idempotent 하게. 020002 의 동등 로직과 안전 중복. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(editorial-chat): web→api-server only, ai-server gRPC compute (#446 fixup) PR #446 의 Stage 3 chat 이 web → ai-server (HTTP) 직접 호출로 구현돼서 모노레포 컨벤션 위반. ai-server 는 internal compute, web 은 항상 api-server 만 호출. Prod 배포 전에 같은 스코프 (#446) 안에서 fixup. 책임 분리: - api-server: chat session/message CRUD (assets DB), layout persist, admin 인증 게이팅 — 새 도메인 `domains/admin/editorial_article_chat`. - ai-server: 한 턴 LLM 실행 (Gemini + tool loop) — pure compute, gRPC `inbound.Queue/RunChatTurn`. DB 안 만짐. - web: 기존 admin proxy 패턴 (`API_BASE_URL` + Bearer token) 으로 통일. ai-server 정리: - editorial_article_chat/api.py 삭제 (HTTP router) - editorial_article_chat/repository.py 삭제 (DB layer) - agent.run_turn 시그니처 변경: (article_title, layout, history, user_text) → RunTurnResult (events list + final_layout + tool_calls_made + ...) - ToolExecutor 생성자에서 db 제거, persist_layout 삭제 - bootstrap.py 의 chat router include 제거 - MetadataServicer.RunChatTurn 신규: layout/history JSON 파싱 → run_turn → events_json + final_layout_json 직렬화 api-server 신규: - entities/assets_editorial_article_chat_{sessions,messages}.rs - domains/admin/editorial_article_chat.rs (4 handlers) - services/decoded_ai_grpc/client.rs::run_chat_turn (deadline 120s) - send_message 오케스트레이션: user msg INSERT → load article+history → gRPC RunChatTurn → events 순서대로 INSERT → layout UPDATE if changed → session.last_message_at touch web: - 2 chat route 가 AI_SERVER_HTTP_URL → API_BASE_URL + Bearer 로 교체 - server-env.ts 에서 AI_SERVER_HTTP_URL export 삭제 Streaming 은 out of scope (이번 PR 은 unary, send 클릭 후 ~30s 대기. 후속 streaming 은 #448 의 "대화형 강화"). prod ai.decoded.style 외부 노출 차단은 별도 SRE 작업 (이 PR 변경 후 web 이 더 이상 호출하지 않음). Local 검증: - cargo build OK - tsc --noEmit OK (chat 파일 clean) - gRPC RunChatTurn E2E: layout 'Test 매거진' → 'K-pop 가을 룩' 변경 확인, events_json sequence (assistant tool_call → tool result → assistant text) + layout_changed=True + final_layout 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
15 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
이슈 #429 의 Stage 3 + 운영 model 정리 + 산출물 품질 묶음 PR.
작업 묶음
1. Stage 3 — 매거진 대화형 편집 agent
editorial_article_chat_sessions/editorial_article_chat_messages테이블editorial_article_chat/모듈 — google-genai function calling agentremove_section / regenerate_section_body / get_current_layout
ChatPanel.tsx+useEditorialChat.ts(TanStack Query)2. Assets 격리 + Operation publish snapshot (architectural)
editorial_articles(publish 전용 snapshot, status 컬럼 없음)Publish클릭 시 assets → operation snapshot 복사 + assets status='published' UPDATE.GET /api/v1/editorial-articles?page//{id_or_slug}(operation, RLS 공개 SELECT)3. UX 개선
21600초→360분)4. 산출물 품질
--mag-accent) 가 같은 호출에서 처리.5. Commercial features
MagazineItemsSection.tsx의 Compact + Full view 두 곳 모두 try-on 버튼 추가. 기존 1 post → 1 magazine 매거진들도 try-on 가능.DB Migrations
supabase-assets/migrations/20260505020000_editorial_staging.sql(5 staging tables + RLS)supabase/migrations/20260505000000_editorial_article_chat.sql(chat sessions/messages — assets 이동 전 작성, 이후 operation drop 마이그에 포함)supabase/migrations/20260505010000_editorial_cycle_minutes.sql(cycle 컬럼 rename)supabase/migrations/20260505020001_editorial_articles_published.sql(operation publish snapshot)supabase/migrations/20260505020002_drop_editorial_staging.sql(operation staging cleanup)Prod 적용 단계 (PR 머지 후 별도 작업)
cd supabase-assets && supabase db push— assets 신규 5 테이블supabase db push(operation) — publish snapshot + staging drop. prod staging 테이블 데이터 백업 필수 (별도 dump). 로컬 dev 무관.vercel env add OPENAI_API_KEY(이미 있으면 skip) — gpt-image-2 thumbnailvercel env add AI_SERVER_HTTP_URL— chat router 접근 (web → ai-server FastAPI)Out of scope (후속 이슈로 분리)
/magazine/[slug](backend endpoint 만 있음)검증 (로컬 풀 사이클)
#429