diff --git a/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py index 4dd3b54d..744c1849 100644 --- a/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py +++ b/packages/ai-server/src/editorial_article/nodes/generate_thumbnail.py @@ -33,8 +33,21 @@ logger = logging.getLogger(__name__) -def _build_prompt(title: str, subtitle: Optional[str]) -> str: # noqa: ARG001 - return f"""Transform this photograph into an English fashion magazine Instagram-style thumbnail +def _build_prompt( + title: str, subtitle: Optional[str], hint: Optional[str] = None +) -> str: # noqa: ARG001 + hint_block = "" + if hint and hint.strip(): + # ADDITIONAL DIRECTION 블록은 reviewer 가 admin UI 에서 넘긴 hint — + # 기본 스타일보다 우선 적용해야 한다. 패턴 출처: + # editorial_article_chat/tools.py:_build_regenerate_prompt. + hint_block = ( + "ADDITIONAL DIRECTION FROM REVIEWER " + "(highest priority — apply over default style):\n" + f"{hint.strip()}\n\n" + ) + + return f"""{hint_block}Transform this photograph into an English fashion magazine Instagram-style thumbnail (2:3 portrait, 1024x1536). KEEP the subject (person, pose, outfit) recognizable from the source photo. @@ -118,7 +131,10 @@ async def generate_thumbnail_node(state: dict, config: RunnableConfig) -> dict: logger.warning("generate_thumbnail: source download failed (%s)", exc) return {} - prompt = _build_prompt(layout.title or "", layout.subtitle) + # regen_hint 는 admin 의 thumbnail 재생성 경로에서만 state 로 전달됨. + # 기본 파이프라인 호출에는 키 없음 → None → 기존 동작 그대로. + regen_hint: Optional[str] = state.get("regen_hint") + prompt = _build_prompt(layout.title or "", layout.subtitle, regen_hint) try: # 1024x1536 (2:3 portrait) — center crop 제거 (#429): crop 이 watermark @@ -148,7 +164,5 @@ async def generate_thumbnail_node(state: dict, config: RunnableConfig) -> dict: return {} new_layout = layout.model_copy(update={"thumbnail_url": result.url}) - logger.info( - "generate_thumbnail: ok article=%s url=%s", article_id, 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 ac2ddfed..ddad2d5d 100644 --- a/packages/ai-server/src/editorial_article/nodes/publish.py +++ b/packages/ai-server/src/editorial_article/nodes/publish.py @@ -21,11 +21,23 @@ async def _persist( article_id: str, recommendation_id: str, layout: MagazineLayout, + error_log: list[str] | None = None, ) -> None: layout_json = layout.model_dump(mode="json") + # 노드 실행 중 누적된 graph state 의 error_log 가 있으면 DB 의 error_log 컬럼에 + # append. status 는 그대로 'draft' 유지 (soft-fail 정책 — thumbnail 같은 비치명 + # 노드 실패가 발생해도 draft 자체는 완성된 상태로 노출). admin UI 가 이 컬럼을 + # 읽어 빨간 배너로 표시. + error_payload = ( + [{"step": "graph", "error": str(e)} for e in (error_log or []) if e] + if error_log + else [] + ) + async with db.acquire() as conn: async with conn.transaction(): # 1) editorial_articles: status=draft, layout_json + title/subtitle/hero/thumb + # + (있으면) error_log append prev_row = await conn.fetchrow( """ UPDATE public.editorial_articles @@ -35,6 +47,10 @@ async def _persist( thumbnail_url = $5, layout_json = $6::jsonb, status = 'draft', + error_log = CASE + WHEN $7::jsonb = '[]'::jsonb THEN error_log + ELSE COALESCE(error_log, '[]'::jsonb) || $7::jsonb + END, updated_at = now() WHERE id = $1::uuid RETURNING id @@ -45,6 +61,7 @@ async def _persist( layout.hero_image_url, layout.thumbnail_url, json.dumps(layout_json), + json.dumps(error_payload), ) if prev_row is None: raise ValueError(f"editorial_articles {article_id} not found") @@ -75,8 +92,8 @@ 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( - "assets_database_manager" + db: DatabaseManager | None = ( + (config or {}).get("configurable", {}).get("assets_database_manager") ) if db is None: return { @@ -97,6 +114,7 @@ async def publish_node(state: dict, config: RunnableConfig) -> dict: article_id=state["article_id"], recommendation_id=state["recommendation_id"], layout=layout, + error_log=state.get("error_log") or [], ) except Exception as exc: logger.exception("publish failed") diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound.proto b/packages/ai-server/src/grpc/proto/inbound/inbound.proto index a4973071..0d9cc941 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound.proto +++ b/packages/ai-server/src/grpc/proto/inbound/inbound.proto @@ -46,6 +46,10 @@ service Queue { // 을 대체 — admin 이 verify 시점에 인물/맥락을 확정한 뒤 생성하므로 인물 오인식 // 으로 인한 title 오염을 막는다. Gemini flash-lite (cheap) + 결정적 fallback. rpc ComposeTitle (ComposeTitleRequest) returns (ComposeTitleResponse); + + // Admin: editorial_articles 의 thumbnail 만 재생성 (본문 보존). hint 는 매니저가 + // "더 어둡게" / "워드마크 색 바꿔줘" 같은 방향성 지시를 넘길 수 있는 선택 필드. + rpc RegenThumbnail (RegenThumbnailRequest) returns (RegenThumbnailResponse); } // #214 RawPostsWorker service removed — ai-server schedules itself. @@ -312,6 +316,22 @@ message ComposeTitleResponse { string error_message = 2; } +// Admin: thumbnail 단독 재생성 (editorial_articles). +// +// hint 는 선택 — 빈 문자열이면 기본 프롬프트로 생성, 그렇지 않으면 +// generate_thumbnail 노드의 _build_prompt 가 hint 를 추가 지시로 주입. +message RegenThumbnailRequest { + string article_id = 1; + string hint = 2; +} + +message RegenThumbnailResponse { + bool success = 1; + string message = 2; + // ARQ job id (즉시 enqueue 후 비동기 처리). 빈 문자열이면 enqueue 실패. + string batch_id = 3; +} + message SearchSolutionUrlResponse { // best URL the filter approved — empty when rejected / no high-confidence match. string best_url = 1; diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py index 90c62850..ccefc2b4 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rinbound.proto\x12\x07inbound\"O\n\x1bProcessPostEditorialRequest\x12\x18\n\x10post_magazine_id\x18\x01 \x01(\t\x12\x16\n\x0epost_data_json\x18\x02 \x01(\t\"R\n\x1cProcessPostEditorialResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"X\n\x08\x44\x61taItem\x12\x0f\n\x07item_id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x14\n\x07post_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x42\n\n\x08_post_id\";\n\x17ProcessDataBatchRequest\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.inbound.DataItem\"N\n\x18ProcessDataBatchResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"#\n\x14\x45xtractOGDataRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\"\xf0\x01\n\x0cLinkMetadata\x12\x12\n\x05price\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08\x63urrency\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05\x62rand\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x10\n\x08material\x18\x04 \x03(\t\x12\x13\n\x06origin\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08\x63\x61tegory\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0csub_category\x18\x07 \x01(\tH\x05\x88\x01\x01\x42\x08\n\x06_priceB\x0b\n\t_currencyB\x08\n\x06_brandB\t\n\x07_originB\x0b\n\t_categoryB\x0f\n\r_sub_category\"\x92\x01\n\x15\x45xtractOGDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05image\x18\x05 \x01(\t\x12\x11\n\tsite_name\x18\x06 \x01(\t\x12\x15\n\rerror_message\x18\x07 \x01(\t\"i\n\x12\x41nalyzeLinkRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0f\n\x07post_id\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x11\n\tsite_name\x18\x05 \x01(\t\"I\n\x13\x41nalyzeLinkResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"*\n\x06QAPair\x12\x10\n\x08question\x18\x01 \x01(\t\x12\x0e\n\x06\x61nswer\x18\x02 \x01(\t\"\x8c\x01\n\x0fProductMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\r\n\x05\x62rand\x18\x03 \x01(\t\x12\r\n\x05price\x18\x04 \x01(\t\x12\x10\n\x08\x63urrency\x18\x05 \x01(\t\x12\x11\n\tmaterials\x18\x06 \x03(\t\x12\x0e\n\x06origin\x18\x07 \x01(\t\"\x87\x01\n\x0f\x41rticleMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x16\n\x0epublished_date\x18\x04 \x01(\t\x12\x14\n\x0creading_time\x18\x05 \x01(\t\x12\x0e\n\x06topics\x18\x06 \x03(\t\"\x83\x01\n\rVideoMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x03 \x01(\t\x12\x10\n\x08\x64uration\x18\x04 \x01(\t\x12\x12\n\nview_count\x18\x05 \x01(\t\x12\x13\n\x0bupload_date\x18\x06 \x01(\t\"M\n\rOtherMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"\xf1\x02\n\x19\x41nalyzeLinkDirectResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07summary\x18\x03 \x01(\t\x12\x10\n\x08keywords\x18\x04 \x03(\t\x12\x1c\n\x03qna\x18\x05 \x03(\x0b\x32\x0f.inbound.QAPair\x12\x34\n\x10product_metadata\x18\x06 \x01(\x0b\x32\x18.inbound.ProductMetadataH\x00\x12\x34\n\x10\x61rticle_metadata\x18\x07 \x01(\x0b\x32\x18.inbound.ArticleMetadataH\x00\x12\x30\n\x0evideo_metadata\x18\x08 \x01(\x0b\x32\x16.inbound.VideoMetadataH\x00\x12\x30\n\x0eother_metadata\x18\t \x01(\x0b\x32\x16.inbound.OtherMetadataH\x00\x12\x15\n\rerror_message\x18\n \x01(\tB\n\n\x08metadata\"i\n\x13\x41nalyzeImageRequest\x12\x12\n\nimage_data\x18\x01 \x01(\t\x12\x0f\n\x07item_id\x18\x02 \x01(\t\x12-\n\x0e\x63\x61tegory_rules\x18\x03 \x03(\x0b\x32\x15.inbound.CategoryRule\"8\n\x0c\x43\x61tegoryRule\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x16\n\x0esub_categories\x18\x02 \x03(\t\"o\n\x13ItemWithCoordinates\x12\x14\n\x0csub_category\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x10\n\x03top\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x11\n\x04left\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x06\n\x04_topB\x07\n\x05_left\"7\n\x08ItemList\x12+\n\x05items\x18\x01 \x03(\x0b\x32\x1c.inbound.ItemWithCoordinates\"\xcc\x02\n\x14\x41nalyzeImageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07subject\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x18\n\x0b\x61rtist_name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ngroup_name\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07\x63ontext\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x37\n\x05items\x18\x07 \x03(\x0b\x32(.inbound.AnalyzeImageResponse.ItemsEntry\x12\x15\n\rerror_message\x18\x08 \x01(\t\x1a?\n\nItemsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.inbound.ItemList:\x02\x38\x01\x42\x0e\n\x0c_artist_nameB\r\n\x0b_group_nameB\n\n\x08_context\"?\n\x19\x45xtractPostContextRequest\x12\x0f\n\x07post_id\x18\x01 \x01(\t\x12\x11\n\timage_url\x18\x02 \x01(\t\"\x88\x01\n\x1a\x45xtractPostContextResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontext\x18\x02 \x01(\t\x12\x12\n\nstyle_tags\x18\x03 \x03(\t\x12\x0c\n\x04mood\x18\x04 \x01(\t\x12\x0f\n\x07setting\x18\x05 \x01(\t\x12\x15\n\rerror_message\x18\x06 \x01(\t\")\n\x14TriggerSourceRequest\x12\x11\n\tsource_id\x18\x01 \x01(\t\"@\n\x15TriggerSourceResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"p\n\x15ReparseRawPostRequest\x12\x13\n\x0braw_post_id\x18\x01 \x01(\t\x12%\n\x18hero_reframe_prompt_hint\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x1b\n\x19_hero_reframe_prompt_hint\"A\n\x16ReparseRawPostResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"i\n\x12RunChatTurnRequest\x12\x12\n\narticle_id\x18\x01 \x01(\t\x12\x13\n\x0blayout_json\x18\x02 \x01(\t\x12\x14\n\x0chistory_json\x18\x03 \x01(\t\x12\x14\n\x0cuser_message\x18\x04 \x01(\t\"\xa1\x01\n\x13RunChatTurnResponse\x12\x13\n\x0b\x65vents_json\x18\x01 \x01(\t\x12\x19\n\x11\x66inal_layout_json\x18\x02 \x01(\t\x12\x12\n\nfinal_text\x18\x03 \x01(\t\x12\x17\n\x0ftool_calls_made\x18\x04 \x01(\x05\x12\x16\n\x0elayout_changed\x18\x05 \x01(\x08\x12\x15\n\rerror_message\x18\x06 \x01(\t\"O\n\x18SearchSolutionUrlRequest\x12\r\n\x05title\x18\x01 \x01(\t\x12\r\n\x05\x62rand\x18\x02 \x01(\t\x12\x15\n\rthumbnail_url\x18\x03 \x01(\t\"9\n\x1aSearchSolutionUrlCandidate\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\"c\n\x13\x43omposeTitleRequest\x12\x13\n\x0b\x61rtist_name\x18\x01 \x01(\t\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontext\x18\x03 \x01(\t\x12\x12\n\nstyle_tags\x18\x04 \x03(\t\"<\n\x14\x43omposeTitleResponse\x12\r\n\x05title\x18\x01 \x01(\t\x12\x15\n\rerror_message\x18\x02 \x01(\t\"\xe5\x01\n\x19SearchSolutionUrlResponse\x12\x10\n\x08\x62\x65st_url\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\t\x12\x14\n\x0c\x64omain_class\x18\x03 \x01(\t\x12\x10\n\x08rejected\x18\x04 \x01(\x08\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x37\n\ncandidates\x18\x06 \x03(\x0b\x32#.inbound.SearchSolutionUrlCandidate\x12\x1a\n\x12\x64\x61ta_quality_issue\x18\x07 \x01(\t\x12\x15\n\rerror_message\x18\x08 \x01(\t2\xf7\x07\n\x05Queue\x12W\n\x10ProcessDataBatch\x12 .inbound.ProcessDataBatchRequest\x1a!.inbound.ProcessDataBatchResponse\x12N\n\rExtractOGData\x12\x1d.inbound.ExtractOGDataRequest\x1a\x1e.inbound.ExtractOGDataResponse\x12H\n\x0b\x41nalyzeLink\x12\x1b.inbound.AnalyzeLinkRequest\x1a\x1c.inbound.AnalyzeLinkResponse\x12T\n\x11\x41nalyzeLinkDirect\x12\x1b.inbound.AnalyzeLinkRequest\x1a\".inbound.AnalyzeLinkDirectResponse\x12K\n\x0c\x41nalyzeImage\x12\x1c.inbound.AnalyzeImageRequest\x1a\x1d.inbound.AnalyzeImageResponse\x12\x63\n\x14ProcessPostEditorial\x12$.inbound.ProcessPostEditorialRequest\x1a%.inbound.ProcessPostEditorialResponse\x12]\n\x12\x45xtractPostContext\x12\".inbound.ExtractPostContextRequest\x1a#.inbound.ExtractPostContextResponse\x12N\n\rTriggerSource\x12\x1d.inbound.TriggerSourceRequest\x1a\x1e.inbound.TriggerSourceResponse\x12Q\n\x0eReparseRawPost\x12\x1e.inbound.ReparseRawPostRequest\x1a\x1f.inbound.ReparseRawPostResponse\x12H\n\x0bRunChatTurn\x12\x1b.inbound.RunChatTurnRequest\x1a\x1c.inbound.RunChatTurnResponse\x12Z\n\x11SearchSolutionUrl\x12!.inbound.SearchSolutionUrlRequest\x1a\".inbound.SearchSolutionUrlResponse\x12K\n\x0c\x43omposeTitle\x12\x1c.inbound.ComposeTitleRequest\x1a\x1d.inbound.ComposeTitleResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rinbound.proto\x12\x07inbound\"O\n\x1bProcessPostEditorialRequest\x12\x18\n\x10post_magazine_id\x18\x01 \x01(\t\x12\x16\n\x0epost_data_json\x18\x02 \x01(\t\"R\n\x1cProcessPostEditorialResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"X\n\x08\x44\x61taItem\x12\x0f\n\x07item_id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x14\n\x07post_id\x18\x04 \x01(\tH\x00\x88\x01\x01\x42\n\n\x08_post_id\";\n\x17ProcessDataBatchRequest\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.inbound.DataItem\"N\n\x18ProcessDataBatchResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"#\n\x14\x45xtractOGDataRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\"\xf0\x01\n\x0cLinkMetadata\x12\x12\n\x05price\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08\x63urrency\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05\x62rand\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x10\n\x08material\x18\x04 \x03(\t\x12\x13\n\x06origin\x18\x05 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08\x63\x61tegory\x18\x06 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0csub_category\x18\x07 \x01(\tH\x05\x88\x01\x01\x42\x08\n\x06_priceB\x0b\n\t_currencyB\x08\n\x06_brandB\t\n\x07_originB\x0b\n\t_categoryB\x0f\n\r_sub_category\"\x92\x01\n\x15\x45xtractOGDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05image\x18\x05 \x01(\t\x12\x11\n\tsite_name\x18\x06 \x01(\t\x12\x15\n\rerror_message\x18\x07 \x01(\t\"i\n\x12\x41nalyzeLinkRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0f\n\x07post_id\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x11\n\tsite_name\x18\x05 \x01(\t\"I\n\x13\x41nalyzeLinkResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"*\n\x06QAPair\x12\x10\n\x08question\x18\x01 \x01(\t\x12\x0e\n\x06\x61nswer\x18\x02 \x01(\t\"\x8c\x01\n\x0fProductMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\r\n\x05\x62rand\x18\x03 \x01(\t\x12\r\n\x05price\x18\x04 \x01(\t\x12\x10\n\x08\x63urrency\x18\x05 \x01(\t\x12\x11\n\tmaterials\x18\x06 \x03(\t\x12\x0e\n\x06origin\x18\x07 \x01(\t\"\x87\x01\n\x0f\x41rticleMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x16\n\x0epublished_date\x18\x04 \x01(\t\x12\x14\n\x0creading_time\x18\x05 \x01(\t\x12\x0e\n\x06topics\x18\x06 \x03(\t\"\x83\x01\n\rVideoMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x03 \x01(\t\x12\x10\n\x08\x64uration\x18\x04 \x01(\t\x12\x12\n\nview_count\x18\x05 \x01(\t\x12\x13\n\x0bupload_date\x18\x06 \x01(\t\"M\n\rOtherMetadata\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x14\n\x0csub_category\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"\xf1\x02\n\x19\x41nalyzeLinkDirectResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0f\n\x07summary\x18\x03 \x01(\t\x12\x10\n\x08keywords\x18\x04 \x03(\t\x12\x1c\n\x03qna\x18\x05 \x03(\x0b\x32\x0f.inbound.QAPair\x12\x34\n\x10product_metadata\x18\x06 \x01(\x0b\x32\x18.inbound.ProductMetadataH\x00\x12\x34\n\x10\x61rticle_metadata\x18\x07 \x01(\x0b\x32\x18.inbound.ArticleMetadataH\x00\x12\x30\n\x0evideo_metadata\x18\x08 \x01(\x0b\x32\x16.inbound.VideoMetadataH\x00\x12\x30\n\x0eother_metadata\x18\t \x01(\x0b\x32\x16.inbound.OtherMetadataH\x00\x12\x15\n\rerror_message\x18\n \x01(\tB\n\n\x08metadata\"i\n\x13\x41nalyzeImageRequest\x12\x12\n\nimage_data\x18\x01 \x01(\t\x12\x0f\n\x07item_id\x18\x02 \x01(\t\x12-\n\x0e\x63\x61tegory_rules\x18\x03 \x03(\x0b\x32\x15.inbound.CategoryRule\"8\n\x0c\x43\x61tegoryRule\x12\x10\n\x08\x63\x61tegory\x18\x01 \x01(\t\x12\x16\n\x0esub_categories\x18\x02 \x03(\t\"o\n\x13ItemWithCoordinates\x12\x14\n\x0csub_category\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x10\n\x03top\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x11\n\x04left\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x06\n\x04_topB\x07\n\x05_left\"7\n\x08ItemList\x12+\n\x05items\x18\x01 \x03(\x0b\x32\x1c.inbound.ItemWithCoordinates\"\xcc\x02\n\x14\x41nalyzeImageResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07subject\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x18\n\x0b\x61rtist_name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ngroup_name\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07\x63ontext\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x37\n\x05items\x18\x07 \x03(\x0b\x32(.inbound.AnalyzeImageResponse.ItemsEntry\x12\x15\n\rerror_message\x18\x08 \x01(\t\x1a?\n\nItemsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.inbound.ItemList:\x02\x38\x01\x42\x0e\n\x0c_artist_nameB\r\n\x0b_group_nameB\n\n\x08_context\"?\n\x19\x45xtractPostContextRequest\x12\x0f\n\x07post_id\x18\x01 \x01(\t\x12\x11\n\timage_url\x18\x02 \x01(\t\"\x88\x01\n\x1a\x45xtractPostContextResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontext\x18\x02 \x01(\t\x12\x12\n\nstyle_tags\x18\x03 \x03(\t\x12\x0c\n\x04mood\x18\x04 \x01(\t\x12\x0f\n\x07setting\x18\x05 \x01(\t\x12\x15\n\rerror_message\x18\x06 \x01(\t\")\n\x14TriggerSourceRequest\x12\x11\n\tsource_id\x18\x01 \x01(\t\"@\n\x15TriggerSourceResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"p\n\x15ReparseRawPostRequest\x12\x13\n\x0braw_post_id\x18\x01 \x01(\t\x12%\n\x18hero_reframe_prompt_hint\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x1b\n\x19_hero_reframe_prompt_hint\"A\n\x16ReparseRawPostResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\x12\x15\n\rerror_message\x18\x02 \x01(\t\"i\n\x12RunChatTurnRequest\x12\x12\n\narticle_id\x18\x01 \x01(\t\x12\x13\n\x0blayout_json\x18\x02 \x01(\t\x12\x14\n\x0chistory_json\x18\x03 \x01(\t\x12\x14\n\x0cuser_message\x18\x04 \x01(\t\"\xa1\x01\n\x13RunChatTurnResponse\x12\x13\n\x0b\x65vents_json\x18\x01 \x01(\t\x12\x19\n\x11\x66inal_layout_json\x18\x02 \x01(\t\x12\x12\n\nfinal_text\x18\x03 \x01(\t\x12\x17\n\x0ftool_calls_made\x18\x04 \x01(\x05\x12\x16\n\x0elayout_changed\x18\x05 \x01(\x08\x12\x15\n\rerror_message\x18\x06 \x01(\t\"O\n\x18SearchSolutionUrlRequest\x12\r\n\x05title\x18\x01 \x01(\t\x12\r\n\x05\x62rand\x18\x02 \x01(\t\x12\x15\n\rthumbnail_url\x18\x03 \x01(\t\"9\n\x1aSearchSolutionUrlCandidate\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\"c\n\x13\x43omposeTitleRequest\x12\x13\n\x0b\x61rtist_name\x18\x01 \x01(\t\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontext\x18\x03 \x01(\t\x12\x12\n\nstyle_tags\x18\x04 \x03(\t\"<\n\x14\x43omposeTitleResponse\x12\r\n\x05title\x18\x01 \x01(\t\x12\x15\n\rerror_message\x18\x02 \x01(\t\"9\n\x15RegenThumbnailRequest\x12\x12\n\narticle_id\x18\x01 \x01(\t\x12\x0c\n\x04hint\x18\x02 \x01(\t\"L\n\x16RegenThumbnailResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x10\n\x08\x62\x61tch_id\x18\x03 \x01(\t\"\xe5\x01\n\x19SearchSolutionUrlResponse\x12\x10\n\x08\x62\x65st_url\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\t\x12\x14\n\x0c\x64omain_class\x18\x03 \x01(\t\x12\x10\n\x08rejected\x18\x04 \x01(\x08\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x37\n\ncandidates\x18\x06 \x03(\x0b\x32#.inbound.SearchSolutionUrlCandidate\x12\x1a\n\x12\x64\x61ta_quality_issue\x18\x07 \x01(\t\x12\x15\n\rerror_message\x18\x08 \x01(\t2\xca\x08\n\x05Queue\x12W\n\x10ProcessDataBatch\x12 .inbound.ProcessDataBatchRequest\x1a!.inbound.ProcessDataBatchResponse\x12N\n\rExtractOGData\x12\x1d.inbound.ExtractOGDataRequest\x1a\x1e.inbound.ExtractOGDataResponse\x12H\n\x0b\x41nalyzeLink\x12\x1b.inbound.AnalyzeLinkRequest\x1a\x1c.inbound.AnalyzeLinkResponse\x12T\n\x11\x41nalyzeLinkDirect\x12\x1b.inbound.AnalyzeLinkRequest\x1a\".inbound.AnalyzeLinkDirectResponse\x12K\n\x0c\x41nalyzeImage\x12\x1c.inbound.AnalyzeImageRequest\x1a\x1d.inbound.AnalyzeImageResponse\x12\x63\n\x14ProcessPostEditorial\x12$.inbound.ProcessPostEditorialRequest\x1a%.inbound.ProcessPostEditorialResponse\x12]\n\x12\x45xtractPostContext\x12\".inbound.ExtractPostContextRequest\x1a#.inbound.ExtractPostContextResponse\x12N\n\rTriggerSource\x12\x1d.inbound.TriggerSourceRequest\x1a\x1e.inbound.TriggerSourceResponse\x12Q\n\x0eReparseRawPost\x12\x1e.inbound.ReparseRawPostRequest\x1a\x1f.inbound.ReparseRawPostResponse\x12H\n\x0bRunChatTurn\x12\x1b.inbound.RunChatTurnRequest\x1a\x1c.inbound.RunChatTurnResponse\x12Z\n\x11SearchSolutionUrl\x12!.inbound.SearchSolutionUrlRequest\x1a\".inbound.SearchSolutionUrlResponse\x12K\n\x0c\x43omposeTitle\x12\x1c.inbound.ComposeTitleRequest\x1a\x1d.inbound.ComposeTitleResponse\x12Q\n\x0eRegenThumbnail\x12\x1e.inbound.RegenThumbnailRequest\x1a\x1f.inbound.RegenThumbnailResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -101,8 +101,12 @@ _globals['_COMPOSETITLEREQUEST']._serialized_end=3617 _globals['_COMPOSETITLERESPONSE']._serialized_start=3619 _globals['_COMPOSETITLERESPONSE']._serialized_end=3679 - _globals['_SEARCHSOLUTIONURLRESPONSE']._serialized_start=3682 - _globals['_SEARCHSOLUTIONURLRESPONSE']._serialized_end=3911 - _globals['_QUEUE']._serialized_start=3914 - _globals['_QUEUE']._serialized_end=4929 + _globals['_REGENTHUMBNAILREQUEST']._serialized_start=3681 + _globals['_REGENTHUMBNAILREQUEST']._serialized_end=3738 + _globals['_REGENTHUMBNAILRESPONSE']._serialized_start=3740 + _globals['_REGENTHUMBNAILRESPONSE']._serialized_end=3816 + _globals['_SEARCHSOLUTIONURLRESPONSE']._serialized_start=3819 + _globals['_SEARCHSOLUTIONURLRESPONSE']._serialized_end=4048 + _globals['_QUEUE']._serialized_start=4051 + _globals['_QUEUE']._serialized_end=5149 # @@protoc_insertion_point(module_scope) diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi index d9ca7547..0f69c8bc 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi @@ -393,6 +393,24 @@ class ComposeTitleResponse(_message.Message): error_message: str def __init__(self, title: _Optional[str] = ..., error_message: _Optional[str] = ...) -> None: ... +class RegenThumbnailRequest(_message.Message): + __slots__ = ("article_id", "hint") + ARTICLE_ID_FIELD_NUMBER: _ClassVar[int] + HINT_FIELD_NUMBER: _ClassVar[int] + article_id: str + hint: str + def __init__(self, article_id: _Optional[str] = ..., hint: _Optional[str] = ...) -> None: ... + +class RegenThumbnailResponse(_message.Message): + __slots__ = ("success", "message", "batch_id") + SUCCESS_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + BATCH_ID_FIELD_NUMBER: _ClassVar[int] + success: bool + message: str + batch_id: str + def __init__(self, success: bool = ..., message: _Optional[str] = ..., batch_id: _Optional[str] = ...) -> None: ... + class SearchSolutionUrlResponse(_message.Message): __slots__ = ("best_url", "confidence", "domain_class", "rejected", "reason", "candidates", "data_quality_issue", "error_message") BEST_URL_FIELD_NUMBER: _ClassVar[int] diff --git a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py index 49bddb2a..8d66dbba 100644 --- a/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py +++ b/packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py @@ -94,6 +94,11 @@ def __init__(self, channel): request_serializer=inbound__pb2.ComposeTitleRequest.SerializeToString, response_deserializer=inbound__pb2.ComposeTitleResponse.FromString, _registered_method=True) + self.RegenThumbnail = channel.unary_unary( + '/inbound.Queue/RegenThumbnail', + request_serializer=inbound__pb2.RegenThumbnailRequest.SerializeToString, + response_deserializer=inbound__pb2.RegenThumbnailResponse.FromString, + _registered_method=True) class QueueServicer(object): @@ -193,6 +198,14 @@ def ComposeTitle(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def RegenThumbnail(self, request, context): + """Admin: editorial_articles 의 thumbnail 만 재생성 (본문 보존). hint 는 매니저가 + "더 어둡게" / "워드마크 색 바꿔줘" 같은 방향성 지시를 넘길 수 있는 선택 필드. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_QueueServicer_to_server(servicer, server): rpc_method_handlers = { @@ -256,6 +269,11 @@ def add_QueueServicer_to_server(servicer, server): request_deserializer=inbound__pb2.ComposeTitleRequest.FromString, response_serializer=inbound__pb2.ComposeTitleResponse.SerializeToString, ), + 'RegenThumbnail': grpc.unary_unary_rpc_method_handler( + servicer.RegenThumbnail, + request_deserializer=inbound__pb2.RegenThumbnailRequest.FromString, + response_serializer=inbound__pb2.RegenThumbnailResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'inbound.Queue', rpc_method_handlers) @@ -590,3 +608,30 @@ def ComposeTitle(request, timeout, metadata, _registered_method=True) + + @staticmethod + def RegenThumbnail(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/inbound.Queue/RegenThumbnail', + inbound__pb2.RegenThumbnailRequest.SerializeToString, + inbound__pb2.RegenThumbnailResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/packages/ai-server/src/grpc/servicer/metadata_servicer.py b/packages/ai-server/src/grpc/servicer/metadata_servicer.py index 99c9bf1f..11cee385 100644 --- a/packages/ai-server/src/grpc/servicer/metadata_servicer.py +++ b/packages/ai-server/src/grpc/servicer/metadata_servicer.py @@ -324,6 +324,62 @@ async def ProcessPostEditorial( batch_id="", ) + async def RegenThumbnail( + self, + request: inbound_pb2.RegenThumbnailRequest, + context, + ) -> inbound_pb2.RegenThumbnailResponse: + """Enqueue thumbnail-only regeneration to ARQ. Async — returns immediately.""" + try: + article_id = request.article_id + hint = request.hint or "" + + if not article_id: + raise ValueError("article_id is required") + + self.logger.debug( + f"Received RegenThumbnail request for article {article_id} " + f"(hint_len={len(hint)})" + ) + + job_id = await self.queue_manager.enqueue_job( + "regen_thumbnail_job", + article_id, + hint, + ) + + if job_id is None: + raise Exception("Failed to enqueue regen_thumbnail") + + self.logger.debug( + f"regen_thumbnail enqueued for {article_id} (job_id: {job_id})" + ) + return inbound_pb2.RegenThumbnailResponse( + success=True, + message=f"Thumbnail regen enqueued for article {article_id}", + batch_id=job_id or "", + ) + + except ValueError as e: + self.logger.warning(f"Invalid RegenThumbnail request: {str(e)}") + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(e)) + return inbound_pb2.RegenThumbnailResponse( + success=False, + message=f"Invalid request: {str(e)}", + batch_id="", + ) + + except Exception as e: + self.logger.error(f"Error enqueuing regen_thumbnail: {str(e)}") + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(str(e)) + return inbound_pb2.RegenThumbnailResponse( + success=False, + message=f"Failed to enqueue regen_thumbnail: {str(e)}", + batch_id="", + ) + async def AnalyzeImage( self, request: inbound_pb2.AnalyzeImageRequest, diff --git a/packages/ai-server/src/managers/queue/worker.py b/packages/ai-server/src/managers/queue/worker.py index 6d7a9535..6858d8e8 100644 --- a/packages/ai-server/src/managers/queue/worker.py +++ b/packages/ai-server/src/managers/queue/worker.py @@ -19,6 +19,9 @@ def _get_functions(): from src.services.editorial_article.editorial_article_service import ( EditorialArticleService, ) + from src.services.editorial_article.regen_thumbnail_service import ( + RegenThumbnailService, + ) return [ func(MetadataExtractService.analyze_link_job, name="analyze_link_job"), @@ -32,6 +35,11 @@ def _get_functions(): name="editorial_article_job", max_tries=1, ), + func( + RegenThumbnailService.regen_thumbnail_job, + name="regen_thumbnail_job", + max_tries=1, + ), # #214 fetch_raw_posts_job removed — raw_posts scheduler runs in-process. ] @@ -124,9 +132,7 @@ async def create_worker( 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["openai_image_client"] = infrastructure_container.openai_image_client() ctx["r2_client"] = infrastructure_container.r2_client() # Create worker with settings diff --git a/packages/ai-server/src/services/editorial_article/regen_thumbnail_service.py b/packages/ai-server/src/services/editorial_article/regen_thumbnail_service.py new file mode 100644 index 00000000..4fc95cfc --- /dev/null +++ b/packages/ai-server/src/services/editorial_article/regen_thumbnail_service.py @@ -0,0 +1,206 @@ +"""editorial_articles.thumbnail 단독 재생성 ARQ job. + +api-server 의 `POST /api/v1/admin/editorial-articles/{id}/regen-thumbnail` 가 +ai-server gRPC `RegenThumbnail` 을 호출 → servicer 가 본 job 을 enqueue 한다. +worker 가 본 함수를 실행해서 thumbnail 만 재생성 (본문/섹션은 보존). + +hint 는 매니저가 admin UI 에서 넘긴 선택 지시 — 비어있지 않으면 +`generate_thumbnail_node` 의 _build_prompt 가 ADDITIONAL DIRECTION 으로 주입. + +성공: editorial_articles.thumbnail_url + layout_json UPDATE, event INSERT (ok). +실패: error_log append + event INSERT (failure note). thumbnail_url 은 보존. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, Optional + +from src.editorial_article.models import MagazineLayout +from src.editorial_article.nodes.generate_thumbnail import generate_thumbnail_node +from src.managers.database import DatabaseManager + + +logger = logging.getLogger(__name__) + + +def _hint_snippet(hint: str) -> str: + """events.note 에 들어갈 hint 요약 — 60자 cap.""" + if not hint: + return "" + return hint[:60] + ("…" if len(hint) > 60 else "") + + +class RegenThumbnailService: + @staticmethod + async def regen_thumbnail_job( + ctx: Dict[str, Any], + article_id: str, + hint: str = "", + ) -> Dict[str, Any]: + assets_db: DatabaseManager | None = ctx.get("assets_database_manager") + openai_image = ctx.get("openai_image_client") + r2 = ctx.get("r2_client") + + if assets_db is None: + logger.error("regen_thumbnail_job: assets_db missing") + return {"success": False, "error": "assets_db missing"} + if openai_image is None or r2 is None: + await _record_failure( + assets_db, article_id, hint, "openai_image_client or r2_client missing" + ) + return {"success": False, "error": "deps missing"} + + layout = await _load_layout(assets_db, article_id) + if layout is None: + await _record_failure(assets_db, article_id, hint, "layout_json not found") + return {"success": False, "error": "layout not found"} + + state = { + "article_id": article_id, + "layout": layout, + "regen_hint": hint or None, + } + config = { + "configurable": { + "openai_image_client": openai_image, + "r2_client": r2, + } + } + + try: + result = await generate_thumbnail_node(state, config) + except Exception as exc: + logger.exception("regen_thumbnail_job: node crashed") + await _record_failure( + assets_db, article_id, hint, f"node crash: {type(exc).__name__}: {exc}" + ) + return {"success": False, "error": str(exc)} + + err = (result or {}).get("error_log") or [] + if err: + msg = "; ".join(str(e) for e in err) + await _record_failure(assets_db, article_id, hint, msg) + return {"success": False, "error": msg} + + new_layout: Optional[MagazineLayout] = (result or {}).get("layout") + if new_layout is None or not new_layout.thumbnail_url: + await _record_failure( + assets_db, article_id, hint, "node returned no thumbnail_url" + ) + return {"success": False, "error": "no thumbnail_url"} + + await _persist_success(assets_db, article_id, new_layout, hint) + logger.info( + "regen_thumbnail_job: ok article=%s thumb=%s", + article_id, + new_layout.thumbnail_url, + ) + return {"success": True, "thumbnail_url": new_layout.thumbnail_url} + + +async def _load_layout( + db: DatabaseManager, article_id: str +) -> Optional[MagazineLayout]: + async with db.acquire() as conn: + row = await conn.fetchrow( + "SELECT layout_json::text AS layout_json " + "FROM public.editorial_articles WHERE id = $1::uuid", + article_id, + ) + if row is None or row["layout_json"] is None: + return None + try: + return MagazineLayout.model_validate(json.loads(row["layout_json"])) + except Exception: + logger.exception("_load_layout: model_validate failed article=%s", article_id) + return None + + +async def _persist_success( + db: DatabaseManager, + article_id: str, + layout: MagazineLayout, + hint: str, +) -> None: + layout_json = layout.model_dump(mode="json") + note = "ok" + snippet = _hint_snippet(hint) + if snippet: + note = f"ok (hint: {snippet})" + try: + async with db.acquire() as conn: + async with conn.transaction(): + await conn.execute( + """ + UPDATE public.editorial_articles + SET thumbnail_url = $2, + layout_json = $3::jsonb, + updated_at = now() + WHERE id = $1::uuid + """, + article_id, + layout.thumbnail_url, + json.dumps(layout_json), + ) + # to_status NOT NULL — regen 은 status 전이가 아니므로 현재 + # status 를 그대로 채워준다 (from_status 도 동일). + await conn.execute( + """ + INSERT INTO public.editorial_article_events + (article_id, from_status, to_status, step, note) + SELECT id, status, status, 'regen_thumbnail', $2::text + FROM public.editorial_articles + WHERE id = $1::uuid + """, + article_id, + note[:500], + ) + except Exception: + logger.exception("_persist_success crashed article=%s", article_id) + + +async def _record_failure( + db: DatabaseManager, + article_id: str, + hint: str, + error: str, +) -> None: + snippet = _hint_snippet(hint) + note = f"failed: {error}" + if snippet: + note = f"failed (hint: {snippet}): {error}" + try: + async with db.acquire() as conn: + async with conn.transaction(): + await conn.execute( + """ + UPDATE public.editorial_articles + SET error_log = COALESCE(error_log, '[]'::jsonb) + || jsonb_build_array( + jsonb_build_object( + 'at', now(), + 'step', 'regen_thumbnail', + 'error', $2::text + ) + ), + updated_at = now() + WHERE id = $1::uuid + """, + article_id, + error, + ) + await conn.execute( + """ + INSERT INTO public.editorial_article_events + (article_id, from_status, to_status, step, note) + SELECT id, status, status, 'regen_thumbnail', $2::text + FROM public.editorial_articles + WHERE id = $1::uuid + """, + article_id, + note[:500], + ) + except Exception: + logger.exception("_record_failure crashed article=%s", article_id) diff --git a/packages/api-server/proto/ai.proto b/packages/api-server/proto/ai.proto index 83a8b8a6..4c968301 100644 --- a/packages/api-server/proto/ai.proto +++ b/packages/api-server/proto/ai.proto @@ -45,6 +45,10 @@ service Queue { // 을 대체 — admin 이 verify 시점에 인물/맥락을 확정한 뒤 생성하므로 인물 오인식 // 으로 인한 title 오염을 막는다. Gemini flash-lite (cheap) + 결정적 fallback. rpc ComposeTitle (ComposeTitleRequest) returns (ComposeTitleResponse); + + // Admin: editorial_articles 의 thumbnail 만 재생성 (본문 보존). hint 는 매니저가 + // "더 어둡게" / "워드마크 색 바꿔줘" 같은 방향성 지시를 넘길 수 있는 선택 필드. + rpc RegenThumbnail (RegenThumbnailRequest) returns (RegenThumbnailResponse); } // #214 RawPostsWorker service removed — ai-server now schedules itself. @@ -326,3 +330,19 @@ message RunChatTurnResponse { // 비어있지 않으면 부분/전체 실패. 호출자가 사용자에게 노출. string error_message = 6; } + +// Admin: thumbnail 단독 재생성 (editorial_articles). +// +// hint 는 선택 — 빈 문자열이면 기본 프롬프트로 생성, 그렇지 않으면 +// generate_thumbnail 노드의 _build_prompt 가 hint 를 추가 지시로 주입. +message RegenThumbnailRequest { + string article_id = 1; + string hint = 2; +} + +message RegenThumbnailResponse { + bool success = 1; + string message = 2; + // ARQ job id (즉시 enqueue 후 비동기 처리). 빈 문자열이면 enqueue 실패. + string batch_id = 3; +} diff --git a/packages/api-server/src/domains/admin/editorial_articles.rs b/packages/api-server/src/domains/admin/editorial_articles.rs index 4e1d5d68..6b02a9fc 100644 --- a/packages/api-server/src/domains/admin/editorial_articles.rs +++ b/packages/api-server/src/domains/admin/editorial_articles.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - routing::get, + routing::{get, post}, Json, Router, }; use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement}; @@ -85,6 +85,19 @@ pub struct PatchArticleRequest { pub review_summary: Option, } +#[derive(Debug, Deserialize)] +pub struct RegenThumbnailRequest { + /// 매니저가 "더 어둡게" 같은 방향성 지시를 넘길 수 있는 선택 필드. + /// 빈/누락이면 기본 프롬프트로 생성. + pub hint: Option, +} + +#[derive(Debug, Serialize)] +pub struct RegenThumbnailEnqueued { + pub enqueued: bool, + pub batch_id: String, +} + #[derive(Debug, Serialize)] pub struct ArticleEvent { pub id: i64, @@ -515,6 +528,88 @@ pub async fn list_events( Ok(Json(events)) } +/// POST /api/v1/admin/editorial-articles/{id}/regen-thumbnail +/// +/// editorial_articles.thumbnail 만 단독 재생성. 본문/섹션은 보존. status 가 +/// `draft` 또는 `failed` 일 때만 허용. ai-server 의 `RegenThumbnail` gRPC RPC +/// 를 호출 → ai-server 가 ARQ enqueue → worker 가 비동기 처리. 결과/에러는 +/// `editorial_articles.error_log` + `editorial_article_events` 로 흘러와 +/// frontend 가 폴링으로 픽업. +pub async fn regen_thumbnail( + State(state): State, + user: axum::Extension, + Path(id): Path, + Json(body): Json, +) -> AppResult<(StatusCode, Json)> { + // 1) status guard — draft / failed 만 허용. + let status_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT status FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into()], + ); + let row = state + .assets_db + .query_one(status_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::not_found(format!("editorial_article {id} not found")))?; + let status: String = row.try_get("", "status").map_err(AppError::DatabaseError)?; + if !["draft", "failed"].contains(&status.as_str()) { + return Err(AppError::bad_request(format!( + "regen-thumbnail not allowed for status='{status}' (only draft|failed)" + ))); + } + + let hint = body.hint.as_deref().unwrap_or("").to_string(); + let hint_snippet: String = if hint.is_empty() { + String::new() + } else if hint.chars().count() > 60 { + let mut s: String = hint.chars().take(60).collect(); + s.push('…'); + s + } else { + hint.clone() + }; + + // 2) requested event 동기 INSERT — UI 가 즉시 타임라인에서 픽업할 수 있게. + let note = if hint_snippet.is_empty() { + "requested".to_string() + } else { + format!("requested (hint: {hint_snippet})") + }; + 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) \ + SELECT id, status, status, 'regen_thumbnail', $2::uuid, $3::text \ + FROM public.editorial_articles WHERE id = $1::uuid", + vec![id.into(), user.id.into(), note.into()], + ); + let _ = state.assets_db.execute(ev_stmt).await; + + // 3) gRPC 즉시 enqueue — ai-server 가 ARQ 큐 적재 후 곧장 응답. + let resp = state + .decoded_ai_client + .regen_thumbnail(&id.to_string(), &hint) + .await + .map_err(|e| AppError::internal(format!("regen_thumbnail grpc failed: {e}")))?; + + if !resp.success { + return Err(AppError::internal(format!( + "regen_thumbnail enqueue failed: {}", + resp.message + ))); + } + + Ok(( + StatusCode::ACCEPTED, + Json(RegenThumbnailEnqueued { + enqueued: true, + batch_id: resp.batch_id, + }), + )) +} + /// DELETE /api/v1/admin/editorial-articles/{id} — hard delete. /// editorial_article_events / chat_sessions 는 FK CASCADE 로 같이 삭제, /// recommendation 의 article_id 는 FK SET NULL. @@ -546,6 +641,7 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { .route("/", get(list)) .route("/{id}", get(get_one).patch(patch).delete(delete_one)) .route("/{id}/events", get(list_events)) + .route("/{id}/regen-thumbnail", post(regen_thumbnail)) .layer(axum::middleware::from_fn_with_state( state, crate::middleware::admin_db_middleware, diff --git a/packages/api-server/src/services/decoded_ai_grpc/client.rs b/packages/api-server/src/services/decoded_ai_grpc/client.rs index e1551692..99a5ddaa 100644 --- a/packages/api-server/src/services/decoded_ai_grpc/client.rs +++ b/packages/api-server/src/services/decoded_ai_grpc/client.rs @@ -10,9 +10,10 @@ use crate::grpc::inbound::{ AnalyzeLinkDirectResponse, AnalyzeLinkRequest, AnalyzeLinkResponse, CategoryRule, ComposeTitleRequest, ComposeTitleResponse, ExtractOgDataRequest, ExtractOgDataResponse, ExtractPostContextRequest, ExtractPostContextResponse, ProcessPostEditorialRequest, - ProcessPostEditorialResponse, ReparseRawPostRequest, ReparseRawPostResponse, - RunChatTurnRequest, RunChatTurnResponse, SearchSolutionUrlRequest, SearchSolutionUrlResponse, - TriggerSourceRequest, TriggerSourceResponse, + ProcessPostEditorialResponse, RegenThumbnailRequest, RegenThumbnailResponse, + ReparseRawPostRequest, ReparseRawPostResponse, RunChatTurnRequest, RunChatTurnResponse, + SearchSolutionUrlRequest, SearchSolutionUrlResponse, TriggerSourceRequest, + TriggerSourceResponse, }; use crate::observability::grpc::record_decoded_ai_call; @@ -122,6 +123,28 @@ impl DecodedAIGrpcClient { res } + /// editorial_articles 의 thumbnail 만 재생성 (ARQ enqueue, 비동기 처리). + /// hint 는 선택 — 빈 문자열이면 기본 프롬프트로 생성. + pub async fn regen_thumbnail( + &self, + article_id: &str, + hint: &str, + ) -> Result> { + let start = Instant::now(); + let mut client = self.client.clone(); + let request = tonic::Request::new(RegenThumbnailRequest { + article_id: article_id.to_string(), + hint: hint.to_string(), + }); + let res = async { + let response = client.regen_thumbnail(request).await?; + Ok::<_, Box>(response.into_inner()) + } + .await; + record_decoded_ai_call("regen_thumbnail", res.is_ok(), start.elapsed()); + res + } + /// 이미지 분석을 즉시 요청합니다 (동기 처리). pub async fn analyze_image( &self, 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 bca946b2..98b8eba4 100644 --- a/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx +++ b/packages/web/app/admin/editorial/magazine/drafts/[id]/page.tsx @@ -11,18 +11,22 @@ * so the timeline updates live during Stage 2 generation. */ -import { use, Suspense } from "react"; +import { use, useState, Suspense } from "react"; import Link from "next/link"; -import { ArrowLeft, Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, RefreshCw } from "lucide-react"; import { AdminStatusBadge } from "@/lib/components/admin/common"; import { useArticleEvents, useEditorialArticle, + useRegenThumbnail, } from "@/lib/hooks/admin/useEditorialArticles"; import { MagazineRenderer } from "@/lib/components/admin/editorial/magazine/MagazineRenderer"; import { ArticleActions } from "@/lib/components/admin/editorial/magazine/ArticleActions"; +import { ArticleErrors } from "@/lib/components/admin/editorial/magazine/ArticleErrors"; import { ChatPanel } from "@/lib/components/admin/editorial/magazine/ChatPanel"; +const REGEN_ALLOWED_STATUSES = new Set(["draft", "failed"]); + interface PageProps { params: Promise<{ id: string }>; } @@ -33,6 +37,9 @@ function ArticleDetailContent({ id }: { id: string }) { poll: true, status: articleQuery.data?.status, }); + const regen = useRegenThumbnail(); + const [regenOpen, setRegenOpen] = useState(false); + const [regenHint, setRegenHint] = useState(""); if (articleQuery.isLoading) { return ( @@ -55,6 +62,21 @@ function ArticleDetailContent({ id }: { id: string }) { } const article = articleQuery.data; + const canRegen = REGEN_ALLOWED_STATUSES.has(article.status); + + const submitRegen = () => { + if (!canRegen || regen.isPending) return; + const trimmed = regenHint.trim(); + regen.mutate( + { id: article.id, hint: trimmed || undefined }, + { + onSuccess: () => { + setRegenOpen(false); + setRegenHint(""); + }, + } + ); + }; return (
@@ -79,6 +101,8 @@ function ArticleDetailContent({ id }: { id: string }) {
+ +
{article.layout_json ? ( @@ -92,9 +116,28 @@ function ArticleDetailContent({ id }: { id: string }) {
+ {regenOpen && canRegen && ( +
+ +