Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
86061af
feat(raw_posts): StarStyle.com adapter (#466) (#481)
cocoyoon May 7, 2026
efcb74f
chore: retrigger CI with bump label
cocoyoon May 7, 2026
4c0b3ff
fix(starstyle-backfill): mirror hero images to R2 before INSERT (#466…
cocoyoon May 7, 2026
3b5d5fc
Merge remote-tracking branch 'origin/main' into dev
cocoyoon May 7, 2026
47f31a8
feat(raw_posts): R2 cleanup on delete + storage rename (#466) (#486)
cocoyoon May 7, 2026
5443b42
feat(web): add vton item and post selection
thxforall May 7, 2026
23a7eb8
fix(web): use local supabase fallback for posts
thxforall May 7, 2026
6a0316a
fix(web): serve local vton seed images
thxforall May 7, 2026
2dbcdb5
fix(web): seed local post images for vton qa
thxforall May 7, 2026
8c57c3a
docs: design profile tries detail modal
thxforall May 7, 2026
8201801
feat(web): add try detail snapshots
thxforall May 7, 2026
b5ca3ea
fix(raw_posts): preserve vision log thumbnails (#490)
CIOI May 7, 2026
6647c59
fix(web): remove missing supabase client export (#491)
CIOI May 7, 2026
28da9df
feat(verify): R2 relocate raw → operation at verify time (#466) (#492)
cocoyoon May 8, 2026
910081f
fix(wiki): remove unknown tags (profile, vton) from profile-tries spe…
cocoyoon May 8, 2026
c53f0d4
Merge remote-tracking branch 'origin/main' into dev
cocoyoon May 8, 2026
0a08891
feat(cost-tracking): Gemini API per-call cost tracking + admin dashbo…
cocoyoon May 14, 2026
01b8c5a
feat(content-studio): AI-powered content generation pipeline (#498)
thxforall May 14, 2026
1b048cf
feat(admin): verify observability — stats page + daily-digest nag (#499)
cocoyoon May 14, 2026
b8dab14
merge: main into dev — resolve admin router/sidebar/home conflicts
cocoyoon May 14, 2026
006c967
feat(content-studio): DB persistence for local dev parity (#510)
thxforall May 14, 2026
d5755a7
feat(editorial): thumbnail regen button + pipeline error surfacing (#…
cocoyoon May 14, 2026
0b95cb2
chore(dx): dev-reset PRD seed + pre-push lint fixes (#525)
thxforall May 14, 2026
9a88c56
chore(release): backend bump ai=1.6.0 api=0.11.0 [skip ci]
May 14, 2026
8f37d04
fix(daily-digest): use single quotes for UUID array literals in psql …
cocoyoon May 14, 2026
39a9951
merge: main into dev — record main as merged (content already in dev …
cocoyoon May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,37 @@ dev-down:
bash "{{ repo }}/scripts/local-deps-down.sh"

# DB 초기화 + 마이그레이션 + seed — 깨끗한 상태로 리셋 (Supabase CLI 필요)
# PRD_DB_URL 이 env 또는 .env.local 에 있으면 PRD 데이터로 시딩, 없으면 로컬 seed.sql 사용
dev-reset:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v supabase >/dev/null 2>&1; then
echo "❌ supabase CLI not found. Install: brew install supabase/tap/supabase"
exit 1
fi
echo "⚠️ Supabase 로컬 DB 를 리셋합니다 (볼륨 유지, schema 재적용)..."
( cd "{{ repo }}" && supabase db reset ) || true
echo "⏳ Waiting 3s for postgres..."
sleep 3

# PRD_DB_URL 자동 감지: env → packages/web/.env.local
if [ -z "${PRD_DB_URL:-}" ]; then
ENV_FILE="{{ repo }}/packages/web/.env.local"
if [ -f "$ENV_FILE" ]; then
PRD_DB_URL=$(grep '^PRD_DB_URL=' "$ENV_FILE" | cut -d'=' -f2- || true)
export PRD_DB_URL
fi
fi

if [ -n "${PRD_DB_URL:-}" ]; then
echo "🔄 PRD 데이터로 리셋합니다 (seed-from-prod --yes)..."
bash "{{ repo }}/scripts/seed-from-prod.sh" --yes
else
echo "⚠️ PRD_DB_URL 없음 — 로컬 seed.sql 로 리셋합니다..."
( cd "{{ repo }}" && supabase db reset ) || true
echo "⏳ Waiting 3s for postgres..."
sleep 3
just seed || echo "⚠️ seed 실패 (Auth 유저 FK 등) — Studio 에서 유저 생성 후 재시도"
fi

# supabase db reset 은 컨테이너를 재시작하므로 decoded-backend 네트워크 재연결 필요
bash "{{ repo }}/scripts/local-deps-connect.sh"
just seed || echo "⚠️ seed 실패 (Auth 유저 FK 등) — Studio 에서 유저 생성 후 재시도"
bash "{{ repo }}/scripts/local-deps-connect.sh" 2>/dev/null || echo "ℹ️ 네트워크 재연결 건너뜀 (just local-deps 미실행 상태)"
echo "✅ DB reset 완료. Start apps with: just dev"

# seed.sql 적용 — postgres 가 기동 중이어야 함 (Supabase CLI 기본 DB)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
22 changes: 20 additions & 2 deletions packages/ai-server/src/editorial_article/nodes/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions packages/ai-server/src/grpc/proto/inbound/inbound.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions packages/ai-server/src/grpc/proto/inbound/inbound_pb2.py

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions packages/ai-server/src/grpc/proto/inbound/inbound_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
45 changes: 45 additions & 0 deletions packages/ai-server/src/grpc/proto/inbound/inbound_pb2_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
56 changes: 56 additions & 0 deletions packages/ai-server/src/grpc/servicer/metadata_servicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions packages/ai-server/src/managers/queue/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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.
]

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading