diff --git a/.github/scripts/daily-digest.sh b/.github/scripts/daily-digest.sh index 2121213e..759ce8bb 100755 --- a/.github/scripts/daily-digest.sh +++ b/.github/scripts/daily-digest.sh @@ -59,6 +59,75 @@ echo "new issues: $ISSUES_COUNT" echo "commits main/dev: $COMMITS_MAIN_COUNT/$COMMITS_DEV_COUNT" echo "::endgroup::" +# --- admin verify stats (assets DB + operation DB) --- +# 두 DB 모두 secret 설정되어 있을 때만 활성. 없으면 silent skip. +echo "::group::verify-stats" + +VERIFY_STATS_JSON="[]" +VERIFY_TOTALS_JSON='{"yesterday":0,"last_7d":0}' +NAG_ADMINS_TEXT="" + +if [ -n "${ASSETS_DATABASE_URL_RO:-}" ] && [ -n "${OPERATION_DATABASE_URL_RO:-}" ]; then + # 1) admins from operation DB + admins_json=$(PGCONNECT_TIMEOUT=10 psql "$OPERATION_DATABASE_URL_RO" -t -A -F$'\t' -c \ + "SELECT id::text, username, COALESCE(display_name, '') FROM public.users WHERE is_admin = true ORDER BY username" \ + 2>/dev/null \ + | jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {id:.[0], username:.[1], display_name:(.[2] // "" | if length == 0 then null else . end)})' \ + || echo "[]") + + admin_count=$(echo "$admins_json" | jq 'length') + echo "admins: $admin_count" + + if [ "$admin_count" -gt 0 ]; then + # 2) verified counts per admin from assets DB + # yesterday 윈도우 = [어제 00:00 KST, 오늘 00:00 KST). last_7d 윈도우 = 7일 rolling. + admin_ids_array=$(echo "$admins_json" | jq -r '[.[].id] | join("\",\"")') + counts_json=$(PGCONNECT_TIMEOUT=10 psql "$ASSETS_DATABASE_URL_RO" -t -A -F$'\t' -c \ + "SELECT verified_by::text, + COUNT(*) FILTER ( + WHERE verified_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') - interval '1 day') AT TIME ZONE 'Asia/Seoul' + AND verified_at < date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul' + ) AS yesterday, + COUNT(*) FILTER (WHERE verified_at >= now() - interval '7 days') AS last_7d + FROM public.raw_posts + WHERE verified_by = ANY(ARRAY[\"$admin_ids_array\"]::uuid[]) + AND verified_at IS NOT NULL + AND verified_at >= now() - interval '7 days' + GROUP BY verified_by" \ + 2>/dev/null \ + | jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {admin_id:.[0], yesterday:(.[1] | tonumber), last_7d:(.[2] | tonumber)})' \ + || echo "[]") + + # 3) zip — admin + counts (fall-through to 0 if no row) + VERIFY_STATS_JSON=$(jq -n \ + --argjson admins "$admins_json" \ + --argjson counts "$counts_json" \ + '[$admins[] as $a + | ($counts[] | select(.admin_id == $a.id)) // {yesterday: 0, last_7d: 0} + | {admin_id: $a.id, username: $a.username, display_name: $a.display_name, + yesterday: .yesterday, last_7d: .last_7d, needs_nag: (.yesterday == 0)}] + | sort_by(-.yesterday)') + + # 4) totals + nag list + VERIFY_TOTALS_JSON=$(echo "$VERIFY_STATS_JSON" | jq '{ + yesterday: (map(.yesterday) | add // 0), + last_7d: (map(.last_7d) | add // 0) + }') + + NAG_ADMINS_TEXT=$(echo "$VERIFY_STATS_JSON" | jq -r ' + map(select(.needs_nag)) | + if length == 0 then "" else + map((.display_name // .username)) | join(", ") + end') + + echo "totals: $(echo "$VERIFY_TOTALS_JSON" | jq -c .)" + echo "nag: ${NAG_ADMINS_TEXT:-(none)}" + fi +else + echo "skip — ASSETS_DATABASE_URL_RO and/or OPERATION_DATABASE_URL_RO not set" +fi +echo "::endgroup::" + # --- Claude Haiku summary --- echo "::group::summarize" @@ -69,25 +138,32 @@ DATA_JSON=$(jq -n \ --argjson open_issues_assigned "$OPEN_ISSUES_ASSIGNED" \ --argjson commits_main "$COMMITS_MAIN" \ --argjson commits_dev "$COMMITS_DEV" \ + --argjson verify_stats "$VERIFY_STATS_JSON" \ + --argjson verify_totals "$VERIFY_TOTALS_JSON" \ '{ merged_prs: $merged_prs, open_prs: $open_prs, new_issues: $new_issues, open_issues_assigned: $open_issues_assigned, commits_main: $commits_main, - commits_dev: $commits_dev + commits_dev: $commits_dev, + admin_verify_stats: $verify_stats, + admin_verify_totals: $verify_totals }') PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로 작성합니다. -아래 JSON은 지난 24시간의 GitHub 활동입니다. +아래 JSON은 지난 24시간의 GitHub 활동 + admin verify 통계입니다. 브랜치 맥락: decoded는 `feature/* → dev → main` 플로우. 대부분 작업은 dev에 병합되고, main은 릴리즈/CI 전용. +admin_verify_stats: admin 별 어제 verify (raw_post 검수) 카운트. `needs_nag=true` 인 admin 은 어제 0건이므로 부드럽게 독촉 메시지 추가. + 요청: - 주요 변화 3~5개를 **base 브랜치별로 그룹핑**해서 제시 (main → dev 순서) - 브랜치에 해당 항목이 없으면 해당 그룹 생략 - review 대기중이거나 오래된 open PR 있으면 "주의" 섹션 (주의는 그룹핑 없이 평평하게) -- 전체 400자 이내, plain text (마크다운 금지) +- admin_verify_stats 가 있고 needs_nag 인 admin 이 있으면 "독촉" 섹션 추가 (이름 명시, 가볍게 — 죄책감 X, 동기부여 톤) +- 전체 500자 이내, plain text (마크다운 금지) - 형식 (정확히 이 들여쓰기/기호 사용): ✨ 하이라이트 @@ -98,7 +174,10 @@ PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로 • 내용 요약 (#PR번호) ⚠️ 주의 -• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략)' +• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략) + +📣 독촉 — (needs_nag=true 인 admin 이 있을 때만 등장, 없으면 섹션 전체 생략) +• [이름]님 어제 검수 한 건도 없네요. 오늘은 한 건만이라도 부탁드려요!' CLAUDE_REQ=$(jq -n \ --arg model "claude-haiku-4-5-20251001" \ @@ -176,6 +255,18 @@ if [ -n "$top_issues" ]; then BODY+="📌 new issues (${ISSUES_COUNT})"$'\n'"$top_issues"$'\n\n' fi +# admin verify table (deterministic — Claude 요약과 별개로 항상 노출) +verify_table=$(echo "$VERIFY_STATS_JSON" | jq -r ' + if length == 0 then "" else + map("• \(.display_name // .username): 어제 \(.yesterday) / 7d \(.last_7d)\(if .needs_nag then " ⚠️" else "" end)") + | join("\n") + end') +if [ -n "$verify_table" ]; then + vy=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.yesterday') + v7=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.last_7d') + BODY+="🔍 admin verify (어제 ${vy} / 7d ${v7})"$'\n'"$verify_table"$'\n\n' +fi + MSG=$(cat </dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client + fi + psql --version + - name: Run daily digest env: GH_TOKEN: ${{ github.token }} @@ -53,4 +63,6 @@ jobs: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} WINDOW_HOURS: ${{ github.event.inputs.window_hours || '24' }} + ASSETS_DATABASE_URL_RO: ${{ secrets.ASSETS_DATABASE_URL_RO }} + OPERATION_DATABASE_URL_RO: ${{ secrets.OPERATION_DATABASE_URL_RO }} run: bash .github/scripts/daily-digest.sh diff --git a/CLAUDE.md b/CLAUDE.md index ce1dd254..c83dfcd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,13 @@ Topic 질의(아키텍처 / API / DB / 디자인 시스템 / AI playbook)는 ** 요약: `feature/*` → `dev` → `main` 플로우. `main` 직접 push 금지, `dev`→`main` PR 머지만 허용. 긴급 시 `hotfix/*`→`main` 예외. 상세는 **[docs/GIT-WORKFLOW.md](docs/GIT-WORKFLOW.md)**. +## Commit discipline + +- 사용자가 코드/문서 수정을 요청하고 작업이 완료되면, 검증 후 관련 파일만 선별해 커밋한다. +- 커밋 금지는 `push`, `merge`, PR 병합 금지와 구분한다. 로컬 커밋은 기본 완료 조건이다. +- 더러운 워크트리에서는 unrelated 변경을 건드리지 말고, 이번 작업 파일만 `git add `로 스테이징한다. +- 커밋하지 않아야 하는 명시 요청, review-only/plan-only 모드, 또는 human checkpoint가 필요한 위험 작업이면 커밋하지 않고 이유를 남긴다. + ## Codebase documentation | 문서 | 내용 | diff --git a/packages/api-server/Cargo.toml b/packages/api-server/Cargo.toml index e4de5283..76e86c01 100644 --- a/packages/api-server/Cargo.toml +++ b/packages/api-server/Cargo.toml @@ -33,7 +33,7 @@ base64 = "0.22" tokio-cron-scheduler = "0.13" # Utils -uuid = { version = "1", features = ["v4", "serde"] } +uuid = { version = "1", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2" tracing = "0.1" diff --git a/packages/api-server/migration/src/lib.rs b/packages/api-server/migration/src/lib.rs index 41b2ea3f..785e134b 100644 --- a/packages/api-server/migration/src/lib.rs +++ b/packages/api-server/migration/src/lib.rs @@ -63,6 +63,7 @@ mod m20260502_000003_public_missing_tables_and_rls; mod m20260502_000004_embeddings_and_search_similar; mod m20260502_000005_magazine_approval_and_rpcs; mod m20260502_000006_backfill_public_columns; +mod m20260507_000001_create_content_studio_tables; pub struct Migrator; @@ -142,6 +143,7 @@ impl MigratorTrait for Migrator { Box::new(m20260502_000004_embeddings_and_search_similar::Migration), Box::new(m20260502_000005_magazine_approval_and_rpcs::Migration), Box::new(m20260502_000006_backfill_public_columns::Migration), + Box::new(m20260507_000001_create_content_studio_tables::Migration), ] } } diff --git a/packages/api-server/migration/src/m20260507_000001_create_content_studio_tables.rs b/packages/api-server/migration/src/m20260507_000001_create_content_studio_tables.rs new file mode 100644 index 00000000..4367c40b --- /dev/null +++ b/packages/api-server/migration/src/m20260507_000001_create_content_studio_tables.rs @@ -0,0 +1,90 @@ +use sea_orm_migration::prelude::*; + +/// Content Studio persistence tables for admin-generated channel drafts. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" + CREATE TABLE IF NOT EXISTS public.content_packets ( + id UUID PRIMARY KEY, + post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, + title TEXT NOT NULL, + hook TEXT NOT NULL, + risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')), + review_status TEXT NOT NULL DEFAULT 'draft' + CHECK (review_status IN ('draft', 'needs_review', 'approved', 'rejected')), + packet_json JSONB NOT NULL, + created_by UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (post_id) + ); + + CREATE TABLE IF NOT EXISTS public.content_variants ( + id UUID PRIMARY KEY, + packet_id UUID NOT NULL REFERENCES public.content_packets(id) ON DELETE CASCADE, + channel TEXT NOT NULL CHECK (channel IN ('instagram', 'youtube', 'x')), + format TEXT NOT NULL CHECK ( + format IN ('instagram_carousel', 'instagram_reel', 'youtube_shorts', 'x_thread') + ), + title TEXT NOT NULL, + body TEXT NOT NULL, + media_plan JSONB NOT NULL, + hashtags JSONB NOT NULL DEFAULT '[]'::jsonb, + disclosure TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'needs_review', 'approved', 'rejected')), + governance_result JSONB, + reviewed_by UUID REFERENCES public.users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (packet_id, format) + ); + + CREATE INDEX IF NOT EXISTS content_packets_review_status_idx + ON public.content_packets(review_status, updated_at DESC); + CREATE INDEX IF NOT EXISTS content_variants_packet_status_idx + ON public.content_variants(packet_id, status); + + ALTER TABLE public.content_packets ENABLE ROW LEVEL SECURITY; + ALTER TABLE public.content_variants ENABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS "admin_can_manage_content_packets" ON public.content_packets; + CREATE POLICY "admin_can_manage_content_packets" + ON public.content_packets FOR ALL + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + + DROP POLICY IF EXISTS "admin_can_manage_content_variants" ON public.content_variants; + CREATE POLICY "admin_can_manage_content_variants" + ON public.content_variants FOR ALL + USING (public.is_admin(auth.uid())) + WITH CHECK (public.is_admin(auth.uid())); + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" + DROP TABLE IF EXISTS public.content_variants; + DROP TABLE IF EXISTS public.content_packets; + "#, + ) + .await?; + + Ok(()) + } +} diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index e1eecb84..216e5e22 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -10,7 +10,7 @@ use super::{ badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots, - synonyms, + synonyms, verify_stats, }; use crate::domains::reports; @@ -58,6 +58,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/gemini-cost", gemini_cost::router(state.clone(), app_config.clone()), ) + .nest( + "/verify-stats", + verify_stats::router(state.clone(), app_config.clone()), + ) .nest("/badges", badges::router(app_config.clone())) .nest("/reports", reports::admin_router(app_config.clone())) .nest( diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index 4b124107..f3db17f4 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -20,6 +20,7 @@ pub mod posts; pub mod solutions; pub mod spots; pub mod synonyms; +pub mod verify_stats; pub use handlers::router; diff --git a/packages/api-server/src/domains/admin/verify_stats.rs b/packages/api-server/src/domains/admin/verify_stats.rs new file mode 100644 index 00000000..757a06cd --- /dev/null +++ b/packages/api-server/src/domains/admin/verify_stats.rs @@ -0,0 +1,250 @@ +//! Admin — verify observability. +//! +//! 어드민 (`users.is_admin=true`) 이 `assets.raw_posts.verified_by` 로 verify +//! 한 횟수를 today / yesterday / 7d / 30d 윈도우 별로 집계. +//! +//! cross-DB: 두 Supabase 프로젝트 분리 (#333) — assets 에서 카운트 + operation +//! 에서 admin 메타. 두 결과를 application 레벨에서 zip. +//! +//! 사용처: +//! - 어드민 UI `/admin/verify-stats` (chart + table) +//! - 어드민 home 의 작은 카드 (오늘 합계 + nag count) +//! - daily-digest 크론 메시지 (어제 admin 별 count + nag — `needs_nag=true`) + +use std::collections::HashMap; + +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +#[derive(Debug, Deserialize)] +pub struct DaysQuery { + #[serde(default)] + pub days: Option, +} + +#[derive(Debug, Serialize)] +pub struct AdminVerifyRow { + pub admin_id: String, + pub username: String, + pub display_name: Option, + pub email: String, + pub verify_count_today: i64, + pub verify_count_yesterday: i64, + pub verify_count_7d: i64, + pub verify_count_30d: i64, + pub last_verify_at: Option, + pub needs_nag: bool, // 어제 0건이면 true +} + +#[derive(Debug, Serialize)] +pub struct VerifyStatsTotals { + pub today: i64, + pub yesterday: i64, + pub last_7d: i64, + pub last_30d: i64, +} + +#[derive(Debug, Serialize)] +pub struct VerifyStatsResponse { + pub as_of: String, + pub admins: Vec, + pub totals: VerifyStatsTotals, + pub nag_admin_ids: Vec, +} + +pub async fn get_verify_stats( + State(state): State, + _user: axum::Extension, + Query(_q): Query, +) -> AppResult> { + let prod_db = state.db.as_ref(); + let assets_db = state.assets_db.as_ref(); + + // 1) operation DB — admin 목록 + let admins = fetch_admin_users(prod_db).await?; + if admins.is_empty() { + return Ok(Json(VerifyStatsResponse { + as_of: chrono::Utc::now().to_rfc3339(), + admins: vec![], + totals: VerifyStatsTotals { + today: 0, + yesterday: 0, + last_7d: 0, + last_30d: 0, + }, + nag_admin_ids: vec![], + })); + } + + // 2) assets DB — verified_by 별 윈도우 카운트 + let admin_ids: Vec = admins.iter().map(|a| a.id).collect(); + let counts = fetch_verify_counts(assets_db, &admin_ids).await?; + + // 3) zip + let mut admin_rows: Vec = admins + .into_iter() + .map(|a| { + let c = counts.get(&a.id).copied().unwrap_or_default(); + AdminVerifyRow { + admin_id: a.id.to_string(), + username: a.username, + display_name: a.display_name, + email: a.email, + verify_count_today: c.today, + verify_count_yesterday: c.yesterday, + verify_count_7d: c.last_7d, + verify_count_30d: c.last_30d, + last_verify_at: c.last_at.map(|t| t.to_rfc3339()), + needs_nag: c.yesterday == 0, + } + }) + .collect(); + // 정렬: 오늘 많이 한 순 → 어제 → 7d + admin_rows.sort_by(|a, b| { + b.verify_count_today + .cmp(&a.verify_count_today) + .then(b.verify_count_yesterday.cmp(&a.verify_count_yesterday)) + .then(b.verify_count_7d.cmp(&a.verify_count_7d)) + }); + + let totals = VerifyStatsTotals { + today: admin_rows.iter().map(|r| r.verify_count_today).sum(), + yesterday: admin_rows.iter().map(|r| r.verify_count_yesterday).sum(), + last_7d: admin_rows.iter().map(|r| r.verify_count_7d).sum(), + last_30d: admin_rows.iter().map(|r| r.verify_count_30d).sum(), + }; + let nag_admin_ids: Vec = admin_rows + .iter() + .filter(|r| r.needs_nag) + .map(|r| r.admin_id.clone()) + .collect(); + + Ok(Json(VerifyStatsResponse { + as_of: chrono::Utc::now().to_rfc3339(), + admins: admin_rows, + totals, + nag_admin_ids, + })) +} + +struct AdminUser { + id: Uuid, + username: String, + display_name: Option, + email: String, +} + +async fn fetch_admin_users(db: &DatabaseConnection) -> AppResult> { + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, username, display_name, email + FROM public.users + WHERE is_admin = true + ORDER BY username", + Vec::::new(), + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(AdminUser { + id: r.try_get("", "id").map_err(AppError::DatabaseError)?, + username: r.try_get("", "username").map_err(AppError::DatabaseError)?, + display_name: r + .try_get("", "display_name") + .map_err(AppError::DatabaseError)?, + email: r.try_get("", "email").map_err(AppError::DatabaseError)?, + }); + } + Ok(out) +} + +#[derive(Default, Clone, Copy)] +struct VerifyCounts { + today: i64, + yesterday: i64, + last_7d: i64, + last_30d: i64, + last_at: Option>, +} + +async fn fetch_verify_counts( + db: &DatabaseConnection, + admin_ids: &[Uuid], +) -> AppResult> { + if admin_ids.is_empty() { + return Ok(HashMap::new()); + } + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT verified_by AS admin_id, + COUNT(*) FILTER ( + WHERE verified_at >= date_trunc('day', now()) + ) AS today, + COUNT(*) FILTER ( + WHERE verified_at >= date_trunc('day', now()) - interval '1 day' + AND verified_at < date_trunc('day', now()) + ) AS yesterday, + COUNT(*) FILTER ( + WHERE verified_at >= now() - interval '7 days' + ) AS last_7d, + COUNT(*) FILTER ( + WHERE verified_at >= now() - interval '30 days' + ) AS last_30d, + MAX(verified_at) AS last_at + FROM public.raw_posts + WHERE verified_by = ANY($1::uuid[]) + AND verified_at IS NOT NULL + AND verified_at >= now() - interval '30 days' + GROUP BY verified_by", + vec![admin_ids.to_vec().into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out: HashMap = HashMap::new(); + for r in rows { + let id: Uuid = r.try_get("", "admin_id").map_err(AppError::DatabaseError)?; + let today: i64 = r.try_get("", "today").map_err(AppError::DatabaseError)?; + let yesterday: i64 = r + .try_get("", "yesterday") + .map_err(AppError::DatabaseError)?; + let last_7d: i64 = r.try_get("", "last_7d").map_err(AppError::DatabaseError)?; + let last_30d: i64 = r.try_get("", "last_30d").map_err(AppError::DatabaseError)?; + let last_at: Option> = + r.try_get("", "last_at").map_err(AppError::DatabaseError)?; + out.insert( + id, + VerifyCounts { + today, + yesterday, + last_7d, + last_30d, + last_at, + }, + ); + } + Ok(out) +} + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/", get(get_verify_stats)) + .layer(axum::middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/api-server/src/domains/content_studio/dto.rs b/packages/api-server/src/domains/content_studio/dto.rs new file mode 100644 index 00000000..089982b4 --- /dev/null +++ b/packages/api-server/src/domains/content_studio/dto.rs @@ -0,0 +1,155 @@ +use chrono::{DateTime, FixedOffset}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateContentPacketRequest { + pub post_id: Uuid, +} + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GenerateVariantsRequest { + pub packet: ContentPacket, +} + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReviewVariantRequest { + pub packet: ContentPacket, + #[serde(default)] + pub variants: Vec, +} + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VariantStatusRequest { + pub variant: ContentVariant, +} + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContentPacketListQuery { + pub status: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ItemEntity { + pub id: String, + pub title: String, + pub brand: Option, + pub thumbnail_url: Option, + pub source_url: Option, + pub confidence: String, + pub verified: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContentAlternatives { + pub budget: Vec, + pub mid: Vec, + pub premium: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DisclosureFlags { + pub ai_generated: bool, + pub synthetic_media: bool, + pub sponsored: bool, + pub rights_risk: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContentPacket { + pub id: String, + pub post_id: String, + pub source_image: String, + pub title: String, + pub hook: String, + pub artist: Option, + pub group: Option, + pub context: Option, + pub detected_items: Vec, + pub style_summary: String, + pub why_it_works: String, + pub alternatives: ContentAlternatives, + pub disclosure_flags: DisclosureFlags, + pub risk_level: String, + pub review_status: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContentVariant { + pub id: String, + pub packet_id: String, + pub channel: String, + pub format: String, + pub title: String, + pub body: String, + pub media_plan: Value, + pub hashtags: Vec, + pub disclosure: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GovernanceResult { + pub verdict: String, + pub risk_level: String, + pub flags: Vec, + pub required_actions: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentPacketResponse { + pub packet: ContentPacket, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentVariantsResponse { + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct GovernanceResponse { + pub result: GovernanceResult, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentVariantResponse { + pub variant: ContentVariant, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentPacketDetailResponse { + pub packet: ContentPacket, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentPacketListItem { + pub id: Uuid, + pub post_id: Uuid, + pub title: String, + pub hook: String, + pub risk_level: String, + pub review_status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ContentPacketListResponse { + pub items: Vec, +} diff --git a/packages/api-server/src/domains/content_studio/handlers.rs b/packages/api-server/src/domains/content_studio/handlers.rs new file mode 100644 index 00000000..744bf1e7 --- /dev/null +++ b/packages/api-server/src/domains/content_studio/handlers.rs @@ -0,0 +1,123 @@ +use axum::{ + extract::{Path, Query, State}, + middleware, + routing::{get, post}, + Extension, Json, Router, +}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::AppResult, + middleware::auth::User, +}; + +use super::{ + dto::{ + ContentPacketDetailResponse, ContentPacketListQuery, ContentPacketListResponse, + ContentPacketResponse, ContentVariantResponse, ContentVariantsResponse, + CreateContentPacketRequest, GenerateVariantsRequest, GovernanceResponse, + ReviewVariantRequest, VariantStatusRequest, + }, + service, +}; + +pub async fn create_packet( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> AppResult> { + let packet = service::build_packet_for_post(state.db.as_ref(), req.post_id, user.id).await?; + Ok(Json(ContentPacketResponse { packet })) +} + +pub async fn list_packets( + State(state): State, + Query(query): Query, +) -> AppResult> { + let items = + service::list_packets(state.db.as_ref(), query.status, query.limit.unwrap_or(20)).await?; + Ok(Json(ContentPacketListResponse { items })) +} + +pub async fn get_packet( + State(state): State, + Path(id): Path, +) -> AppResult> { + let (packet, variants) = service::get_packet_detail(state.db.as_ref(), id).await?; + Ok(Json(ContentPacketDetailResponse { packet, variants })) +} + +pub async fn generate_variants( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let packet_id = format!("packet_{id}"); + if req.packet.id != packet_id { + return Err(crate::error::AppError::bad_request("Packet id mismatch")); + } + + let variants = service::generate_channel_variants(&req.packet); + service::persist_variants(state.db.as_ref(), &variants).await?; + Ok(Json(ContentVariantsResponse { variants })) +} + +pub async fn review_variant( + State(state): State, + Extension(user): Extension, + Path(_id): Path, + Json(req): Json, +) -> AppResult> { + let result = service::run_governance_check(&req.packet, &req.variants); + service::persist_governance(state.db.as_ref(), &req.variants, &result, user.id).await?; + Ok(Json(GovernanceResponse { result })) +} + +pub async fn approve_variant( + State(state): State, + Extension(user): Extension, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + if req.variant.id != id { + return Err(crate::error::AppError::bad_request("Variant id mismatch")); + } + + let variant = + service::update_variant_status(state.db.as_ref(), &id, "approved", None, user.id).await?; + Ok(Json(ContentVariantResponse { variant })) +} + +pub async fn reject_variant( + State(state): State, + Extension(user): Extension, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + if req.variant.id != id { + return Err(crate::error::AppError::bad_request("Variant id mismatch")); + } + + let variant = + service::update_variant_status(state.db.as_ref(), &id, "rejected", None, user.id).await?; + Ok(Json(ContentVariantResponse { variant })) +} + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/packets", post(create_packet).get(list_packets)) + .route("/packets/{id}", get(get_packet)) + .route("/packets/{id}/generate-variants", post(generate_variants)) + .route("/variants/{id}/review", post(review_variant)) + .route("/variants/{id}/approve", post(approve_variant)) + .route("/variants/{id}/reject", post(reject_variant)) + .layer(middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/api-server/src/domains/content_studio/mod.rs b/packages/api-server/src/domains/content_studio/mod.rs new file mode 100644 index 00000000..95cbbed6 --- /dev/null +++ b/packages/api-server/src/domains/content_studio/mod.rs @@ -0,0 +1,9 @@ +//! Content Studio domain. +//! +//! Admin-only persistence and generation for marketing content drafts. + +pub mod dto; +mod handlers; +mod service; + +pub use handlers::router; diff --git a/packages/api-server/src/domains/content_studio/service.rs b/packages/api-server/src/domains/content_studio/service.rs new file mode 100644 index 00000000..4ac7de69 --- /dev/null +++ b/packages/api-server/src/domains/content_studio/service.rs @@ -0,0 +1,605 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{ + entities::{ + content_packets, content_variants, posts, solutions, spots, ContentPackets, + ContentVariants, Posts, Solutions, Spots, + }, + error::{AppError, AppResult}, +}; + +use super::dto::{ + ContentAlternatives, ContentPacket, ContentPacketListItem, ContentVariant, DisclosureFlags, + GovernanceResult, ItemEntity, +}; + +fn json_string(metadata: &Option, key: &str) -> Option { + metadata + .as_ref() + .and_then(|value| value.as_object()) + .and_then(|obj| obj.get(key)) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn packet_uuid(packet_id: &str) -> AppResult { + let raw = packet_id.strip_prefix("packet_").unwrap_or(packet_id); + Uuid::parse_str(raw).map_err(|_| AppError::bad_request("Invalid packet id")) +} + +fn variant_uuid(variant_id: &str) -> AppResult { + let raw = variant_id + .strip_prefix("variant_") + .ok_or_else(|| AppError::bad_request("Invalid variant id"))?; + Uuid::parse_str(raw).map_err(|_| AppError::bad_request("Invalid variant id")) +} + +fn deterministic_variant_id(packet_id: Uuid, format: &str) -> Uuid { + Uuid::new_v5(&packet_id, format.as_bytes()) +} + +fn confidence_for_spot( + spot: &spots::Model, + top: Option<&solutions::Model>, + count: usize, +) -> String { + if top.map(|solution| solution.is_verified).unwrap_or(false) || spot.status == "verified" { + "high".to_string() + } else if count > 0 || top.is_some() { + "medium".to_string() + } else { + "low".to_string() + } +} + +fn item_list(items: &[ItemEntity]) -> String { + if items.is_empty() { + return "- Item identity needs editor review".to_string(); + } + + items + .iter() + .take(4) + .enumerate() + .map(|(idx, item)| { + let brand = item + .brand + .as_ref() + .map(|value| format!("{value} ")) + .unwrap_or_default(); + format!("{}. {}{}", idx + 1, brand, item.title) + }) + .collect::>() + .join("\n") +} + +fn build_hook(post: &posts::Model, items: &[ItemEntity]) -> String { + let subject = post + .artist_name + .as_ref() + .or(post.group_name.as_ref()) + .or(post.context.as_ref()) + .map(String::as_str) + .unwrap_or("This look"); + + if let Some(item) = items.first() { + format!( + "{}: the styling works because the {} sets the proportion.", + subject, item.title + ) + } else { + format!("{subject}: decode the item, silhouette, and styling logic.") + } +} + +fn build_why_it_works(post: &posts::Model, items: &[ItemEntity]) -> String { + if let Some(summary) = post.ai_summary.as_ref().map(|value| value.trim()) { + if !summary.is_empty() { + return summary.to_string(); + } + } + + if !items.is_empty() { + let names = items + .iter() + .take(3) + .map(|item| item.title.as_str()) + .collect::>() + .join(", "); + return format!( + "The look is anchored by {names}. Match the silhouette before chasing exact brands." + ); + } + + "No interpretation layer is available yet.".to_string() +} + +fn risk_level_for_post(post: &posts::Model) -> String { + if post.artist_name.is_some() || post.group_name.is_some() { + "medium".to_string() + } else if post.media_type == "ai_generated" { + "high".to_string() + } else { + "low".to_string() + } +} + +async fn detected_items_for_post( + db: &DatabaseConnection, + post_id: Uuid, +) -> AppResult> { + let post_spots = Spots::find() + .filter(spots::Column::PostId.eq(post_id)) + .all(db) + .await?; + + let mut items = Vec::with_capacity(post_spots.len()); + for spot in post_spots { + let spot_solutions = Solutions::find() + .filter(solutions::Column::SpotId.eq(spot.id)) + .filter(solutions::Column::Status.eq("active")) + .all(db) + .await?; + let top = spot_solutions.iter().max_by_key(|solution| { + (solution.is_adopted as i32) * 4 + (solution.is_verified as i32) * 2 + }); + + items.push(ItemEntity { + id: top + .map(|solution| solution.id) + .unwrap_or(spot.id) + .to_string(), + title: top + .map(|solution| solution.title.clone()) + .unwrap_or_else(|| "Unidentified item".to_string()), + brand: top.and_then(|solution| json_string(&solution.metadata, "brand")), + thumbnail_url: top.and_then(|solution| solution.thumbnail_url.clone()), + source_url: top.and_then(|solution| { + solution + .affiliate_url + .clone() + .or_else(|| solution.original_url.clone()) + }), + confidence: confidence_for_spot(&spot, top, spot_solutions.len()), + verified: top.map(|solution| solution.is_verified).unwrap_or(false) + || spot.status == "verified", + }); + } + + Ok(items) +} + +pub async fn build_packet_for_post( + db: &DatabaseConnection, + post_id: Uuid, + admin_user_id: Uuid, +) -> AppResult { + let post = Posts::find_by_id(post_id) + .one(db) + .await? + .ok_or_else(|| AppError::not_found("post"))?; + let detected_items = detected_items_for_post(db, post_id).await?; + let id = format!("packet_{post_id}"); + let now = Utc::now().to_rfc3339(); + let why_it_works = build_why_it_works(&post, &detected_items); + let style_summary = post + .ai_summary + .clone() + .unwrap_or_else(|| why_it_works.clone()); + let risk_level = risk_level_for_post(&post); + let rights_risk = post.artist_name.is_some() || post.group_name.is_some(); + let hook = build_hook(&post, &detected_items); + let title = post + .title + .clone() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + let joined = [post.artist_name.clone(), post.context.clone()] + .into_iter() + .flatten() + .collect::>() + .join(" / "); + (!joined.is_empty()).then_some(joined) + }) + .unwrap_or_else(|| "Untitled decoded post".to_string()); + + let packet = ContentPacket { + id, + post_id: post_id.to_string(), + source_image: post.image_url, + title, + hook, + artist: post.artist_name, + group: post.group_name, + context: post.context, + detected_items: detected_items.clone(), + style_summary, + why_it_works, + alternatives: ContentAlternatives { + budget: vec![], + mid: detected_items + .iter() + .filter(|item| item.confidence != "low") + .cloned() + .collect(), + premium: vec![], + }, + disclosure_flags: DisclosureFlags { + ai_generated: true, + synthetic_media: post.media_type == "ai_generated", + sponsored: false, + rights_risk, + }, + risk_level, + review_status: "draft".to_string(), + created_at: now, + }; + + persist_packet(db, &packet, admin_user_id).await?; + Ok(packet) +} + +pub async fn persist_packet( + db: &DatabaseConnection, + packet: &ContentPacket, + admin_user_id: Uuid, +) -> AppResult<()> { + let id = packet_uuid(&packet.id)?; + let post_id = Uuid::parse_str(&packet.post_id) + .map_err(|_| AppError::bad_request("Invalid packet post id"))?; + let packet_json = serde_json::to_value(packet) + .map_err(|e| AppError::internal(format!("Packet serialization error: {e}")))?; + + let existing = ContentPackets::find_by_id(id).one(db).await?; + let mut active = existing.map_or_else( + || content_packets::ActiveModel { + id: Set(id), + post_id: Set(post_id), + title: Set(packet.title.clone()), + hook: Set(packet.hook.clone()), + risk_level: Set(packet.risk_level.clone()), + review_status: Set(packet.review_status.clone()), + packet_json: Set(packet_json.clone()), + created_by: Set(admin_user_id), + ..Default::default() + }, + Into::into, + ); + active.title = Set(packet.title.clone()); + active.hook = Set(packet.hook.clone()); + active.risk_level = Set(packet.risk_level.clone()); + active.review_status = Set(packet.review_status.clone()); + active.packet_json = Set(packet_json); + active.updated_at = Set(Utc::now().into()); + active.save(db).await?; + Ok(()) +} + +pub fn generate_channel_variants(packet: &ContentPacket) -> Vec { + let tags = vec![ + "decoded".to_string(), + "styledecoder".to_string(), + "kfashion".to_string(), + ]; + let item_breakdown = item_list(&packet.detected_items); + let disclosure = if packet.disclosure_flags.rights_risk { + "AI-assisted style analysis. Source and rights require editor review before publishing." + } else { + "AI-assisted style analysis with editor review required before publishing." + } + .to_string(); + + vec![ + ContentVariant { + id: format!( + "variant_{}", + deterministic_variant_id(packet_uuid(&packet.id).unwrap_or_else(|_| Uuid::nil()), "instagram_carousel") + ), + packet_id: packet.id.clone(), + channel: "instagram".to_string(), + format: "instagram_carousel".to_string(), + title: packet.hook.clone(), + body: [ + "Slide 1: This look works because of proportion.".to_string(), + format!("Slide 2: {}", packet.title), + format!("Slide 3: Key items\n{item_breakdown}"), + format!("Slide 4: Silhouette\n{}", packet.why_it_works), + format!("Slide 5: Mood\n{}", packet.style_summary), + "Slide 6: Start with silhouette, then choose similar items.".to_string(), + "Slide 7: Budget / Mid / Premium alternatives need editor selection.".to_string(), + "Slide 8: Save or try the full decode in decoded.".to_string(), + ] + .join("\n\n"), + media_plan: json!({ + "sourceImage": packet.source_image, + "slides": ["hook", "full_look", "key_items", "silhouette", "mood", "how_to_wear", "alternatives", "decoded_cta"] + }), + hashtags: tags.clone(), + disclosure: disclosure.clone(), + status: "draft".to_string(), + }, + ContentVariant { + id: format!( + "variant_{}", + deterministic_variant_id(packet_uuid(&packet.id).unwrap_or_else(|_| Uuid::nil()), "instagram_reel") + ), + packet_id: packet.id.clone(), + channel: "instagram".to_string(), + format: "instagram_reel".to_string(), + title: packet.hook.clone(), + body: format!( + "Hook: {}\n\nDecode: {}\n\nBreakdown:\n{}\n\nCTA: Full decode in decoded.", + packet.hook, packet.why_it_works, item_breakdown + ), + media_plan: json!({ + "sourceImage": packet.source_image, + "beats": ["hook", "look", "items", "why_it_works", "cta"] + }), + hashtags: tags, + disclosure: disclosure.clone(), + status: "draft".to_string(), + }, + ContentVariant { + id: format!( + "variant_{}", + deterministic_variant_id(packet_uuid(&packet.id).unwrap_or_else(|_| Uuid::nil()), "youtube_shorts") + ), + packet_id: packet.id.clone(), + channel: "youtube".to_string(), + format: "youtube_shorts".to_string(), + title: format!("Why {} works", packet.title), + body: format!( + "{}\n\n{}\n\ndecoded found these core items:\n{}\n\nTo wear it yourself, match the silhouette before the exact brand.", + packet.hook, packet.why_it_works, item_breakdown + ), + media_plan: json!({ + "sourceImage": packet.source_image, + "shots": ["opening_claim", "zoom_items", "fit_logic", "cta"] + }), + hashtags: vec![ + "decoded".to_string(), + "styleanalysis".to_string(), + "kpopfashion".to_string(), + ], + disclosure: disclosure.clone(), + status: "draft".to_string(), + }, + ContentVariant { + id: format!( + "variant_{}", + deterministic_variant_id(packet_uuid(&packet.id).unwrap_or_else(|_| Uuid::nil()), "x_thread") + ), + packet_id: packet.id.clone(), + channel: "x".to_string(), + format: "x_thread".to_string(), + title: packet.hook.clone(), + body: format!( + "1/ {}\n\n2/ The key is not only the brand. {}\n\n3/ decoded item read:\n{}\n\n4/ To recreate it, start with silhouette and proportion.\n\n5/ Full decode: [link]", + packet.hook, packet.why_it_works, item_breakdown + ), + media_plan: json!({ + "sourceImage": packet.source_image, + "posts": 5 + }), + hashtags: vec!["decoded".to_string(), "styledecoder".to_string()], + disclosure, + status: "draft".to_string(), + }, + ] +} + +pub async fn persist_variants( + db: &DatabaseConnection, + variants: &[ContentVariant], +) -> AppResult<()> { + for variant in variants { + let id = variant_uuid(&variant.id)?; + let packet_id = packet_uuid(&variant.packet_id)?; + let hashtags = serde_json::to_value(&variant.hashtags) + .map_err(|e| AppError::internal(format!("Hashtag serialization error: {e}")))?; + let existing = ContentVariants::find_by_id(id).one(db).await?; + let mut active = existing.map_or_else( + || content_variants::ActiveModel { + id: Set(id), + packet_id: Set(packet_id), + channel: Set(variant.channel.clone()), + format: Set(variant.format.clone()), + title: Set(variant.title.clone()), + body: Set(variant.body.clone()), + media_plan: Set(variant.media_plan.clone()), + hashtags: Set(hashtags.clone()), + disclosure: Set(variant.disclosure.clone()), + status: Set(variant.status.clone()), + ..Default::default() + }, + Into::into, + ); + active.title = Set(variant.title.clone()); + active.body = Set(variant.body.clone()); + active.media_plan = Set(variant.media_plan.clone()); + active.hashtags = Set(hashtags); + active.disclosure = Set(variant.disclosure.clone()); + active.status = Set(variant.status.clone()); + active.updated_at = Set(Utc::now().into()); + active.save(db).await?; + } + Ok(()) +} + +pub fn run_governance_check( + packet: &ContentPacket, + variants: &[ContentVariant], +) -> GovernanceResult { + let mut flags = Vec::new(); + let mut required_actions = Vec::new(); + let has_interpretation = !packet.detected_items.is_empty() + || (!packet.style_summary.trim().is_empty() + && packet.style_summary != "No interpretation layer is available yet."); + + if !has_interpretation { + flags.push("thin_repost".to_string()); + required_actions.push("Add interpretation layer before publishing".to_string()); + } + if packet.disclosure_flags.rights_risk { + flags.push("celebrity_likeness_review".to_string()); + required_actions.push("Human review before publishing".to_string()); + } + if packet.disclosure_flags.synthetic_media { + flags.push("synthetic_media_disclosure".to_string()); + required_actions.push("Add synthetic media disclosure".to_string()); + } + if packet + .detected_items + .iter() + .any(|item| item.confidence == "low") + { + flags.push("low_confidence_item".to_string()); + required_actions.push("Verify low-confidence item identity".to_string()); + } + if variants + .iter() + .any(|variant| variant.disclosure.trim().is_empty()) + { + flags.push("missing_disclosure".to_string()); + required_actions.push("Add disclosure copy".to_string()); + } + + if flags.iter().any(|flag| flag == "thin_repost") { + GovernanceResult { + verdict: "reject".to_string(), + risk_level: "high".to_string(), + flags, + required_actions, + } + } else if packet.risk_level != "low" || !flags.is_empty() { + GovernanceResult { + verdict: "needs_review".to_string(), + risk_level: packet.risk_level.clone(), + flags, + required_actions, + } + } else { + GovernanceResult { + verdict: "approve".to_string(), + risk_level: "low".to_string(), + flags, + required_actions, + } + } +} + +pub async fn persist_governance( + db: &DatabaseConnection, + variants: &[ContentVariant], + result: &GovernanceResult, + admin_user_id: Uuid, +) -> AppResult<()> { + let value = serde_json::to_value(result) + .map_err(|e| AppError::internal(format!("Governance serialization error: {e}")))?; + for variant in variants { + update_variant_status( + db, + &variant.id, + "needs_review", + Some(value.clone()), + admin_user_id, + ) + .await?; + } + Ok(()) +} + +pub async fn update_variant_status( + db: &DatabaseConnection, + variant_id: &str, + status: &str, + governance_result: Option, + admin_user_id: Uuid, +) -> AppResult { + let id = variant_uuid(variant_id)?; + let row = ContentVariants::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| AppError::not_found("content variant"))?; + let mut active: content_variants::ActiveModel = row.into(); + active.status = Set(status.to_string()); + if governance_result.is_some() { + active.governance_result = Set(governance_result); + } + active.reviewed_by = Set(Some(admin_user_id)); + active.reviewed_at = Set(Some(Utc::now().into())); + active.updated_at = Set(Utc::now().into()); + let saved = active.update(db).await?; + Ok(variant_from_model(saved)) +} + +pub async fn get_packet_detail( + db: &DatabaseConnection, + packet_id: Uuid, +) -> AppResult<(ContentPacket, Vec)> { + let packet_row = ContentPackets::find_by_id(packet_id) + .one(db) + .await? + .ok_or_else(|| AppError::not_found("content packet"))?; + let packet: ContentPacket = serde_json::from_value(packet_row.packet_json) + .map_err(|e| AppError::internal(format!("Packet decode error: {e}")))?; + let variant_rows = ContentVariants::find() + .filter(content_variants::Column::PacketId.eq(packet_id)) + .order_by_asc(content_variants::Column::CreatedAt) + .all(db) + .await?; + + Ok(( + packet, + variant_rows.into_iter().map(variant_from_model).collect(), + )) +} + +pub async fn list_packets( + db: &DatabaseConnection, + status: Option, + limit: u64, +) -> AppResult> { + let mut query = ContentPackets::find().order_by_desc(content_packets::Column::UpdatedAt); + if let Some(status) = status { + query = query.filter(content_packets::Column::ReviewStatus.eq(status)); + } + let rows = query.paginate(db, limit.min(100)).fetch_page(0).await?; + + Ok(rows + .into_iter() + .map(|row| ContentPacketListItem { + id: row.id, + post_id: row.post_id, + title: row.title, + hook: row.hook, + risk_level: row.risk_level, + review_status: row.review_status, + created_at: row.created_at, + updated_at: row.updated_at, + }) + .collect()) +} + +fn variant_from_model(row: content_variants::Model) -> ContentVariant { + ContentVariant { + id: format!("variant_{}", row.id), + packet_id: format!("packet_{}", row.packet_id), + channel: row.channel, + format: row.format, + title: row.title, + body: row.body, + media_plan: row.media_plan, + hashtags: serde_json::from_value(row.hashtags).unwrap_or_default(), + disclosure: row.disclosure, + status: row.status, + } +} diff --git a/packages/api-server/src/domains/mod.rs b/packages/api-server/src/domains/mod.rs index 5bed8955..e6f34c78 100644 --- a/packages/api-server/src/domains/mod.rs +++ b/packages/api-server/src/domains/mod.rs @@ -6,6 +6,7 @@ pub mod admin; pub mod badges; pub mod categories; pub mod comments; +pub mod content_studio; pub mod earnings; pub mod editorial_articles_published; pub mod events; diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index dfb2cbce..2bb35fb9 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -18,7 +18,6 @@ pub struct MediaSourceDto { #[serde(rename = "type")] #[validate(length(min = 1, max = 128))] pub media_type: String, - /// 자연어 설명 (옵션) /// 예: "넷플릭스 드라마 ㅇㅇㅇ 시즌2 3화, 주인공이 카페에서..." /// AI가 이 설명에서 title과 metadata를 자동 추출합니다. @@ -59,15 +58,12 @@ pub struct CreateSpotDto { /// 위치 좌표 (왼쪽, 퍼센트) #[validate(length(min = 1))] pub position_left: String, - /// 위치 좌표 (위, 퍼센트) #[validate(length(min = 1))] pub position_top: String, - /// 서브카테고리 ID (`None`이면 미분류 `uncategorized`로 저장) #[serde(default)] pub subcategory_id: Option, - /// Solution 정보 (0개 이상) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub solutions: Vec, diff --git a/packages/api-server/src/domains/solutions/tests.rs b/packages/api-server/src/domains/solutions/tests.rs index 45f082e4..bce0aff8 100644 --- a/packages/api-server/src/domains/solutions/tests.rs +++ b/packages/api-server/src/domains/solutions/tests.rs @@ -230,6 +230,7 @@ mod mock_db_tests { status: Some("active".to_string()), spot_id: Some(fixtures::test_uuid(2)), user_id: Some(fixtures::test_uuid(10)), + has_url: None, sort: "recent".to_string(), pagination: Pagination::new(1, 20), has_url: None, @@ -258,6 +259,7 @@ mod mock_db_tests { status: None, spot_id: None, user_id: None, + has_url: None, sort: "popular".to_string(), pagination: Pagination::new(1, 20), has_url: None, @@ -284,6 +286,7 @@ mod mock_db_tests { status: None, spot_id: None, user_id: None, + has_url: None, sort: "verified".to_string(), pagination: Pagination::new(1, 20), has_url: None, @@ -310,6 +313,7 @@ mod mock_db_tests { status: None, spot_id: None, user_id: None, + has_url: None, sort: "adopted".to_string(), pagination: Pagination::new(1, 20), has_url: None, @@ -336,6 +340,7 @@ mod mock_db_tests { status: None, spot_id: None, user_id: None, + has_url: None, sort: "unknown_sort_key".to_string(), pagination: Pagination::new(1, 20), has_url: None, diff --git a/packages/api-server/src/entities/content_packets.rs b/packages/api-server/src/entities/content_packets.rs new file mode 100644 index 00000000..1f0c85b8 --- /dev/null +++ b/packages/api-server/src/entities/content_packets.rs @@ -0,0 +1,60 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Content Studio packet persisted from a decoded post. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "content_packets")] +pub struct Model { + #[sea_orm(primary_key, column_type = "Uuid")] + pub id: Uuid, + + pub post_id: Uuid, + + pub title: String, + + pub hook: String, + + pub risk_level: String, + + pub review_status: String, + + #[sea_orm(column_type = "JsonBinary")] + pub packet_json: Json, + + pub created_by: Uuid, + + pub created_at: DateTimeWithTimeZone, + + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::posts::Entity", + from = "Column::PostId", + to = "super::posts::Column::Id" + )] + Post, + + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::CreatedBy", + to = "super::users::Column::Id" + )] + CreatedByUser, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Post.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CreatedByUser.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/entities/content_variants.rs b/packages/api-server/src/entities/content_variants.rs new file mode 100644 index 00000000..fcc9e466 --- /dev/null +++ b/packages/api-server/src/entities/content_variants.rs @@ -0,0 +1,74 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Content Studio channel variant persisted under a packet. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "content_variants")] +pub struct Model { + #[sea_orm(primary_key, column_type = "Uuid")] + pub id: Uuid, + + pub packet_id: Uuid, + + pub channel: String, + + pub format: String, + + pub title: String, + + pub body: String, + + #[sea_orm(column_type = "JsonBinary")] + pub media_plan: Json, + + #[sea_orm(column_type = "JsonBinary")] + pub hashtags: Json, + + pub disclosure: String, + + pub status: String, + + #[sea_orm(nullable, column_type = "JsonBinary")] + pub governance_result: Option, + + #[sea_orm(nullable)] + pub reviewed_by: Option, + + #[sea_orm(nullable)] + pub reviewed_at: Option, + + pub created_at: DateTimeWithTimeZone, + + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::content_packets::Entity", + from = "Column::PacketId", + to = "super::content_packets::Column::Id" + )] + Packet, + + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::ReviewedBy", + to = "super::users::Column::Id" + )] + ReviewedByUser, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Packet.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReviewedByUser.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/entities/mod.rs b/packages/api-server/src/entities/mod.rs index 2d32ae71..305fa9d7 100644 --- a/packages/api-server/src/entities/mod.rs +++ b/packages/api-server/src/entities/mod.rs @@ -11,7 +11,9 @@ pub mod badges; pub mod categories; pub mod click_logs; pub mod comments; +pub mod content_packets; pub mod content_reports; +pub mod content_variants; pub mod curation_posts; pub mod curations; pub mod earnings; @@ -63,6 +65,14 @@ pub use comments::ActiveModel as CommentsActiveModel; pub use comments::Entity as Comments; pub use comments::Model as CommentsModel; +pub use content_packets::ActiveModel as ContentPacketsActiveModel; +pub use content_packets::Entity as ContentPackets; +pub use content_packets::Model as ContentPacketsModel; + +pub use content_variants::ActiveModel as ContentVariantsActiveModel; +pub use content_variants::Entity as ContentVariants; +pub use content_variants::Model as ContentVariantsModel; + pub use posts::ActiveModel as PostsActiveModel; pub use posts::Entity as Posts; pub use posts::Model as PostsModel; diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs index 2d97b227..877d9cda 100644 --- a/packages/api-server/src/openapi.rs +++ b/packages/api-server/src/openapi.rs @@ -118,6 +118,8 @@ use utoipa::OpenApi; crate::domains::admin::spots::update_spot_subcategory, crate::domains::admin::solutions::list_solutions, crate::domains::admin::solutions::update_solution_status, + crate::domains::admin::solutions::edit_solution, + crate::domains::admin::solutions::research_url, crate::domains::admin::badges::create_badge, crate::domains::admin::badges::update_badge, crate::domains::admin::badges::delete_badge, @@ -125,14 +127,21 @@ use utoipa::OpenApi; // #258 Raw posts crate::domains::raw_posts::handlers::list_sources, crate::domains::raw_posts::handlers::create_source, + crate::domains::raw_posts::handlers::bulk_create_sources, crate::domains::raw_posts::handlers::update_source, crate::domains::raw_posts::handlers::delete_source, crate::domains::raw_posts::handlers::list_items, crate::domains::raw_posts::handlers::get_item, + crate::domains::raw_posts::handlers::delete_item, crate::domains::raw_posts::handlers::verify, crate::domains::raw_posts::handlers::trigger_source, crate::domains::raw_posts::handlers::reparse_item, crate::domains::raw_posts::handlers::stats, + crate::domains::raw_posts::handlers::pipeline_stats, + crate::domains::raw_posts::handlers::pipeline_health, + crate::domains::raw_posts::handlers::update_pipeline_setting, + crate::domains::raw_posts::handlers::vision_filter_log, + crate::domains::raw_posts::handlers::list_events, ), tags( (name = "health", description = "Health check endpoints"), diff --git a/packages/api-server/src/router.rs b/packages/api-server/src/router.rs index 74125123..5f96851c 100644 --- a/packages/api-server/src/router.rs +++ b/packages/api-server/src/router.rs @@ -20,6 +20,10 @@ pub fn build_api_router(state: AppState) -> Router { .nest("/search", domains::search::router(config.clone())) .nest("/feed", domains::feed::router(config.clone())) .nest("/events", domains::events::router(config.clone())) + .nest( + "/content", + domains::content_studio::router(state.clone(), config.clone()), + ) .nest("/rankings", domains::rankings::router(config.clone())) .nest("/badges", domains::badges::router(config.clone())) .nest("/earnings", domains::earnings::router(config.clone())) diff --git a/packages/api-server/src/tests/architecture.rs b/packages/api-server/src/tests/architecture.rs index 1ae41cfd..7ad8f2da 100644 --- a/packages/api-server/src/tests/architecture.rs +++ b/packages/api-server/src/tests/architecture.rs @@ -407,6 +407,8 @@ const DTO_NAME_EXCEPTIONS: &[&str] = &[ "AdoptSolutionDto", // #333 admin verify 액션 입력 "VerifyRawPostDto", + // #366 bulk source registration action input + "BulkCreateRawPostSourcesDto", ]; fn is_acceptable_input_dto_name(name: &str) -> bool { diff --git a/packages/web/app/admin/content-studio/AssetPanel.tsx b/packages/web/app/admin/content-studio/AssetPanel.tsx new file mode 100644 index 00000000..5c9e99fb --- /dev/null +++ b/packages/web/app/admin/content-studio/AssetPanel.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useState } from "react"; +import { + AlertTriangle, + Copy, + Download, + Image as ImageIcon, + Loader2, + Wand2, +} from "lucide-react"; +import type { + AssetPlan, + AssetTargetFormat, + ContentPacket, + ContentVariant, + ResearchRun, +} from "@/lib/content-studio"; + +type Props = { + packet: ContentPacket | null; + researchRun: ResearchRun | null; + variants: ContentVariant[]; + useResearchInCopy: boolean; +}; + +const ALL_FORMATS: AssetTargetFormat[] = [ + "instagram_feed", + "instagram_story", + "instagram_carousel_cover", + "youtube_thumbnail", + "pinterest_pin", +]; + +function formatLabel(format: AssetTargetFormat) { + return format.replace(/_/g, " "); +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && typeof data.error === "string" + ? data.error + : `HTTP ${response.status}`; + throw new Error(message); + } + return data as T; +} + +function copyText(text: string) { + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(text).catch(() => {}); + } +} + +export function AssetPanel({ + packet, + researchRun, + variants, + useResearchInCopy, +}: Props) { + const [selected, setSelected] = useState([ + "instagram_feed", + ]); + const [plan, setPlan] = useState(null); + const [warning, setWarning] = useState(null); + const [error, setError] = useState(null); + const [state, setState] = useState<"idle" | "running" | "error">("idle"); + const [imageState, setImageState] = useState<"idle" | "running" | "error">( + "idle" + ); + const [imageError, setImageError] = useState(null); + const [useReferenceImages, setUseReferenceImages] = useState(true); + const [embedHeadline, setEmbedHeadline] = useState(true); + const [imageFailures, setImageFailures] = useState< + Array<{ id: string; error: string }> + >([]); + + function toggleFormat(format: AssetTargetFormat) { + setSelected((current) => + current.includes(format) + ? current.filter((item) => item !== format) + : [...current, format] + ); + } + + async function generate() { + if (!packet || selected.length === 0) return; + setState("running"); + setError(null); + try { + const data = await postJson<{ + plan: AssetPlan; + warning: string | null; + }>("/api/v1/content/assets/plan", { + packet, + researchRun: researchRun ?? undefined, + variants, + assetTypes: selected, + useResearchInCopy, + embedHeadline, + }); + setPlan(data.plan); + setWarning(data.warning); + setState("idle"); + } catch (err) { + setError(err instanceof Error ? err.message : "Asset plan failed"); + setState("error"); + } + } + + async function generateImages() { + if (!plan) return; + setImageState("running"); + setImageError(null); + setImageFailures([]); + try { + const data = await postJson<{ + plan: AssetPlan; + failures: Array<{ id: string; error: string }>; + }>("/api/v1/content/assets/images", { + plan, + packet, + useReferenceImages, + }); + setPlan(data.plan); + setImageFailures(data.failures ?? []); + setImageState("idle"); + } catch (err) { + setImageError( + err instanceof Error ? err.message : "Image generation failed" + ); + setImageState("error"); + } + } + + if (!packet) return null; + + return ( +
+
+

+ Asset Generator +

+

+ Generate post-ready image prompts and overlay text for each format. +

+
+ +
+ {ALL_FORMATS.map((format) => ( + + ))} +
+ +
+ +
+ +
+ +
+ + {error && ( +
+ + {error} +
+ )} + {warning && ( +
+ + {warning} +
+ )} + + {plan && ( +
+
+ + + + Calls OpenAI Image API for each prompt. Cost applies. + +
+ + {imageError && ( +
+ + {imageError} +
+ )} + {imageFailures.length > 0 && ( +
+

Some images failed:

+
    + {imageFailures.map((failure) => ( +
  • + {failure.id}: {failure.error} +
  • + ))} +
+
+ )} + + {plan.imageAssets.map((asset) => ( +
+
+

+ {formatLabel(asset.format)} / {asset.size} +

+ +
+ {asset.previewUrl && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {asset.altText} +
+ + Download + +
+ )} +

+ {asset.prompt} +

+
+ ))} + + {plan.overlayText.length > 0 && ( +
+

+ Overlay text +

+
+ {plan.overlayText.map((overlay) => ( +
+
+

+ {formatLabel(overlay.format)} +

+ +
+

+ {overlay.headline} +

+ {overlay.subheadline && ( +

+ {overlay.subheadline} +

+ )} +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/web/app/admin/content-studio/ResearchPanel.tsx b/packages/web/app/admin/content-studio/ResearchPanel.tsx new file mode 100644 index 00000000..f9a11f71 --- /dev/null +++ b/packages/web/app/admin/content-studio/ResearchPanel.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + Loader2, + Search, + TrendingUp, +} from "lucide-react"; +import { + suggestResearchQueries, + type ContentPacket, + type ResearchRun, + type ResearchSourceType, +} from "@/lib/content-studio"; + +type Props = { + packet: ContentPacket | null; + run: ResearchRun | null; + warning: string | null; + useResearchInCopy: boolean; + onUseResearchInCopyChange: (value: boolean) => void; + onRunComplete: (run: ResearchRun, warning: string | null) => void; +}; + +const SOURCE_TYPES: ResearchSourceType[] = ["style_trend", "channel_format"]; + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && typeof data.message === "string" + ? data.message + : data && typeof data.error === "string" + ? data.error + : `HTTP ${response.status}`; + throw new Error(message); + } + return data as T; +} + +function formatSourceType(type: ResearchSourceType) { + return type === "style_trend" ? "Style trends" : "Channel formats"; +} + +export function ResearchPanel({ + packet, + run, + warning, + useResearchInCopy, + onUseResearchInCopyChange, + onRunComplete, +}: Props) { + const suggestions = useMemo( + () => (packet ? suggestResearchQueries(packet) : []), + [packet] + ); + const [query, setQuery] = useState(""); + const [sourceTypes, setSourceTypes] = + useState(SOURCE_TYPES); + const [maxResults, setMaxResults] = useState(3); + const [state, setState] = useState<"idle" | "running" | "error">("idle"); + const [error, setError] = useState(null); + const currentPacketIdRef = useRef(packet?.id ?? null); + currentPacketIdRef.current = packet?.id ?? null; + + useEffect(() => { + setQuery(suggestions[0] ?? ""); + setSourceTypes(SOURCE_TYPES); + setError(null); + setState("idle"); + }, [suggestions]); + + function toggleSourceType(type: ResearchSourceType) { + setSourceTypes((current) => + current.includes(type) + ? current.filter((item) => item !== type) + : [...current, type] + ); + } + + async function runResearch() { + if (!packet || !query.trim() || sourceTypes.length === 0) return; + const requestPacketId = packet.id; + + setState("running"); + setError(null); + + try { + const data = await postJson<{ + run: ResearchRun; + warning?: string | null; + }>("/api/v1/content/research", { + packet, + query: query.trim(), + sourceTypes, + maxResults, + }); + if ( + currentPacketIdRef.current !== requestPacketId || + data.run.packetId !== requestPacketId + ) { + return; + } + onRunComplete(data.run, data.warning ?? null); + setState("idle"); + } catch (err) { + if (currentPacketIdRef.current !== requestPacketId) return; + setError(err instanceof Error ? err.message : "Research failed"); + setState("error"); + } + } + + if (!packet) return null; + + return ( +
+
+
+

+ Research Panel +

+

+ Source-backed style and channel-format signals. +

+
+ +
+ +
+ + + +
+ + {suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} + +
+ {SOURCE_TYPES.map((type) => ( + + ))} +
+ + {!useResearchInCopy && ( +

+ Research affects recommendations only until copy usage is enabled. +

+ )} + {error && ( +
+ + {error} +
+ )} + {warning && ( +
+ + {warning} +
+ )} + + {run && ( +
+
+
+

+ + Run {run.status} +

+

+ + Trend signal {run.recommendations.externalTrendSignal} +

+
+

{run.query}

+
+ + {run.recommendations.recommendedChannels.length > 0 && ( +
+

+ Recommendations +

+
    + {run.recommendations.recommendedChannels.map( + (recommendation) => ( +
  • + + {recommendation.format} + + : {recommendation.reason} +
  • + ) + )} +
+
+ )} + + {run.sources.length > 0 && ( + + )} + + {run.insights.length > 0 && ( +
+

+ Insights +

+
+ {run.insights.map((insight) => { + const evidenceSources = insight.evidenceRefs + .map((ref) => run.sources.find((s) => s.id === ref)) + .filter( + (s): s is (typeof run.sources)[number] => s !== undefined + ); + return ( +
+

+ {insight.topic} +

+

+ {insight.summary} +

+

+ {insight.claimType} / {insight.confidence} +

+ {evidenceSources.length > 0 && ( +
+ {evidenceSources.map((source) => ( + + {source.domain} + + ))} +
+ )} +
+ ); + })} +
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/web/app/admin/content-studio/ShortFormPanel.tsx b/packages/web/app/admin/content-studio/ShortFormPanel.tsx new file mode 100644 index 00000000..1ad4b882 --- /dev/null +++ b/packages/web/app/admin/content-studio/ShortFormPanel.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState } from "react"; +import { AlertTriangle, Copy, Loader2, Video } from "lucide-react"; +import type { + ContentPacket, + ContentVariant, + ResearchRun, + ShortFormPlan, + ShortFormPlatform, +} from "@/lib/content-studio"; + +type Props = { + packet: ContentPacket | null; + researchRun: ResearchRun | null; + variants: ContentVariant[]; + useResearchInCopy: boolean; +}; + +const PLATFORMS: ShortFormPlatform[] = ["instagram_reel", "youtube_shorts"]; +const DURATIONS = [10, 15, 20, 30, 45, 60]; + +function platformLabel(platform: ShortFormPlatform) { + return platform === "instagram_reel" ? "Instagram Reel" : "YouTube Shorts"; +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && typeof data.error === "string" + ? data.error + : `HTTP ${response.status}`; + throw new Error(message); + } + return data as T; +} + +function copyText(text: string) { + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(text).catch(() => {}); + } +} + +export function ShortFormPanel({ + packet, + researchRun, + variants, + useResearchInCopy, +}: Props) { + const [platform, setPlatform] = useState("youtube_shorts"); + const [duration, setDuration] = useState(20); + const [plan, setPlan] = useState(null); + const [warning, setWarning] = useState(null); + const [error, setError] = useState(null); + const [state, setState] = useState<"idle" | "running" | "error">("idle"); + + async function generate() { + if (!packet) return; + setState("running"); + setError(null); + try { + const data = await postJson<{ + plan: ShortFormPlan; + warning: string | null; + }>("/api/v1/content/assets/plan", { + packet, + researchRun: researchRun ?? undefined, + variants, + platform, + durationSeconds: duration, + useResearchInCopy, + }); + setPlan(data.plan); + setWarning(data.warning); + setState("idle"); + } catch (err) { + setError(err instanceof Error ? err.message : "Short form plan failed"); + setState("error"); + } + } + + if (!packet) return null; + + return ( +
+
+

+ Short Form Builder +

+

+ Plan voiceover and scenes for Reels and Shorts. +

+
+ +
+ + + +
+ + {error && ( +
+ + {error} +
+ )} + {warning && ( +
+ + {warning} +
+ )} + + {plan && ( +
+
+
+

+ Hook · {platformLabel(plan.platform)} · {plan.durationSeconds}s +

+ +
+

{plan.hook}

+
+ +
+
+

+ Voiceover · {plan.voiceover.tone} ·{" "} + {plan.voiceover.targetReadingSeconds}s target +

+ +
+

+ {plan.voiceover.text} +

+
+ +
+

+ Scenes +

+
+ {plan.scenes.map((scene) => ( +
+

+ Scene {scene.order} · {scene.seconds}s +

+

+ {scene.onScreenText} +

+

+ {scene.visualDirection} +

+

+ Narration: {scene.narration} +

+
+ ))} +
+
+ +

CTA: {plan.cta}

+
+ )} +
+ ); +} diff --git a/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx new file mode 100644 index 00000000..86ee553d --- /dev/null +++ b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx @@ -0,0 +1,163 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, waitFor } from "@testing-library/react"; +// @ts-expect-error jsdom is installed in this workspace without declarations. +import { JSDOM } from "jsdom"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { AssetPanel } from "../AssetPanel"; +import type { ContentPacket } from "@/lib/content-studio"; + +let fallbackDom: JSDOM | null = null; +const originalGlobals = { + window: Object.getOwnPropertyDescriptor(globalThis, "window"), + document: Object.getOwnPropertyDescriptor(globalThis, "document"), + HTMLElement: Object.getOwnPropertyDescriptor(globalThis, "HTMLElement"), + Node: Object.getOwnPropertyDescriptor(globalThis, "Node"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), +}; + +if (typeof document === "undefined") { + fallbackDom = new JSDOM(""); + const globalWithDom = globalThis as typeof globalThis & { + window: Window & typeof globalThis; + document: Document; + HTMLElement: typeof HTMLElement; + Node: typeof Node; + navigator: Navigator; + }; + + globalWithDom.window = fallbackDom.window as Window & typeof globalThis; + globalWithDom.document = fallbackDom.window.document; + globalWithDom.HTMLElement = fallbackDom.window.HTMLElement; + globalWithDom.Node = fallbackDom.window.Node; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: fallbackDom.window.navigator, + }); +} + +function restoreGlobal(name: keyof typeof originalGlobals) { + const descriptor = originalGlobals[name]; + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +} + +const packet: ContentPacket = { + id: "packet-1", + postId: "post-1", + sourceImage: "https://example.com/image.jpg", + title: "Airport denim look", + hook: "Airport denim hook", + artist: null, + group: null, + context: "Airport", + detectedItems: [], + styleSummary: "Denim travel styling", + whyItWorks: "Clean proportions", + alternatives: { budget: [], mid: [], premium: [] }, + disclosureFlags: { + aiGenerated: false, + syntheticMedia: false, + sponsored: false, + rightsRisk: false, + }, + riskLevel: "low", + reviewStatus: "draft", + createdAt: "2026-05-07T00:00:00.000Z", +}; + +describe("AssetPanel", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + fallbackDom?.window.close(); + restoreGlobal("window"); + restoreGlobal("document"); + restoreGlobal("HTMLElement"); + restoreGlobal("Node"); + restoreGlobal("navigator"); + }); + + it("renders format toggles when a packet exists", () => { + const view = render( + + ); + + expect(view.getByLabelText("instagram feed")).toBeTruthy(); + expect(view.getByLabelText("youtube thumbnail")).toBeTruthy(); + expect( + view.getByRole("button", { name: "Generate Asset Plan" }) + ).toBeTruthy(); + }); + + it("submits an asset request and renders the returned plan", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan: { + id: "asset-plan-1", + packetId: "packet-1", + researchRunId: null, + status: "draft", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + imageAssets: [ + { + id: "instagram_feed_1", + format: "instagram_feed", + prompt: "Editorial airport denim still", + size: "1024x1280", + editMode: "generate", + previewUrl: null, + altText: "alt", + }, + ], + overlayText: [ + { + id: "instagram_feed_overlay_1", + format: "instagram_feed", + headline: "Airport denim hook", + subheadline: "Denim travel styling", + }, + ], + provenance: { + sourcePacketId: "packet-1", + sourceResearchRunId: null, + }, + }, + warning: null, + }), + } as Response); + + const view = render( + + ); + + fireEvent.click( + view.getByRole("button", { name: "Generate Asset Plan" }) + ); + + await waitFor(() => { + expect(view.getByText("Editorial airport denim still")).toBeTruthy(); + }); + expect( + view.getByLabelText("Copy prompt for instagram_feed") + ).toBeTruthy(); + }); +}); diff --git a/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx new file mode 100644 index 00000000..e8583dbf --- /dev/null +++ b/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx @@ -0,0 +1,286 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, waitFor } from "@testing-library/react"; +// @ts-expect-error jsdom is installed in this workspace without declarations. +import { JSDOM } from "jsdom"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { ResearchPanel } from "../ResearchPanel"; +import type { ContentPacket } from "@/lib/content-studio"; + +let fallbackDom: JSDOM | null = null; +const originalGlobals = { + window: Object.getOwnPropertyDescriptor(globalThis, "window"), + document: Object.getOwnPropertyDescriptor(globalThis, "document"), + HTMLElement: Object.getOwnPropertyDescriptor(globalThis, "HTMLElement"), + Node: Object.getOwnPropertyDescriptor(globalThis, "Node"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), +}; + +if (typeof document === "undefined") { + fallbackDom = new JSDOM(""); + const globalWithDom = globalThis as typeof globalThis & { + window: Window & typeof globalThis; + document: Document; + HTMLElement: typeof HTMLElement; + Node: typeof Node; + navigator: Navigator; + }; + + globalWithDom.window = fallbackDom.window as Window & typeof globalThis; + globalWithDom.document = fallbackDom.window.document; + globalWithDom.HTMLElement = fallbackDom.window.HTMLElement; + globalWithDom.Node = fallbackDom.window.Node; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: fallbackDom.window.navigator, + }); +} + +function restoreGlobal(name: keyof typeof originalGlobals) { + const descriptor = originalGlobals[name]; + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +} + +const packet: ContentPacket = { + id: "packet-1", + postId: "post-1", + sourceImage: "https://example.com/image.jpg", + title: "Airport denim look", + hook: "Airport denim look", + artist: "Sample Artist", + group: null, + context: "Airport", + detectedItems: [], + styleSummary: "Denim travel styling", + whyItWorks: "Clean proportions", + alternatives: { budget: [], mid: [], premium: [] }, + disclosureFlags: { + aiGenerated: false, + syntheticMedia: false, + sponsored: false, + rightsRisk: false, + }, + riskLevel: "low", + reviewStatus: "draft", + createdAt: "2026-05-07T00:00:00.000Z", +}; + +describe("ResearchPanel", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + fallbackDom?.window.close(); + restoreGlobal("window"); + restoreGlobal("document"); + restoreGlobal("HTMLElement"); + restoreGlobal("Node"); + restoreGlobal("navigator"); + }); + + it("shows suggested queries when a packet exists", () => { + const view = render( + {}} + onRunComplete={() => {}} + /> + ); + + expect(view.getByDisplayValue("Airport fashion trend")).toBeTruthy(); + expect(view.getByText("Use research in copy")).toBeTruthy(); + }); + + it("runs research and displays sources", async () => { + const onRunComplete = vi.fn(); + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + run: { + id: "run-1", + packetId: "packet-1", + query: "Airport fashion trend", + mode: "manual", + status: "completed", + createdAt: "2026-05-07T00:00:00.000Z", + sources: [ + { + id: "source-1", + runId: "run-1", + url: "https://www.vogue.com/article/a", + title: "Airport style", + domain: "www.vogue.com", + sourceType: "style_trend", + fetchedAt: "2026-05-07T00:00:00.000Z", + confidence: "high", + }, + ], + insights: [], + recommendations: { + externalTrendSignal: 0.7, + recommendedChannels: [], + }, + }, + warning: null, + }), + } as Response); + + const view = render( + {}} + onRunComplete={onRunComplete} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Run Research" })); + + await waitFor(() => { + expect(onRunComplete).toHaveBeenCalledWith( + expect.objectContaining({ id: "run-1" }), + null + ); + }); + }); + + it("resets source types when the packet changes", () => { + const nextPacket = { + ...packet, + id: "packet-2", + postId: "post-2", + context: "Editorial", + }; + const view = render( + {}} + onRunComplete={() => {}} + /> + ); + + fireEvent.click(view.getByLabelText("Style trends")); + fireEvent.click(view.getByLabelText("Channel formats")); + expect(view.getByRole("button", { name: "Run Research" })).toHaveProperty( + "disabled", + true + ); + + view.rerender( + {}} + onRunComplete={() => {}} + /> + ); + + expect(view.getByRole("button", { name: "Run Research" })).toHaveProperty( + "disabled", + false + ); + }); + + it("ignores stale research results for a previous packet", async () => { + let resolveFetch: (value: Response) => void = () => {}; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + const onRunComplete = vi.fn(); + vi.spyOn(global, "fetch").mockReturnValueOnce(fetchPromise); + + const view = render( + {}} + onRunComplete={onRunComplete} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Run Research" })); + view.rerender( + {}} + onRunComplete={onRunComplete} + /> + ); + + resolveFetch({ + ok: true, + json: async () => ({ + run: { + id: "run-1", + packetId: "packet-1", + query: "Airport fashion trend", + mode: "manual", + status: "completed", + createdAt: "2026-05-07T00:00:00.000Z", + sources: [], + insights: [], + recommendations: { + externalTrendSignal: 0, + recommendedChannels: [], + }, + }, + warning: null, + }), + } as Response); + + await waitFor(() => { + expect(onRunComplete).not.toHaveBeenCalled(); + }); + }); + + it("shows server research unavailability messages", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ + error: "research_unavailable", + message: "Content Studio research is disabled", + }), + } as Response); + + const view = render( + {}} + onRunComplete={() => {}} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Run Research" })); + + await waitFor(() => { + expect( + view.getByText("Content Studio research is disabled") + ).toBeTruthy(); + }); + }); +}); diff --git a/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx new file mode 100644 index 00000000..2a3013e1 --- /dev/null +++ b/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx @@ -0,0 +1,168 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, waitFor } from "@testing-library/react"; +// @ts-expect-error jsdom is installed in this workspace without declarations. +import { JSDOM } from "jsdom"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { ShortFormPanel } from "../ShortFormPanel"; +import type { ContentPacket } from "@/lib/content-studio"; + +let fallbackDom: JSDOM | null = null; +const originalGlobals = { + window: Object.getOwnPropertyDescriptor(globalThis, "window"), + document: Object.getOwnPropertyDescriptor(globalThis, "document"), + HTMLElement: Object.getOwnPropertyDescriptor(globalThis, "HTMLElement"), + Node: Object.getOwnPropertyDescriptor(globalThis, "Node"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), +}; + +if (typeof document === "undefined") { + fallbackDom = new JSDOM(""); + const globalWithDom = globalThis as typeof globalThis & { + window: Window & typeof globalThis; + document: Document; + HTMLElement: typeof HTMLElement; + Node: typeof Node; + navigator: Navigator; + }; + + globalWithDom.window = fallbackDom.window as Window & typeof globalThis; + globalWithDom.document = fallbackDom.window.document; + globalWithDom.HTMLElement = fallbackDom.window.HTMLElement; + globalWithDom.Node = fallbackDom.window.Node; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: fallbackDom.window.navigator, + }); +} + +function restoreGlobal(name: keyof typeof originalGlobals) { + const descriptor = originalGlobals[name]; + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +} + +const packet: ContentPacket = { + id: "packet-1", + postId: "post-1", + sourceImage: "https://example.com/image.jpg", + title: "Airport denim look", + hook: "Airport denim hook", + artist: null, + group: null, + context: "Airport", + detectedItems: [], + styleSummary: "Denim travel styling", + whyItWorks: "Clean proportions", + alternatives: { budget: [], mid: [], premium: [] }, + disclosureFlags: { + aiGenerated: false, + syntheticMedia: false, + sponsored: false, + rightsRisk: false, + }, + riskLevel: "low", + reviewStatus: "draft", + createdAt: "2026-05-07T00:00:00.000Z", +}; + +describe("ShortFormPanel", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + fallbackDom?.window.close(); + restoreGlobal("window"); + restoreGlobal("document"); + restoreGlobal("HTMLElement"); + restoreGlobal("Node"); + restoreGlobal("navigator"); + }); + + it("renders platform and duration controls when a packet exists", () => { + const view = render( + + ); + + expect(view.getByLabelText("Platform")).toBeTruthy(); + expect(view.getByLabelText("Duration seconds")).toBeTruthy(); + expect( + view.getByRole("button", { name: "Generate Short Form" }) + ).toBeTruthy(); + }); + + it("submits a short-form request and renders hook, voiceover, and scenes", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan: { + id: "short-form-1", + packetId: "packet-1", + researchRunId: null, + platform: "youtube_shorts", + durationSeconds: 20, + status: "draft", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + hook: "Why this airport look reads premium", + voiceover: { + text: "The silhouette does the work before the brand does.", + tone: "decoded_editorial", + targetReadingSeconds: 4, + }, + scenes: [ + { + id: "scene_1", + order: 1, + seconds: 4, + visualDirection: "Open with the full silhouette.", + onScreenText: "Silhouette first", + narration: "Open with the full silhouette.", + }, + ], + cta: "Save for reference", + provenance: { + sourcePacketId: "packet-1", + sourceResearchRunId: null, + }, + }, + warning: null, + }), + } as Response); + + const view = render( + + ); + + fireEvent.click( + view.getByRole("button", { name: "Generate Short Form" }) + ); + + await waitFor(() => { + expect( + view.getByText("Why this airport look reads premium") + ).toBeTruthy(); + }); + expect( + view.getByText( + "The silhouette does the work before the brand does." + ) + ).toBeTruthy(); + expect(view.getByTestId("scene-1")).toBeTruthy(); + }); +}); diff --git a/packages/web/app/admin/content-studio/page.tsx b/packages/web/app/admin/content-studio/page.tsx new file mode 100644 index 00000000..c30a09bd --- /dev/null +++ b/packages/web/app/admin/content-studio/page.tsx @@ -0,0 +1,683 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + Clipboard, + FileText, + Loader2, + ShieldCheck, + Sparkles, + XCircle, +} from "lucide-react"; +import { ResearchPanel } from "./ResearchPanel"; +import { AssetPanel } from "./AssetPanel"; +import { ShortFormPanel } from "./ShortFormPanel"; +import type { + ContentGenerationMode, + ContentPacket, + ContentVariant, + GovernanceResult, + ResearchRun, +} from "@/lib/content-studio"; + +type LoadState = "idle" | "loading" | "error"; + +type ContentPacketListItem = { + id: string; + post_id: string; + title: string; + hook: string; + risk_level: string; + review_status: string; + updated_at: string; +}; + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && typeof data.message === "string" + ? data.message + : data && typeof data.error === "string" + ? data.error + : `HTTP ${response.status}`; + throw new Error(message); + } + return data as T; +} + +async function getJson(url: string): Promise { + const response = await fetch(url, { + method: "GET", + credentials: "include", + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && typeof data.message === "string" + ? data.message + : data && typeof data.error === "string" + ? data.error + : `HTTP ${response.status}`; + throw new Error(message); + } + return data as T; +} + +function StatusPill({ + label, + tone, +}: { + label: string; + tone: "neutral" | "success" | "warning" | "danger"; +}) { + const toneClass = { + neutral: "bg-muted text-muted-foreground border-border", + success: + "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-300 dark:border-emerald-900", + warning: + "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900", + danger: + "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-900", + }[tone]; + + return ( + + {label} + + ); +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + async function copy() { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } + + return ( + + ); +} + +function PacketPanel({ packet }: { packet: ContentPacket }) { + return ( +
+
+
+
+

+ Content Packet +

+

{packet.hook}

+
+
+ + +
+
+
+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {packet.title} +
+
+
+
+

+ Artist +

+

+ {packet.artist || "-"} +

+
+
+

+ Context +

+

+ {packet.context || "-"} +

+
+
+

+ Items +

+

+ {packet.detectedItems.length} +

+
+
+ +
+

+ Why It Works +

+

+ {packet.whyItWorks} +

+
+ +
+

+ Detected Items +

+
+ {packet.detectedItems.map((item) => ( +
+
+

+ {item.brand ? `${item.brand} ` : ""} + {item.title} +

+

+ confidence: {item.confidence} +

+
+ {item.verified ? ( + + ) : ( + + )} +
+ ))} +
+
+
+
+
+ ); +} + +function GovernancePanel({ result }: { result: GovernanceResult | null }) { + if (!result) return null; + + const tone = + result.verdict === "approve" + ? "success" + : result.verdict === "reject" + ? "danger" + : "warning"; + const Icon = + result.verdict === "approve" + ? ShieldCheck + : result.verdict === "reject" + ? XCircle + : AlertTriangle; + + return ( +
+
+
+ +

+ Governance Check +

+
+ +
+ + {result.flags.length > 0 ? ( +
+
+

+ Flags +

+
    + {result.flags.map((flag) => ( +
  • {flag}
  • + ))} +
+
+
+

+ Required Actions +

+
    + {result.requiredActions.map((action) => ( +
  • {action}
  • + ))} +
+
+
+ ) : ( +

+ No automated risk flags were found. +

+ )} +
+ ); +} + +function VariantPanel({ + variants, + onStatusChange, +}: { + variants: ContentVariant[]; + onStatusChange: ( + variant: ContentVariant, + status: ContentVariant["status"] + ) => void; +}) { + if (variants.length === 0) return null; + + return ( +
+
+

+ Channel Variants +

+
+
+ {variants.map((variant) => ( +
+
+
+
+

+ {variant.format} +

+ + +
+

+ {variant.title} +

+ {(variant.llmModel || variant.promptVersion) && ( +

+ {[variant.llmModel, variant.promptVersion] + .filter(Boolean) + .join(" / ")} +

+ )} +
+
+ + + +
+
+
+              {variant.body}
+            
+

+ {variant.disclosure} +

+
+ ))} +
+
+ ); +} + +export default function ContentStudioPage() { + const [postId, setPostId] = useState(""); + const [packet, setPacket] = useState(null); + const [variants, setVariants] = useState([]); + const [recentPackets, setRecentPackets] = useState( + [] + ); + const [governance, setGovernance] = useState(null); + const [generationMode, setGenerationMode] = + useState("template"); + const [generationWarning, setGenerationWarning] = useState( + null + ); + const [researchRun, setResearchRun] = useState(null); + const [researchWarning, setResearchWarning] = useState(null); + const [useResearchInCopy, setUseResearchInCopy] = useState(false); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + + const canGenerate = useMemo( + () => !!packet && state !== "loading", + [packet, state] + ); + + async function loadRecentPackets() { + try { + const data = await getJson<{ items: ContentPacketListItem[] }>( + "/api/v1/content/packets?limit=10" + ); + setRecentPackets(data.items ?? []); + } catch { + setRecentPackets([]); + } + } + + useEffect(() => { + void loadRecentPackets(); + }, []); + + async function handleCreatePacket(event: FormEvent) { + event.preventDefault(); + setState("loading"); + setError(null); + setGovernance(null); + setGenerationWarning(null); + setResearchRun(null); + setResearchWarning(null); + setUseResearchInCopy(false); + setVariants([]); + + try { + const data = await postJson<{ packet: ContentPacket }>( + "/api/v1/content/packets", + { postId: postId.trim() } + ); + setPacket(data.packet); + await loadRecentPackets(); + setState("idle"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create packet"); + setState("error"); + } + } + + async function handleGenerateVariants() { + if (!packet) return; + setState("loading"); + setError(null); + setGenerationWarning(null); + + try { + const variantData = await postJson<{ + variants: ContentVariant[]; + mode?: ContentGenerationMode; + warning?: string | null; + }>(`/api/v1/content/packets/${packet.id}/generate-variants`, { + packet, + mode: generationMode, + locale: "ko-KR", + researchContext: researchRun + ? { + runId: researchRun.id, + sources: researchRun.sources, + insights: researchRun.insights, + } + : undefined, + useResearchInCopy, + }); + setVariants(variantData.variants); + setGenerationWarning(variantData.warning ?? null); + + const reviewData = await postJson<{ result: GovernanceResult }>( + "/api/v1/content/variants/draft/review", + { packet, variants: variantData.variants } + ); + setGovernance(reviewData.result); + setState("idle"); + await loadRecentPackets(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to generate variants" + ); + setState("error"); + } + } + + async function handleVariantStatusChange( + variant: ContentVariant, + status: ContentVariant["status"] + ) { + const action = status === "approved" ? "approve" : "reject"; + setError(null); + + try { + const data = await postJson<{ variant: ContentVariant }>( + `/api/v1/content/variants/${variant.id}/${action}`, + variant + ); + setVariants((current) => + current.map((item) => (item.id === variant.id ? data.variant : item)) + ); + await loadRecentPackets(); + } catch (err) { + setError( + err instanceof Error ? err.message : `Failed to ${action} variant` + ); + } + } + + async function handleOpenPacket(id: string) { + setState("loading"); + setError(null); + setResearchRun(null); + setResearchWarning(null); + setUseResearchInCopy(false); + + try { + const data = await getJson<{ + packet: ContentPacket; + variants: ContentVariant[]; + }>(`/api/v1/content/packets/${id}`); + setPacket(data.packet); + setPostId(data.packet.postId); + setVariants(data.variants); + setGovernance(null); + setState("idle"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to open packet"); + setState("error"); + } + } + + return ( +
+
+
+

+ Content Studio +

+

+ Create reviewable marketing drafts from an existing decoded post. +

+
+ +
+ +
+
+ +
+ + + +
+
+ + {error && ( +
+ {error} +
+ )} + {generationWarning && ( +
+ {generationWarning} +
+ )} +
+ + {recentPackets.length > 0 && ( +
+
+

+ Recent Packets +

+
+
+ {recentPackets.map((item) => ( + + ))} +
+
+ )} + + {packet && } + { + setResearchRun(run); + setResearchWarning(nextWarning); + setPacket((current) => + current && current.id === run.packetId + ? { + ...current, + externalEvidence: { + researchRunId: run.id, + sources: run.sources, + insights: run.insights, + }, + } + : current + ); + }} + /> + + + + +
+ ); +} diff --git a/packages/web/app/admin/layout.tsx b/packages/web/app/admin/layout.tsx index 27c9f39b..eb6375d3 100644 --- a/packages/web/app/admin/layout.tsx +++ b/packages/web/app/admin/layout.tsx @@ -28,7 +28,12 @@ export default async function AdminLayout({ }: { children: React.ReactNode; }) { - const supabase = await createSupabaseServerClient(); + let supabase: Awaited>; + try { + supabase = await createSupabaseServerClient(); + } catch { + return <>{children}; + } const { data: { session }, diff --git a/packages/web/app/admin/login/page.tsx b/packages/web/app/admin/login/page.tsx index c7c4dac3..a003aa82 100644 --- a/packages/web/app/admin/login/page.tsx +++ b/packages/web/app/admin/login/page.tsx @@ -1,9 +1,13 @@ "use client"; import { useState, useEffect } from "react"; -import { supabaseBrowserClient } from "@/lib/supabase/client"; +import { + hasSupabaseBrowserConfig, + supabaseBrowserClient, +} from "@/lib/supabase/client"; export default function AdminLoginPage() { + const isAuthConfigured = hasSupabaseBrowserConfig(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -20,6 +24,10 @@ export default function AdminLoginPage() { // 세션 sync 응답의 isAdmin 플래그로 분기해 비-admin 은 즉시 sign out 하고 // 에러를 표시한다. useEffect(() => { + if (!isAuthConfigured) { + return; + } + const { data: { subscription }, } = supabaseBrowserClient.auth.onAuthStateChange((event, session) => { @@ -54,9 +62,14 @@ export default function AdminLoginPage() { } }); return () => subscription.unsubscribe(); - }, []); + }, [isAuthConfigured]); const handleGoogleLogin = async () => { + if (!isAuthConfigured) { + setError("Admin auth environment variables are not configured."); + return; + } + setError(null); setLoading(true); @@ -79,6 +92,11 @@ export default function AdminLoginPage() { // proxy.ts 가 최신 쿠키를 본 상태에서 /admin 을 렌더하도록 한다. const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (!isAuthConfigured) { + setError("Admin auth environment variables are not configured."); + return; + } + setError(null); setLoading(true); @@ -144,11 +162,16 @@ export default function AdminLoginPage() { /> + {!isAuthConfigured && ( +

+ Admin auth is not configured in this environment. +

+ )} {error &&

{error}

} + + +
+ {(["style_trend", "channel_format"] as const).map((type) => ( + + ))} +
+ + {!useResearchInCopy && ( +

+ Research affects score and channel recommendations only. +

+ )} + {error &&

{error}

} + {warning &&

{warning}

} + + {run && ( +
+
+

+ Run {run.status} · trend signal {run.recommendations.externalTrendSignal} +

+

{run.query}

+
+ + + + {run.insights.map((insight) => ( +
+

{insight.topic}

+

{insight.summary}

+

+ {insight.claimType} · {insight.confidence} · evidence {insight.evidenceRefs.join(", ")} +

+
+ ))} +
+ )} + + ); +} +``` + +- [ ] **Step 4: Wire panel into page** + +In `app/admin/content-studio/page.tsx`, import: + +```ts +import { ResearchPanel } from "./ResearchPanel"; +import type { ResearchRun } from "@/lib/content-studio"; +``` + +Add state: + +```ts + const [researchRun, setResearchRun] = useState(null); + const [researchWarning, setResearchWarning] = useState(null); + const [useResearchInCopy, setUseResearchInCopy] = useState(false); +``` + +Reset it in `handleCreatePacket` before the API call: + +```ts + setResearchRun(null); + setResearchWarning(null); + setUseResearchInCopy(false); +``` + +Pass research fields in `handleGenerateVariants`: + +```ts + researchContext: researchRun + ? { + runId: researchRun.id, + insights: researchRun.insights, + } + : undefined, + useResearchInCopy, +``` + +Render the panel after `PacketPanel`: + +```tsx + {packet && } + { + setResearchRun(run); + setResearchWarning(nextWarning); + }} + /> +``` + +- [ ] **Step 5: Run panel tests** + +Run: `bun test app/admin/content-studio/__tests__/ResearchPanel.test.tsx` + +Expected: PASS. + +- [ ] **Step 6: Run Content Studio test set** + +Run: `bun test lib/content-studio/__tests__/research.test.ts lib/content-studio/__tests__/llm-generation.test.ts lib/content-studio/__tests__/content-studio.test.ts app/admin/content-studio/__tests__/ResearchPanel.test.tsx` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app/admin/content-studio/ResearchPanel.tsx app/admin/content-studio/__tests__/ResearchPanel.test.tsx app/admin/content-studio/page.tsx +git commit -m "feat(content-studio): add research panel ui" +``` + +## Task 6: Final Verification + +**Files:** +- Verify all files touched by Tasks 1-5 + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +bun test lib/content-studio/__tests__/research.test.ts lib/content-studio/__tests__/llm-generation.test.ts lib/content-studio/__tests__/content-studio.test.ts app/api/v1/content/research/__tests__/route.test.ts app/admin/content-studio/__tests__/ResearchPanel.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` + +Expected: PASS. + +- [ ] **Step 3: Run diff whitespace check** + +Run: `git diff --check` + +Expected: no output. + +- [ ] **Step 4: Start local dev server for browser QA** + +Run: `npm run dev` + +Expected: local Next.js server starts and prints a localhost URL. + +- [ ] **Step 5: Browser smoke with gstack** + +Use gstack browse against the local URL: + +```bash +$B goto http://localhost:3000/admin/content-studio +$B snapshot -i +$B console --errors +``` + +Expected: +- Page loads without client errors. +- Research Panel is visible. +- Generate Variants remains available when research has not run. + +- [ ] **Step 6: Confirm implementation work is committed** + +Run: `git status --short` + +Expected: no uncommitted files from this plan. If verification changed implementation files from this plan, stage the exact plan file set and commit: + +```bash +git add lib/content-studio/schemas.ts lib/content-studio/index.ts lib/content-studio/research lib/content-studio/llm-schemas.ts lib/content-studio/llm-client.ts lib/content-studio/llm-generation.ts lib/content-studio/governance-check.ts lib/content-studio/__tests__/research.test.ts lib/content-studio/__tests__/llm-generation.test.ts lib/content-studio/__tests__/content-studio.test.ts app/api/v1/content/research app/admin/content-studio/ResearchPanel.tsx app/admin/content-studio/__tests__/ResearchPanel.test.tsx app/admin/content-studio/page.tsx +git commit -m "fix(content-studio): finalize research panel verification" +``` + +## Self-Review + +Spec coverage: +- Suggested research queries: Task 2 and Task 5. +- Editable query and panel controls: Task 5. +- Firecrawl search through server API: Task 3. +- Domain allowlist/blocklist: Task 2 and Task 3. +- Limited source scrape/extract: Task 3. +- Source/insight normalization: Task 2. +- Source URL, confidence, run status display: Task 5. +- Opportunity signal and channel recommendations: Task 2. +- Research in copy only when enabled: Task 4 and Task 5. +- Governance for unsupported or source-free claims: Task 4. +- No DB writes, scheduler, saved queries, auto-publishing: all tasks avoid persistence and background jobs. + +Placeholder scan: +- No step relies on a later product decision. +- Commands include expected outcomes. +- File paths are exact. + +Type consistency: +- `ResearchRun`, `ResearchInsight`, `ResearchSource`, `ResearchRecommendations`, and `RunResearchRequest` originate in `lib/content-studio/schemas.ts`. +- `researchContext` uses `{ runId, insights }` in UI, route, generation, and tests. +- `researchProvenance` uses `{ researchRunId, useResearchInCopy, usedEvidenceRefs }` in generation and governance. diff --git a/packages/web/docs/superpowers/specs/2026-05-07-content-studio-research-panel-design.md b/packages/web/docs/superpowers/specs/2026-05-07-content-studio-research-panel-design.md new file mode 100644 index 00000000..5ab8e796 --- /dev/null +++ b/packages/web/docs/superpowers/specs/2026-05-07-content-studio-research-panel-design.md @@ -0,0 +1,461 @@ +# Content Studio Research Panel Design + +Date: 2026-05-07 +Status: Approved for implementation planning + +## Goal + +Extend decoded Content Studio v0.2 with a Firecrawl-backed marketing research panel that helps admins find current style and channel-format signals before generating marketing variants. + +The feature should make Content Studio more than a copy generator. It should add a source-grounded research layer that can influence opportunity scoring, channel recommendation, and, when explicitly enabled, LLM copy generation. + +## Current Context + +Content Studio currently supports this flow: + +```txt +Post ID +-> Create Packet +-> Generate Variants +-> Governance Check +-> Approve / Reject +``` + +Recent v0.2 work added: + +- Template / Hybrid LLM / LLM generation modes. +- Structured LLM output validation with Zod. +- Template fallback when LLM generation fails. +- Rule governance plus optional LLM governance merge. +- Variant metadata for generation mode, model, prompt version, and input hash. +- `externalTrendSignal` as an opportunity scoring input. + +The current LLM path is still best described as an LLM-assisted variant generator. The next step is to add a marketing research layer that produces source-backed inputs instead of ungrounded prompt context. + +## Scope + +Build a Research Panel MVP inside `/admin/content-studio`. + +The panel will: + +- Suggest research queries from the current content packet. +- Let the admin edit the query before running it. +- Run Firecrawl search through a server API. +- Filter search results through domain policy. +- Scrape/extract a limited number of sources. +- Normalize results into sources and insights. +- Show source URLs, confidence, extracted insights, and run status. +- Feed research into opportunity scoring and channel recommendation by default. +- Feed research into generated copy only when the admin enables `Use research in copy`. + +Initial research categories: + +- Style trends. +- Channel format trends. + +Out of scope for the MVP: + +- Brand/product trend claims. +- Scheduled runs. +- Saved queries. +- Database writes. +- Auto-publishing. +- Unfiltered external search results in LLM prompts. + +## Product Behavior + +The admin flow is: + +```txt +Create Packet +-> Suggested research queries appear +-> Admin edits query +-> Run Research +-> Research panel shows sources and insights +-> Admin optionally enables Use research in copy +-> Generate Variants +-> Governance checks unsupported or ungrounded claims +``` + +Default behavior is conservative: + +- Research affects opportunity score and recommended channels. +- Research does not directly change copy unless `Use research in copy` is enabled. +- Firecrawl failures do not block packet creation or variant generation. +- Existing template fallback remains intact. + +## Data Model + +The MVP does not write to the database, but API responses should match a future persistence model. + +```ts +type ResearchRun = { + id: string; + packetId: string; + query: string; + mode: "manual"; + status: "completed" | "partial" | "failed"; + createdAt: string; + sources: ResearchSource[]; + insights: ResearchInsight[]; + recommendations: ResearchRecommendations; +}; + +type ResearchSource = { + id: string; + runId: string; + url: string; + title: string | null; + domain: string; + sourceType: "style_trend" | "channel_format"; + fetchedAt: string; + confidence: "low" | "medium" | "high"; +}; + +type ResearchInsight = { + id: string; + runId: string; + sourceIds: string[]; + topic: string; + summary: string; + claimType: "trend" | "format_pattern" | "audience_signal"; + evidenceRefs: string[]; + confidence: "low" | "medium" | "high"; +}; + +type ResearchRecommendations = { + externalTrendSignal: number; + recommendedChannels: Array<{ + format: "instagram_carousel" | "instagram_reel" | "youtube_shorts" | "x_thread"; + reason: string; + evidenceRefs: string[]; + }>; +}; +``` + +`ContentPacket` should support optional external evidence without merging external facts directly into core decoded facts: + +```ts +externalEvidence?: { + researchRunId: string; + insights: ResearchInsight[]; +}; +``` + +`ContentVariant` should support claim-level governance: + +```ts +researchProvenance?: { + researchRunId: string; + useResearchInCopy: boolean; + usedEvidenceRefs: string[]; +}; + +claims?: Array<{ + text: string; + evidenceRefs: string[]; + confidence: number; + source: "packet" | "research" | "generated"; +}>; + +missingFacts?: string[]; +``` + +`researchProvenance` is required only when a generation request includes `researchContext`. +When `useResearchInCopy` is false, generated variants must either omit +`researchProvenance` or set `usedEvidenceRefs` to an empty array. + +## API Design + +Add: + +```txt +POST /api/v1/content/research +``` + +Request: + +```ts +{ + packet: ContentPacket; + query: string; + sourceTypes: Array<"style_trend" | "channel_format">; + maxResults: number; +} +``` + +Response: + +```ts +{ + run: ResearchRun; + warning?: string | null; +} +``` + +`run.recommendations.externalTrendSignal` is the only research-derived value that +feeds the existing opportunity score in the MVP. `run.recommendations.recommendedChannels` +is displayed in the Research Panel and may be used by the admin to select generation +channels, but the MVP does not silently change the channel set after research runs. + +Server flow: + +```txt +1. Validate request. +2. Check admin access. +3. Apply query and domain policy. +4. Run Firecrawl search. +5. Filter result URLs through allowlist/blocklist policy. +6. Scrape or extract the top N sources. +7. Normalize source records and insights. +8. Return ResearchRun. +``` + +Failure behavior: + +- Firecrawl token missing or research disabled: return `503 research_unavailable`. +- Search failure: return a failed run when no useful data exists. +- Some scrape failures: return a partial run with successful sources. +- Empty results: return completed run with empty `sources` and `insights`. + +## Query Generation + +Suggested queries should start as deterministic templates, not LLM output. + +Inputs: + +- Packet title. +- Artist or group, when present. +- Context. +- Detected item labels. +- Target categories: style trend and channel format. + +Example query templates: + +```txt +{context} fashion trend +{artist_or_group} style analysis +{item_title} styling trend +K-pop airport fashion trend +Instagram carousel fashion hook style analysis +YouTube Shorts fashion analysis format +X thread fashion commentary format +``` + +Admins can edit the query before running Firecrawl. If query quality becomes a bottleneck later, LLM-generated query suggestions can be added behind the same UI. + +## Firecrawl Policy + +Firecrawl is a research source collector, not a content writer. + +The MVP should enforce: + +- Search query length and character limits. +- Maximum result count. +- Timeout. +- Domain allowlist/blocklist. +- Per-source scrape/extract failure isolation. +- Source URL and fetched timestamp on every source. +- No source URL, no evidence claim. + +Default domain policy: + +- Deny by default when `CONTENT_STUDIO_RESEARCH_ENABLED` is false. +- Allow by policy when enabled, then remove blocked domains before scraping. +- Start with an explicit allowlist for editorial, platform, and search-result sources + approved for marketing research. Do not pass unknown domains into LLM prompts. +- Keep the allowlist/blocklist in code or env config, not in admin-editable UI, for + the MVP. If `CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS` is unset, use the + code-level default allowlist. +- Any insight from a filtered-out source must be dropped before recommendations or + copy generation. + +Suggested environment controls: + +```txt +CONTENT_STUDIO_RESEARCH_ENABLED=false +FIRECRAWL_TOKEN= +FIRECRAWL_API_KEY= +FIRECRAWL_API_BASE=https://api.firecrawl.dev +FIRECRAWL_MAX_RESULTS=5 +FIRECRAWL_TIMEOUT_MS=15000 +CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS= +CONTENT_STUDIO_RESEARCH_BLOCKED_DOMAINS= +CONTENT_STUDIO_RESEARCH_COPY_DEFAULT=false +``` + +Implementation should isolate Firecrawl behind a single adapter such as +`lib/content-studio/research/firecrawl-client.ts` so tests can mock search and scrape +without hitting the network. The adapter should call Firecrawl's HTTP API directly +instead of shelling out to a local CLI. + +## LLM Integration + +Variant generation accepts optional research context: + +```ts +{ + packet: ContentPacket; + mode: "template" | "hybrid" | "llm"; + locale: "ko-KR" | "en-US"; + researchContext?: { + runId: string; + insights: ResearchInsight[]; + }; + useResearchInCopy: boolean; +} +``` + +Rules: + +- If `useResearchInCopy` is false, research context is not passed into copy generation. +- If `useResearchInCopy` is true, only insights with evidence refs can be passed. +- LLM output must include claim-level evidence for factual or trend claims. +- Unsupported research claims should be flagged by governance. +- The generation input hash must include `researchContext.runId`, + `researchContext.insights`, and `useResearchInCopy` whenever research context is + present, even when copy use is disabled. +- The output variant must preserve `researchProvenance` so governance can distinguish + packet-grounded copy from research-grounded copy. + +## Governance + +Add rule-first checks before any LLM judge output can relax a decision. + +New flags: + +```txt +UNSUPPORTED_CLAIM +TREND_WITHOUT_SOURCE +RESEARCH_COPY_WITHOUT_EVIDENCE +LOW_CONFIDENCE_RESEARCH_CLAIM +``` + +Required behavior: + +- A variant claim with no evidence ref gets `UNSUPPORTED_CLAIM`. +- A trend statement sourced from research but missing a source URL gets `TREND_WITHOUT_SOURCE`. +- Copy that uses research while `useResearchInCopy` is false gets `RESEARCH_COPY_WITHOUT_EVIDENCE`. +- Low-confidence research claims require human review. +- A variant with `researchProvenance.useResearchInCopy=false` and non-empty + `usedEvidenceRefs` gets `RESEARCH_COPY_WITHOUT_EVIDENCE`. +- A research-sourced claim whose `evidenceRefs` do not map to an allowed + `ResearchSource.url` gets `TREND_WITHOUT_SOURCE`. + +The final governance decision remains conservative: + +```txt +approve < needs_review < reject +``` + +## UI Design + +Add a Research Panel inside the existing Content Studio screen. + +Panel states: + +- No packet: disabled placeholder. +- Packet ready: suggested queries visible. +- Running: loading state, generation buttons remain independent. +- Completed: sources and insights visible. +- Partial: show successful sources and warning. +- Failed: show error, variant generation still available. + +Controls: + +- Query input. +- Source type selection for style trend and channel format. +- Max results selector. +- Run Research button. +- Use research in copy toggle, default off. + +Display: + +- Run status. +- Query. +- Source list with domain, title, URL, confidence, fetchedAt. +- Insight list with topic, summary, claim type, confidence, evidence refs. +- Warning when research is not used in copy. + +## Testing + +Unit tests: + +- Suggested query generation. +- Domain allowlist/blocklist filtering. +- Firecrawl raw result normalization. +- Insight evidence ref validation. +- Research recommendation derivation for `externalTrendSignal` and channel reasons. +- `useResearchInCopy=false` excludes research from LLM prompt input. +- `useResearchInCopy=true` includes only evidence-backed insights. +- Generation hash changes when research context or `useResearchInCopy` changes. +- Unsupported claim governance. +- Research provenance governance. + +API tests: + +- Firecrawl disabled or missing returns 503. +- Empty search results return completed empty run. +- Partial scrape failure returns partial run. +- Invalid query/sourceTypes/maxResults are rejected. +- Filtered-out domains are not returned in sources, insights, or recommendations. + +UI smoke: + +- Enter Post ID. +- Create Packet. +- See suggested queries. +- Edit query. +- Run Research. +- See sources and insights. +- Toggle Use research in copy. +- Generate Variants. +- See governance outcome. + +## Rollout + +Phase 1: schema and pure helpers. + +- Research schemas. +- Suggested query helper. +- Domain policy helper. +- Normalization helper. +- Governance claim helper. + +Phase 2: manual research API. + +- `POST /api/v1/content/research`. +- Firecrawl HTTP API adapter. +- No DB writes. + +Phase 3: Content Studio panel. + +- Research panel UI. +- Manual query editing. +- Run result display. +- Use research in copy toggle. + +Phase 4: generation and governance integration. + +- Optional research context in generation request. +- Prompt/schema changes for evidence-backed claims. +- Governance rules for unsupported research claims. + +Phase 5: future automation. + +- Saved queries. +- Scheduled runs. +- Database persistence. +- Marketing Research standalone screen. + +## Acceptance Criteria + +- Existing Create Packet and Generate Variants work when research is disabled. +- Firecrawl failure does not break variant generation. +- Research results are normalized into sources and insights. +- Every insight used in copy has evidence refs. +- Default generation does not include research copy. +- Admin can explicitly enable research-backed copy. +- Governance flags unsupported or source-free claims. +- UI exposes source URL and confidence. +- No automatic scheduler or DB write is included in the MVP. diff --git a/packages/web/lib/components/admin/AdminSidebar.tsx b/packages/web/lib/components/admin/AdminSidebar.tsx index de7b8c4b..5f0dc6f2 100644 --- a/packages/web/lib/components/admin/AdminSidebar.tsx +++ b/packages/web/lib/components/admin/AdminSidebar.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, LayoutDashboard, FileText, + PanelsTopLeft, Sparkles, Star, LogOut, @@ -16,6 +17,7 @@ import { UsersRound, Link2, DollarSign, + ShieldCheck, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/lib/stores/authStore"; @@ -41,6 +43,11 @@ function isNavGroup(entry: SidebarEntry): entry is NavGroup { const SIDEBAR_ENTRIES: SidebarEntry[] = [ { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true }, { href: "/admin/content", label: "Content", icon: FileText }, + { + href: "/admin/content-studio", + label: "Content Studio", + icon: PanelsTopLeft, + }, { href: "/admin/editorial", label: "Editorial", icon: Sparkles }, { href: "/admin/picks", label: "Decoded Pick", icon: Star }, { @@ -67,6 +74,11 @@ const SIDEBAR_ENTRIES: SidebarEntry[] = [ label: "Observability", items: [ { href: "/admin/gemini-cost", label: "Gemini Cost", icon: DollarSign }, + { + href: "/admin/verify-stats", + label: "Verify Stats", + icon: ShieldCheck, + }, ], }, ]; diff --git a/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx b/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx new file mode 100644 index 00000000..d2fda2f2 --- /dev/null +++ b/packages/web/lib/components/admin/dashboard/VerifyStatsMini.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * /admin home 의 작은 verify-stats 카드 (#admin-verify-observability). + * 어제 admin verify total + nag count 노출. /admin/verify-stats 로 drill-down. + */ + +import Link from "next/link"; +import { useVerifyStats } from "@/lib/hooks/admin/useVerifyStats"; + +export function VerifyStatsMini() { + const { data, isLoading } = useVerifyStats(); + const nagCount = data?.nag_admin_ids.length ?? 0; + const hasNag = nagCount > 0; + + return ( + +
+

+ Admin Verify Stats +

+ view → +
+ {isLoading ? ( +
+ ) : ( +
+
+
+ Today +
+
+ {data?.totals.today ?? 0} +
+
+
+
+ Yesterday +
+
+ {data?.totals.yesterday ?? 0} +
+
+
+
+ 7d +
+
+ {data?.totals.last_7d ?? 0} +
+
+
+
+ Nag +
+
+ {hasNag ? `⚠️ ${nagCount}` : "0"} +
+
+
+ )} + + ); +} diff --git a/packages/web/lib/components/auth/AuthProvider.tsx b/packages/web/lib/components/auth/AuthProvider.tsx index 4b93c980..ccb598b0 100644 --- a/packages/web/lib/components/auth/AuthProvider.tsx +++ b/packages/web/lib/components/auth/AuthProvider.tsx @@ -1,7 +1,10 @@ "use client"; import { useEffect } from "react"; -import { supabaseBrowserClient } from "@/lib/supabase/client"; +import { + hasSupabaseBrowserConfig, + supabaseBrowserClient, +} from "@/lib/supabase/client"; import { useAuthStore } from "@/lib/stores/authStore"; const SESSION_CHECK_INTERVAL_MS = 5 * 60 * 1000; @@ -41,12 +44,16 @@ async function clearSessionCookies(): Promise { } export function AuthProvider({ children }: { children: React.ReactNode }) { + const isAuthConfigured = hasSupabaseBrowserConfig(); const initialize = useAuthStore((s) => s.initialize); const setUser = useAuthStore((s) => s.setUser); const setSessionExpired = useAuthStore((s) => s.setSessionExpired); const user = useAuthStore((s) => s.user); useEffect(() => { + if (!isAuthConfigured) { + return; + } initialize(); const { @@ -84,11 +91,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => { subscription.unsubscribe(); }; - }, [initialize, setUser, setSessionExpired]); + }, [initialize, isAuthConfigured, setUser, setSessionExpired]); // 인증된 유저가 있을 때만 주기적 세션 체크 (5분마다) useEffect(() => { - if (!user) return; + if (!isAuthConfigured || !user) return; const sessionCheckInterval = setInterval(async () => { const { @@ -113,7 +120,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => { clearInterval(sessionCheckInterval); }; - }, [user, setSessionExpired]); + }, [isAuthConfigured, user, setSessionExpired]); return <>{children}; } diff --git a/packages/web/lib/content-studio/__tests__/assets.test.ts b/packages/web/lib/content-studio/__tests__/assets.test.ts new file mode 100644 index 00000000..fd4dd9c7 --- /dev/null +++ b/packages/web/lib/content-studio/__tests__/assets.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + assetPlanRequestSchema, + assetPlanSchema, + shortFormPlanSchema, +} from "../schemas"; +import { buildAssetPlan, buildShortFormPlan } from "../assets"; + +const packet = { + id: "packet-1", + postId: "post-1", + sourceImage: "https://example.com/source.jpg", + title: "Airport denim layered look", + hook: "Why this airport look reads premium", + artist: null, + group: null, + context: null, + detectedItems: [], + styleSummary: + "Layered denim with structured outerwear keeps proportion crisp.", + whyItWorks: "The silhouette does the work before the brand does.", + alternatives: { budget: [], mid: [], premium: [] }, + disclosureFlags: { + aiGenerated: false, + syntheticMedia: false, + sponsored: false, + rightsRisk: false, + }, + riskLevel: "low" as const, + reviewStatus: "draft" as const, + createdAt: "2026-05-07T00:00:00.000Z", +}; + +const researchRun = { + id: "run-1", + packetId: "packet-1", + query: "airport denim layered look", + mode: "manual" as const, + status: "completed" as const, + createdAt: "2026-05-07T00:00:00.000Z", + sources: [], + insights: [], + recommendations: { + externalTrendSignal: 0.5, + recommendedChannels: [], + }, +}; + +describe("Content Studio asset schemas", () => { + it("parses an asset plan with image and overlay variants", () => { + const parsed = assetPlanSchema.parse({ + id: "asset-plan-1", + packetId: "packet-1", + researchRunId: "run-1", + status: "draft", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + imageAssets: [ + { + id: "asset-1", + format: "instagram_feed", + prompt: + "Editorial fashion still in a clean luxury-streetwear composition.", + size: "1024x1536", + editMode: "generate", + previewUrl: null, + altText: "Instagram feed asset", + }, + ], + overlayText: [ + { + id: "overlay-1", + format: "instagram_feed", + headline: "Streetwear proportion notes", + subheadline: "Use silhouette before brand name.", + }, + ], + provenance: { + sourcePacketId: "packet-1", + sourceResearchRunId: "run-1", + }, + }); + + expect(parsed.imageAssets[0].format).toBe("instagram_feed"); + expect(parsed.provenance.sourceResearchRunId).toBe("run-1"); + }); + + it("parses a short form plan with scenes and voiceover", () => { + const parsed = shortFormPlanSchema.parse({ + id: "short-form-1", + packetId: "packet-1", + researchRunId: "run-1", + platform: "youtube_shorts", + durationSeconds: 20, + status: "draft", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + hook: "Why this look works", + voiceover: { + text: "The silhouette does the work before the brand does.", + tone: "decoded_editorial", + targetReadingSeconds: 4, + }, + scenes: [ + { + id: "scene-1", + order: 1, + seconds: 4, + visualDirection: "Open with the full silhouette.", + onScreenText: "Silhouette first", + narration: "Open with the full silhouette.", + }, + ], + cta: "Save for reference", + provenance: { + sourcePacketId: "packet-1", + sourceResearchRunId: "run-1", + }, + }); + + expect(parsed.platform).toBe("youtube_shorts"); + expect(parsed.scenes[0].order).toBe(1); + }); + + it("rejects invalid asset plan requests", () => { + const parsed = assetPlanRequestSchema.safeParse({ + packet: {}, + assetTypes: ["unknown"], + useResearchInCopy: false, + }); + + expect(parsed.success).toBe(false); + }); + + it("builds image asset prompts from packet and research context", () => { + const plan = buildAssetPlan({ + packet, + assetTypes: ["instagram_feed", "youtube_thumbnail"], + useResearchInCopy: true, + researchRun, + variants: [], + }); + + expect(plan.imageAssets).toHaveLength(2); + expect(plan.imageAssets[0].prompt).toContain(packet.hook); + expect(plan.status).toBe("draft"); + expect(plan.provenance.sourceResearchRunId).toBe("run-1"); + }); + + it("builds a short form plan with scenes and voiceover", () => { + const plan = buildShortFormPlan({ + packet, + platform: "youtube_shorts", + durationSeconds: 20, + useResearchInCopy: true, + researchRun, + variants: [], + }); + + expect(plan.voiceover.text.length).toBeGreaterThan(0); + expect(plan.scenes.length).toBeGreaterThan(0); + expect(plan.platform).toBe("youtube_shorts"); + }); +}); diff --git a/packages/web/lib/content-studio/__tests__/content-studio.test.ts b/packages/web/lib/content-studio/__tests__/content-studio.test.ts new file mode 100644 index 00000000..60530248 --- /dev/null +++ b/packages/web/lib/content-studio/__tests__/content-studio.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import { + buildContentPacketFromPost, + generateChannelVariants, +} from "../packet-builder"; +import { runGovernanceCheck } from "../governance-check"; +import { mapSupabasePostToPostDetail } from "../post-source"; +import { scoreMarketingOpportunity } from "../opportunity-score"; +import type { PostDetailResponse } from "@/lib/api/generated/models"; + +const samplePost: PostDetailResponse = { + id: "post-123", + image_url: "https://example.com/look.jpg", + image_width: 1200, + image_height: 1600, + title: "Airport denim look", + ai_summary: "A cropped jacket and wide denim create a long-leg silhouette.", + artist_name: "Sample Artist", + group_name: "Sample Group", + context: "Airport", + created_at: "2026-05-07T00:00:00.000Z", + updated_at: "2026-05-07T00:00:00.000Z", + status: "active", + media_source: { type: "user_upload", description: "Airport photo" }, + user: { + id: "user-1", + username: "editor", + rank: "admin", + avatar_url: null, + }, + spots: [ + { + id: "spot-1", + position_left: "45%", + position_top: "32%", + solution_count: 2, + status: "verified", + created_at: "2026-05-07T00:00:00.000Z", + category: null, + top_solution: { + id: "solution-1", + title: "Cropped denim jacket", + thumbnail_url: "https://example.com/jacket.jpg", + original_url: "https://shop.example.com/jacket", + affiliate_url: null, + brand_logo_url: null, + is_adopted: true, + is_verified: true, + metadata: { brand: "Denim Lab", price: "$120" }, + }, + }, + { + id: "spot-2", + position_left: "50%", + position_top: "70%", + solution_count: 1, + status: "pending", + created_at: "2026-05-07T00:00:00.000Z", + category: null, + top_solution: { + id: "solution-2", + title: "Wide leg jeans", + thumbnail_url: null, + original_url: "https://shop.example.com/jeans", + affiliate_url: null, + brand_logo_url: null, + is_adopted: false, + is_verified: false, + metadata: { brand: "Fit Studio" }, + }, + }, + ], + comment_count: 3, + like_count: 42, + save_count: 18, + try_count: 6, + view_count: 900, +}; + +describe("Content Studio packet builder", () => { + it("builds a content packet from decoded post detail", () => { + const packet = buildContentPacketFromPost(samplePost); + + expect(packet.postId).toBe("post-123"); + expect(packet.sourceImage).toBe("https://example.com/look.jpg"); + expect(packet.hook).toContain("Sample Artist"); + expect(packet.detectedItems).toHaveLength(2); + expect(packet.detectedItems[0]).toMatchObject({ + title: "Cropped denim jacket", + brand: "Denim Lab", + confidence: "high", + }); + expect(packet.riskLevel).toBe("medium"); + expect(packet.reviewStatus).toBe("draft"); + }); + + it("maps Supabase post rows into packet-builder compatible post detail", () => { + const post = mapSupabasePostToPostDetail({ + id: "post-db-1", + image_url: "https://example.com/db-look.jpg", + image_width: 1080, + image_height: 1440, + title: "Database look", + ai_summary: "A compact jacket balances a long skirt.", + artist_id: null, + artist_name: null, + context: "Editorial", + created_at: "2026-05-07T00:00:00.000Z", + created_with_solutions: true, + group_id: null, + group_name: null, + media_metadata: { description: "Local Supabase post" }, + media_type: "user_upload", + post_magazine_id: null, + status: "active", + style_tags: ["minimal"], + trending_score: null, + updated_at: "2026-05-07T00:00:00.000Z", + user_id: "user-db-1", + view_count: 11, + spots: [ + { + id: "spot-db-1", + post_id: "post-db-1", + position_left: "50%", + position_top: "40%", + status: "verified", + subcategory_id: null, + user_id: "user-db-1", + created_at: "2026-05-07T00:00:00.000Z", + updated_at: "2026-05-07T00:00:00.000Z", + solutions: [ + { + id: "solution-db-1", + spot_id: "spot-db-1", + title: "Compact jacket", + thumbnail_url: "https://example.com/jacket.jpg", + original_url: "https://shop.example.com/jacket", + affiliate_url: null, + accurate_count: 0, + adopted_at: null, + brand_id: null, + click_count: 0, + comment: null, + created_at: "2026-05-07T00:00:00.000Z", + description: null, + different_count: 0, + is_adopted: true, + is_verified: true, + keywords: null, + link_type: null, + match_type: null, + metadata: { brand: "Studio DB" }, + price_amount: null, + price_currency: null, + purchase_count: 0, + qna: null, + status: "active", + updated_at: "2026-05-07T00:00:00.000Z", + user_id: "user-db-1", + }, + ], + }, + ], + }); + + expect(post.media_source).toEqual({ + type: "user_upload", + description: "Local Supabase post", + }); + expect(post.spots[0].solution_count).toBe(1); + expect(post.spots[0].top_solution).toMatchObject({ + title: "Compact jacket", + is_adopted: true, + metadata: { brand: "Studio DB" }, + }); + expect(buildContentPacketFromPost(post).detectedItems[0]).toMatchObject({ + title: "Compact jacket", + brand: "Studio DB", + confidence: "high", + }); + }); + + it("generates channel-native variants from one packet", () => { + const packet = buildContentPacketFromPost(samplePost); + const variants = generateChannelVariants(packet); + + expect(variants.map((variant) => variant.format)).toEqual([ + "instagram_carousel", + "instagram_reel", + "youtube_shorts", + "x_thread", + ]); + expect(variants.every((variant) => variant.status === "draft")).toBe(true); + expect(variants[0].mediaPlan.slides).toHaveLength(8); + expect(variants[3].body).toContain("1/"); + }); +}); + +describe("Content Studio governance", () => { + it("requires review when a celebrity context has medium rights risk", () => { + const packet = buildContentPacketFromPost(samplePost); + const result = runGovernanceCheck(packet, generateChannelVariants(packet)); + + expect(result.verdict).toBe("needs_review"); + expect(result.flags).toContain("celebrity_likeness_review"); + expect(result.requiredActions).toContain("Human review before publishing"); + }); + + it("rejects packets without interpretation layer", () => { + const packet = buildContentPacketFromPost({ + ...samplePost, + ai_summary: null, + spots: [], + }); + const result = runGovernanceCheck(packet, []); + + expect(result.verdict).toBe("reject"); + expect(result.flags).toContain("thin_repost"); + }); +}); + +describe("Content Studio research governance", () => { + it("requires review when research copy was used while disabled", () => { + const packet = buildContentPacketFromPost(samplePost); + const [variant] = generateChannelVariants(packet); + + const result = runGovernanceCheck(packet, [ + { + ...variant, + researchProvenance: { + researchRunId: "run-1", + useResearchInCopy: false, + usedEvidenceRefs: ["source-1"], + }, + }, + ]); + + expect(result.verdict).toBe("needs_review"); + expect(result.flags).toContain("RESEARCH_COPY_WITHOUT_EVIDENCE"); + }); + + it("requires review for research claims without evidence", () => { + const packet = buildContentPacketFromPost(samplePost); + const [variant] = generateChannelVariants(packet); + + const result = runGovernanceCheck(packet, [ + { + ...variant, + claims: [ + { + text: "Airport denim is a current trend.", + evidenceRefs: [], + confidence: 0.8, + source: "research", + }, + ], + }, + ]); + + expect(result.flags).toContain("UNSUPPORTED_CLAIM"); + }); + + it("requires review when research claim evidence does not map to a source URL", () => { + const packet = { + ...buildContentPacketFromPost(samplePost), + externalEvidence: { + researchRunId: "run-1", + sources: [ + { + id: "source-1", + runId: "run-1", + url: "https://www.vogue.com/article/airport-style", + title: "Airport style", + domain: "www.vogue.com", + sourceType: "style_trend" as const, + fetchedAt: "2026-05-07T00:00:00.000Z", + confidence: "high" as const, + }, + ], + insights: [], + }, + }; + const [variant] = generateChannelVariants(packet); + + const result = runGovernanceCheck(packet, [ + { + ...variant, + claims: [ + { + text: "Airport denim is a current trend.", + evidenceRefs: ["missing-source"], + confidence: 0.8, + source: "research", + }, + ], + }, + ]); + + expect(result.flags).toContain("TREND_WITHOUT_SOURCE"); + }); +}); + +describe("Content Studio opportunity scoring", () => { + it("rewards product and engagement signals while penalizing rights risk", () => { + const score = scoreMarketingOpportunity({ + searchIntent: 0.8, + saveRate: 0.7, + tryOnStarts: 0.5, + itemClickRate: 0.6, + editorialScore: 1, + externalTrendSignal: 0.4, + freshness: 0.9, + rightsRisk: 0.2, + }); + + expect(score).toBe(64); + }); +}); diff --git a/packages/web/lib/content-studio/__tests__/llm-generation.test.ts b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts new file mode 100644 index 00000000..50eb1282 --- /dev/null +++ b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import { + buildContentPacketFromPost, + generateChannelVariants, +} from "../packet-builder"; +import { + CONTENT_STUDIO_PROMPT_VERSION, + generateVariantsWithMode, + mergeGovernanceResults, +} from "../llm-generation"; +import type { + ContentGenerationMode, + GovernanceResult, + ResearchInsight, +} from "../schemas"; +import type { PostDetailResponse } from "@/lib/api/generated/models"; + +const samplePost: PostDetailResponse = { + id: "post-llm-1", + image_url: "https://example.com/look.jpg", + image_width: 1200, + image_height: 1600, + title: "Urban Layers", + ai_summary: "Oversized layers work because the short jacket resets balance.", + artist_name: "Decoded Local", + group_name: null, + context: "Oversized silhouettes meet structured tailoring", + created_at: "2026-05-07T00:00:00.000Z", + updated_at: "2026-05-07T00:00:00.000Z", + status: "active", + media_source: { type: "user_upload", description: "Local test image" }, + user: { + id: "user-1", + username: "editor", + rank: "admin", + avatar_url: null, + }, + spots: [ + { + id: "spot-1", + position_left: "45%", + position_top: "32%", + solution_count: 1, + status: "verified", + created_at: "2026-05-07T00:00:00.000Z", + category: null, + top_solution: { + id: "solution-1", + title: "Structured jacket", + thumbnail_url: null, + original_url: "https://shop.example.com/jacket", + affiliate_url: null, + brand_logo_url: null, + is_adopted: true, + is_verified: true, + metadata: { brand: "Decoded" }, + }, + }, + ], + comment_count: 0, + like_count: 0, + save_count: 0, + try_count: null, + view_count: 10, +}; + +describe("Content Studio LLM generation", () => { + it("falls back to template variants when hybrid mode has no LLM client", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateVariantsWithMode({ + packet, + mode: "hybrid", + llmEnabled: true, + }); + + expect(result.mode).toBe("template"); + expect(result.warning).toContain("LLM client is not configured"); + expect(result.variants).toEqual( + generateChannelVariants(packet).map((variant) => ({ + ...variant, + generationMode: "template", + llmModel: null, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + generationInputHash: expect.any(String), + })) + ); + expect(result.variants[0]).toMatchObject({ + generationMode: "template", + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + }); + }); + + it("returns schema-validated LLM variants with generation metadata", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateVariantsWithMode({ + packet, + mode: "hybrid", + llmEnabled: true, + model: "test-model", + llmGenerate: async () => ({ + variants: generateChannelVariants(packet).map((variant) => ({ + channel: variant.channel, + format: variant.format, + title: `LLM ${variant.title}`, + body: `LLM body for ${variant.format}`, + hook: "Layering works because of proportion.", + mediaPlan: variant.mediaPlan, + hashtags: ["decoded"], + disclosure: variant.disclosure, + cta: "Try the full decode.", + riskNotes: [], + })), + }), + }); + + expect(result.mode).toBe("hybrid"); + expect(result.warning).toBeNull(); + expect(result.variants).toHaveLength(4); + expect(result.variants[0]).toMatchObject({ + id: "packet_post-llm-1_instagram_carousel", + packetId: "packet_post-llm-1", + generationMode: "hybrid", + llmModel: "test-model", + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + generationInputHash: expect.any(String), + }); + expect(result.variants[0].body).toContain("LLM body"); + }); + + it("falls back to templates when LLM output fails schema validation", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateVariantsWithMode({ + packet, + mode: "hybrid", + llmEnabled: true, + llmGenerate: async () => ({ variants: [] }), + }); + + expect(result.mode).toBe("template"); + expect(result.warning).toContain("LLM output failed validation"); + expect(result.variants[0].generationMode).toBe("template"); + }); + + it("merges rule and LLM governance with the stricter decision", () => { + const rule: GovernanceResult = { + verdict: "approve", + riskLevel: "low", + flags: [], + requiredActions: [], + }; + const llm: GovernanceResult = { + verdict: "needs_review", + riskLevel: "medium", + flags: ["exact_product_claim"], + requiredActions: ["Soften exact product identity claim"], + }; + + expect(mergeGovernanceResults(rule, llm)).toEqual({ + verdict: "needs_review", + riskLevel: "medium", + flags: ["exact_product_claim"], + requiredActions: ["Soften exact product identity claim"], + }); + }); + + it.each(["template", "hybrid", "llm"])( + "accepts %s as a generation mode", + (mode) => { + expect(mode).toBeTruthy(); + } + ); +}); + +describe("Content Studio research generation", () => { + const researchContext: { + runId: string; + insights: ResearchInsight[]; + } = { + runId: "run-1", + insights: [ + { + id: "insight-1", + runId: "run-1", + sourceIds: ["source-1"], + topic: "Airport denim", + summary: "Airport denim coverage favors structured jackets.", + claimType: "trend", + evidenceRefs: ["source-1"], + confidence: "high", + }, + ], + }; + + it("excludes research from the LLM call when copy use is disabled", async () => { + const packet = buildContentPacketFromPost(samplePost); + let captured: unknown = null; + + const result = await generateVariantsWithMode({ + packet, + mode: "hybrid", + llmEnabled: true, + researchContext, + useResearchInCopy: false, + llmGenerate: async (input) => { + captured = input; + return { + variants: generateChannelVariants(packet).map((variant) => ({ + channel: variant.channel, + format: variant.format, + title: variant.title, + body: variant.body, + hook: "Hook", + mediaPlan: variant.mediaPlan, + hashtags: variant.hashtags, + disclosure: variant.disclosure, + cta: "Open decoded", + riskNotes: [], + claims: [], + })), + }; + }, + }); + + expect(captured).not.toHaveProperty("researchContext"); + expect(result.variants[0].researchProvenance).toEqual({ + researchRunId: "run-1", + useResearchInCopy: false, + usedEvidenceRefs: [], + }); + }); + + it("passes only evidence-backed insights when copy use is enabled", async () => { + const packet = buildContentPacketFromPost(samplePost); + let captured: unknown = null; + + await generateVariantsWithMode({ + packet, + mode: "hybrid", + llmEnabled: true, + researchContext: { + runId: "run-1", + insights: [ + ...researchContext.insights, + { + ...researchContext.insights[0], + id: "insight-2", + evidenceRefs: [], + }, + ], + }, + useResearchInCopy: true, + llmGenerate: async (input) => { + captured = input; + return { + variants: generateChannelVariants(packet).map((variant) => ({ + channel: variant.channel, + format: variant.format, + title: variant.title, + body: `${variant.body}\nAirport denim coverage favors structured jackets.`, + hook: "Hook", + mediaPlan: variant.mediaPlan, + hashtags: variant.hashtags, + disclosure: variant.disclosure, + cta: "Open decoded", + riskNotes: [], + claims: [ + { + text: "Airport denim coverage favors structured jackets.", + evidenceRefs: ["source-1"], + confidence: 0.8, + source: "research", + }, + ], + })), + }; + }, + }); + + expect(captured).toMatchObject({ + researchContext: { + runId: "run-1", + insights: [{ id: "insight-1" }], + }, + }); + }); + + it("changes the generation hash when research context is available but copy use is disabled", async () => { + const packet = buildContentPacketFromPost(samplePost); + const withoutResearch = await generateVariantsWithMode({ + packet, + mode: "template", + }); + const withResearchDisabled = await generateVariantsWithMode({ + packet, + mode: "template", + researchContext, + useResearchInCopy: false, + }); + + expect( + withResearchDisabled.variants[0].generationInputHash + ).not.toEqual(withoutResearch.variants[0].generationInputHash); + }); +}); diff --git a/packages/web/lib/content-studio/__tests__/research.test.ts b/packages/web/lib/content-studio/__tests__/research.test.ts new file mode 100644 index 00000000..4e5deff7 --- /dev/null +++ b/packages/web/lib/content-studio/__tests__/research.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from "vitest"; +import { + contentPacketSchema, + contentVariantSchema, + researchRunSchema, + runResearchRequestSchema, +} from "../schemas"; +import { buildContentPacketFromPost } from "../packet-builder"; +import { + buildDomainPolicy, + createFirecrawlClient, + deriveResearchRecommendations, + filterAllowedSearchResults, + normalizeResearchRun, + ResearchUnavailableError, + runContentResearch, + suggestResearchQueries, +} from "../research"; +import type { PostDetailResponse } from "@/lib/api/generated/models"; + +describe("Content Studio research schemas", () => { + const source = { + id: "source-1", + runId: "run-1", + url: "https://www.vogue.com/article/airport-style", + title: "Airport style report", + domain: "www.vogue.com", + sourceType: "style_trend", + fetchedAt: "2026-05-07T00:00:00.000Z", + confidence: "high", + } as const; + + const insight = { + id: "insight-1", + runId: "run-1", + sourceIds: ["source-1"], + topic: "Airport denim", + summary: + "Structured denim jackets are appearing in airport-style coverage.", + claimType: "trend", + evidenceRefs: ["source-1"], + confidence: "medium", + } as const; + + it("parses a research run with recommendations", () => { + const parsed = researchRunSchema.parse({ + id: "run-1", + packetId: "packet-1", + query: "airport denim fashion trend", + mode: "manual", + status: "completed", + createdAt: "2026-05-07T00:00:00.000Z", + sources: [source], + insights: [insight], + recommendations: { + externalTrendSignal: 0.7, + recommendedChannels: [ + { + format: "instagram_carousel", + reason: "Source-backed style pattern has enough detail for slides.", + evidenceRefs: ["source-1"], + }, + ], + }, + }); + + expect(parsed.recommendations.externalTrendSignal).toBe(0.7); + expect(parsed.insights[0].evidenceRefs).toEqual(["source-1"]); + }); + + it("accepts packet external evidence without changing core packet fields", () => { + const parsed = contentPacketSchema.partial().parse({ + externalEvidence: { + researchRunId: "run-1", + sources: [source], + insights: [insight], + }, + }); + + expect(parsed.externalEvidence?.researchRunId).toBe("run-1"); + expect(parsed.externalEvidence?.sources[0].url).toBe( + "https://www.vogue.com/article/airport-style" + ); + }); + + it("accepts variant research provenance and claim sources", () => { + const parsed = contentVariantSchema.partial().parse({ + researchProvenance: { + researchRunId: "run-1", + useResearchInCopy: true, + usedEvidenceRefs: ["source-1"], + }, + claims: [ + { + text: "Airport denim is trending in current editorial coverage.", + evidenceRefs: ["source-1"], + confidence: 0.72, + source: "research", + }, + ], + missingFacts: [], + }); + + expect(parsed.claims?.[0].source).toBe("research"); + expect(parsed.researchProvenance?.usedEvidenceRefs).toEqual(["source-1"]); + }); + + it("rejects invalid research requests", () => { + const parsed = runResearchRequestSchema.safeParse({ + packet: {}, + query: "", + sourceTypes: ["unknown"], + maxResults: 99, + }); + + expect(parsed.success).toBe(false); + }); +}); + +const researchPost: PostDetailResponse = { + id: "post-research-1", + image_url: "https://example.com/look.jpg", + image_width: 1200, + image_height: 1600, + title: "Airport denim look", + ai_summary: + "Cropped denim and wide-leg jeans create a clean travel silhouette.", + artist_name: "Sample Artist", + group_name: "Sample Group", + context: "Airport", + created_at: "2026-05-07T00:00:00.000Z", + updated_at: "2026-05-07T00:00:00.000Z", + status: "active", + media_source: { type: "user_upload", description: "Airport photo" }, + user: { id: "user-1", username: "editor", rank: "admin", avatar_url: null }, + spots: [], + comment_count: 0, + like_count: 0, + save_count: 0, + try_count: 0, + view_count: 1, +}; + +describe("Content Studio research helpers", () => { + it("suggests deterministic research queries from a packet", () => { + const packet = buildContentPacketFromPost(researchPost); + + expect(suggestResearchQueries(packet)).toEqual([ + "Airport fashion trend", + "Sample Artist style analysis", + "Airport denim look styling trend", + "Instagram carousel fashion hook style analysis", + "YouTube Shorts fashion analysis format", + "X thread fashion commentary format", + ]); + }); + + it("filters search results through allowlist and blocklist policy", () => { + const policy = buildDomainPolicy({ + allowedDomains: "vogue.com,instagram.com,youtube.com", + blockedDomains: "spam.example", + }); + + const filtered = filterAllowedSearchResults( + [ + { url: "https://www.vogue.com/article/a", title: "A", description: "" }, + { url: "ftp://www.vogue.com/article/b", title: "B", description: "" }, + { url: "https://spam.example/post", title: "B", description: "" }, + { + url: "https://unknown.example/post", + title: "C", + description: "", + }, + ], + policy + ); + + expect(filtered.map((result) => result.url)).toEqual([ + "https://www.vogue.com/article/a", + ]); + }); + + it("uses a default research allowlist when env policy is absent", () => { + const previousAllowed = process.env.CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS; + delete process.env.CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS; + + try { + const policy = buildDomainPolicy(); + + expect(policy.allowedDomains).toEqual( + expect.arrayContaining(["vogue.com", "youtube.com"]) + ); + expect( + filterAllowedSearchResults( + [ + { + url: "https://www.youtube.com/watch?v=abc", + title: "YouTube format", + description: "", + }, + { + url: "https://unknown.example/post", + title: "Unknown", + description: "", + }, + ], + policy + ).map((result) => result.url) + ).toEqual(["https://www.youtube.com/watch?v=abc"]); + } finally { + restoreEnv("CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS", previousAllowed); + } + }); + + it("normalizes evidence-backed sources and insights", () => { + const run = normalizeResearchRun({ + runId: "run-1", + packetId: "packet-1", + query: "airport denim fashion trend", + sourceTypes: ["style_trend", "channel_format"], + fetchedAt: "2026-05-07T00:00:00.000Z", + records: [ + { + url: "https://www.vogue.com/article/airport-denim", + title: "Airport denim", + description: + "Denim jackets keep appearing in airport style coverage.", + markdown: + "Instagram carousel breakdowns highlight jacket proportions.", + sourceType: "style_trend", + }, + ], + failedCount: 0, + }); + + expect(run.status).toBe("completed"); + expect(run.sources[0]).toMatchObject({ + id: "source-1", + domain: "www.vogue.com", + confidence: "medium", + }); + expect(run.insights[0]).toMatchObject({ + evidenceRefs: ["source-1"], + claimType: "trend", + }); + }); + + it("marks all-failed normalization runs as failed", () => { + const run = normalizeResearchRun({ + runId: "run-1", + packetId: "packet-1", + query: "airport denim fashion trend", + sourceTypes: ["style_trend"], + fetchedAt: "2026-05-07T00:00:00.000Z", + records: [], + failedCount: 2, + }); + + expect(run.status).toBe("failed"); + expect(run.sources).toEqual([]); + expect(run.insights).toEqual([]); + }); + + it("derives recommendations from insight evidence", () => { + const recommendations = deriveResearchRecommendations([ + { + id: "insight-1", + runId: "run-1", + sourceIds: ["source-1"], + topic: "Instagram carousel format", + summary: + "Carousel analysis works well for proportional styling breakdowns.", + claimType: "format_pattern", + evidenceRefs: ["source-1"], + confidence: "high", + }, + ]); + + expect(recommendations.externalTrendSignal).toBe(0.7); + expect(recommendations.recommendedChannels[0]).toMatchObject({ + format: "instagram_carousel", + evidenceRefs: ["source-1"], + }); + }); +}); + +describe("Firecrawl API client", () => { + it("searches through the Firecrawl HTTP API with bearer auth", async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + + return Response.json({ + success: true, + data: { + web: [ + { + url: "https://www.vogue.com/article/airport-style", + title: "Airport style", + description: "Current airport outfit coverage.", + }, + ], + }, + }); + }; + + const client = createFirecrawlClient({ + token: "fc-test-token", + fetchImpl: fetchImpl as typeof fetch, + }); + + await expect(client.search("airport style", 2)).resolves.toEqual([ + { + url: "https://www.vogue.com/article/airport-style", + title: "Airport style", + description: "Current airport outfit coverage.", + }, + ]); + + expect(requests[0]).toMatchObject({ + url: "https://api.firecrawl.dev/v2/search", + }); + expect(requests[0].init.headers).toMatchObject({ + Authorization: "Bearer fc-test-token", + "Content-Type": "application/json", + }); + expect(JSON.parse(String(requests[0].init.body))).toMatchObject({ + query: "airport style", + limit: 2, + sources: ["web"], + }); + }); + + it("scrapes markdown through the Firecrawl HTTP API", async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + + return Response.json({ + success: true, + data: { + markdown: "Structured denim jackets are prominent.", + metadata: { + title: "Airport denim report", + }, + }, + }); + }; + + const client = createFirecrawlClient({ + token: "fc-test-token", + fetchImpl: fetchImpl as typeof fetch, + }); + + await expect( + client.scrape("https://www.vogue.com/article/airport-style") + ).resolves.toEqual({ + url: "https://www.vogue.com/article/airport-style", + title: "Airport denim report", + markdown: "Structured denim jackets are prominent.", + }); + + expect(requests[0]).toMatchObject({ + url: "https://api.firecrawl.dev/v2/scrape", + }); + expect(JSON.parse(String(requests[0].init.body))).toMatchObject({ + url: "https://www.vogue.com/article/airport-style", + formats: ["markdown"], + onlyMainContent: true, + }); + }); +}); + +describe("Content research service configuration", () => { + it("does not require a Firecrawl token when research is disabled", async () => { + const previousEnabled = process.env.CONTENT_STUDIO_RESEARCH_ENABLED; + const previousToken = process.env.FIRECRAWL_TOKEN; + delete process.env.FIRECRAWL_TOKEN; + process.env.CONTENT_STUDIO_RESEARCH_ENABLED = "false"; + + try { + const packet = buildContentPacketFromPost(researchPost); + + await expect( + runContentResearch({ + packet, + query: "airport style", + sourceTypes: ["style_trend"], + maxResults: 1, + }) + ).rejects.toBeInstanceOf(ResearchUnavailableError); + } finally { + restoreEnv("CONTENT_STUDIO_RESEARCH_ENABLED", previousEnabled); + restoreEnv("FIRECRAWL_TOKEN", previousToken); + } + }); + + it("maps missing Firecrawl token to research unavailable", async () => { + const previousEnabled = process.env.CONTENT_STUDIO_RESEARCH_ENABLED; + const previousToken = process.env.FIRECRAWL_TOKEN; + delete process.env.FIRECRAWL_TOKEN; + process.env.CONTENT_STUDIO_RESEARCH_ENABLED = "true"; + + try { + const packet = buildContentPacketFromPost(researchPost); + + await expect( + runContentResearch({ + packet, + query: "airport style", + sourceTypes: ["style_trend"], + maxResults: 1, + }) + ).rejects.toBeInstanceOf(ResearchUnavailableError); + } finally { + restoreEnv("CONTENT_STUDIO_RESEARCH_ENABLED", previousEnabled); + restoreEnv("FIRECRAWL_TOKEN", previousToken); + } + }); +}); + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key]; + return; + } + + process.env[key] = value; +} diff --git a/packages/web/lib/content-studio/assets/index.ts b/packages/web/lib/content-studio/assets/index.ts new file mode 100644 index 00000000..f63d462e --- /dev/null +++ b/packages/web/lib/content-studio/assets/index.ts @@ -0,0 +1 @@ +export { buildAssetPlan, buildShortFormPlan } from "./plan"; diff --git a/packages/web/lib/content-studio/assets/openai-client.ts b/packages/web/lib/content-studio/assets/openai-client.ts new file mode 100644 index 00000000..b59f83a9 --- /dev/null +++ b/packages/web/lib/content-studio/assets/openai-client.ts @@ -0,0 +1,442 @@ +import type { + AssetPlan, + AssetTargetFormat, + ContentPacket, + ContentVariant, + ResearchRun, + ShortFormPlan, + ShortFormPlatform, +} from "../schemas"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin-server"; + +const CONTENT_STUDIO_BUCKET = "content-studio-assets"; + +const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; +const OPENAI_IMAGES_URL = "https://api.openai.com/v1/images/generations"; +const OPENAI_IMAGE_EDITS_URL = "https://api.openai.com/v1/images/edits"; + +export function isContentStudioAssetLlmEnabled(): boolean { + return process.env.CONTENT_STUDIO_ASSET_LLM_ENABLED === "true"; +} + +export function contentStudioAssetModel(): string { + return process.env.CONTENT_STUDIO_ASSET_MODEL || "gpt-4.1"; +} + +export function contentStudioImageModel(): string { + return process.env.CONTENT_STUDIO_IMAGE_MODEL || "gpt-image-1"; +} + +function mapToOpenAIImageSize( + size: string +): "1024x1024" | "1024x1536" | "1536x1024" { + const [w, h] = size.split("x").map((n) => Number.parseInt(n, 10)); + if (!Number.isFinite(w) || !Number.isFinite(h)) return "1024x1024"; + if (w === h) return "1024x1024"; + return w > h ? "1536x1024" : "1024x1536"; +} + +async function fetchImageBytesFromOpenAI( + prompt: string, + size: string +): Promise<{ bytes: Buffer; contentType: string }> { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY is not configured"); + + const response = await fetch(OPENAI_IMAGES_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: contentStudioImageModel(), + prompt, + size: mapToOpenAIImageSize(size), + n: 1, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `OpenAI image request failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}` + ); + } + + const data = (await response.json()) as { + data?: Array<{ b64_json?: string; url?: string }>; + }; + const item = data.data?.[0]; + if (item?.b64_json) { + return { + bytes: Buffer.from(item.b64_json, "base64"), + contentType: "image/png", + }; + } + if (item?.url) { + const imageResponse = await fetch(item.url); + if (!imageResponse.ok) { + throw new Error(`Failed to fetch image URL: ${imageResponse.status}`); + } + const arrayBuffer = await imageResponse.arrayBuffer(); + return { + bytes: Buffer.from(arrayBuffer), + contentType: imageResponse.headers.get("content-type") ?? "image/png", + }; + } + throw new Error("OpenAI image response missing data"); +} + +async function fetchReferenceImage( + url: string +): Promise<{ bytes: Buffer; contentType: string; filename: string } | null> { + try { + const response = await fetch(url); + if (!response.ok) return null; + const arrayBuffer = await response.arrayBuffer(); + const contentType = response.headers.get("content-type") ?? "image/png"; + const ext = contentType.includes("jpeg") + ? "jpg" + : contentType.includes("webp") + ? "webp" + : "png"; + const filename = `ref_${Math.random().toString(36).slice(2, 8)}.${ext}`; + return { bytes: Buffer.from(arrayBuffer), contentType, filename }; + } catch { + return null; + } +} + +async function fetchImageBytesFromOpenAIEdit( + prompt: string, + size: string, + referenceUrls: string[] +): Promise<{ bytes: Buffer; contentType: string }> { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY is not configured"); + + const refs = ( + await Promise.all(referenceUrls.slice(0, 4).map(fetchReferenceImage)) + ).filter((r): r is NonNullable => r !== null); + + if (refs.length === 0) { + throw new Error("No usable reference images"); + } + + const form = new FormData(); + form.append("model", contentStudioImageModel()); + form.append("prompt", prompt); + form.append("size", mapToOpenAIImageSize(size)); + form.append("n", "1"); + + for (const ref of refs) { + const blob = new Blob([new Uint8Array(ref.bytes)], { + type: ref.contentType, + }); + form.append("image[]", blob, ref.filename); + } + + const response = await fetch(OPENAI_IMAGE_EDITS_URL, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `OpenAI image-edit failed: ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}` + ); + } + + const data = (await response.json()) as { + data?: Array<{ b64_json?: string; url?: string }>; + }; + const item = data.data?.[0]; + if (item?.b64_json) { + return { + bytes: Buffer.from(item.b64_json, "base64"), + contentType: "image/png", + }; + } + if (item?.url) { + const imageResponse = await fetch(item.url); + const arrayBuffer = await imageResponse.arrayBuffer(); + return { + bytes: Buffer.from(arrayBuffer), + contentType: imageResponse.headers.get("content-type") ?? "image/png", + }; + } + throw new Error("OpenAI image-edit response missing data"); +} + +async function uploadToStorage( + planId: string, + assetId: string, + bytes: Buffer, + contentType: string +): Promise { + const supabase = createAdminSupabaseClient(); + const ext = contentType.includes("jpeg") ? "jpg" : "png"; + const path = `${planId}/${assetId}_${Date.now()}.${ext}`; + + const { error } = await supabase.storage + .from(CONTENT_STUDIO_BUCKET) + .upload(path, bytes, { + contentType, + upsert: false, + }); + + if (error) { + throw new Error(`Storage upload failed: ${error.message}`); + } + + const { data } = supabase.storage + .from(CONTENT_STUDIO_BUCKET) + .getPublicUrl(path); + return data.publicUrl; +} + +export async function generateAssetImage( + prompt: string, + size: string, + options?: { + planId?: string; + assetId?: string; + referenceImageUrls?: string[]; + } +): Promise { + const useEdit = + options?.referenceImageUrls && options.referenceImageUrls.length > 0; + + let bytes: Buffer; + let contentType: string; + if (useEdit && options?.referenceImageUrls) { + try { + const result = await fetchImageBytesFromOpenAIEdit( + prompt, + size, + options.referenceImageUrls + ); + bytes = result.bytes; + contentType = result.contentType; + } catch (error) { + // Fall back to plain generation if edit endpoint fails + const fallback = await fetchImageBytesFromOpenAI(prompt, size); + bytes = fallback.bytes; + contentType = fallback.contentType; + void error; + } + } else { + const result = await fetchImageBytesFromOpenAI(prompt, size); + bytes = result.bytes; + contentType = result.contentType; + } + + if (options?.planId && options?.assetId) { + try { + return await uploadToStorage( + options.planId, + options.assetId, + bytes, + contentType + ); + } catch { + // fall through to data URL fallback + } + } + + return `data:${contentType};base64,${bytes.toString("base64")}`; +} + +export async function generateAssetImagesForPlan( + plan: AssetPlan, + referenceImageUrls?: string[] +): Promise<{ + plan: AssetPlan; + failures: Array<{ id: string; error: string }>; +}> { + const failures: Array<{ id: string; error: string }> = []; + const updated = await Promise.all( + plan.imageAssets.map(async (asset) => { + try { + const url = await generateAssetImage(asset.prompt, asset.size, { + planId: plan.id, + assetId: asset.id, + referenceImageUrls, + }); + return { ...asset, previewUrl: url }; + } catch (error) { + failures.push({ + id: asset.id, + error: + error instanceof Error ? error.message : "Image generation failed", + }); + return asset; + } + }) + ); + return { plan: { ...plan, imageAssets: updated }, failures }; +} + +type AssetPromptInput = { + packet: ContentPacket; + researchRun?: ResearchRun; + variants: ContentVariant[]; + assetTypes: AssetTargetFormat[]; + useResearchInCopy?: boolean; + model?: string; +}; + +type ShortFormPromptInput = { + packet: ContentPacket; + researchRun?: ResearchRun; + variants: ContentVariant[]; + platform: ShortFormPlatform; + durationSeconds: number; + useResearchInCopy?: boolean; + model?: string; +}; + +async function callOpenAI( + systemPrompt: string, + userPayload: unknown, + model: string +): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY is not configured"); + + const response = await fetch(OPENAI_RESPONSES_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + input: [ + { role: "system", content: systemPrompt }, + { role: "user", content: JSON.stringify(userPayload) }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI request failed: ${response.status}`); + } + + const data = (await response.json()) as { + output_text?: string; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + if (typeof data.output_text === "string") return data.output_text; + for (const o of data.output ?? []) { + for (const c of o.content ?? []) { + if (c.type === "output_text" && typeof c.text === "string") return c.text; + } + } + return null; +} + +export async function refineAssetPlanWithOpenAI( + base: AssetPlan, + input: AssetPromptInput +): Promise { + const text = await callOpenAI( + "You refine post-ready image prompts. Return JSON with imageAssets array of { id, prompt }.", + { + packet: { + title: input.packet.title, + hook: input.packet.hook, + styleSummary: input.packet.styleSummary, + }, + research: + input.useResearchInCopy && input.researchRun + ? { query: input.researchRun.query } + : null, + assets: base.imageAssets.map((a) => ({ + id: a.id, + format: a.format, + prompt: a.prompt, + })), + }, + input.model ?? contentStudioAssetModel() + ); + if (!text) return base; + try { + const parsed = JSON.parse(text) as { + imageAssets?: Array<{ id: string; prompt?: string }>; + }; + const refined = new Map( + (parsed.imageAssets ?? []).map((a) => [a.id, a.prompt]) + ); + return { + ...base, + imageAssets: base.imageAssets.map((a) => ({ + ...a, + prompt: refined.get(a.id) ?? a.prompt, + })), + }; + } catch { + return base; + } +} + +export async function refineShortFormPlanWithOpenAI( + base: ShortFormPlan, + input: ShortFormPromptInput +): Promise { + const text = await callOpenAI( + "You refine short-form scripts. Return JSON with hook, voiceoverText, and scenes [{ id, narration, onScreenText }].", + { + packet: { + title: input.packet.title, + hook: input.packet.hook, + whyItWorks: input.packet.whyItWorks, + styleSummary: input.packet.styleSummary, + }, + platform: input.platform, + durationSeconds: input.durationSeconds, + research: + input.useResearchInCopy && input.researchRun + ? { query: input.researchRun.query } + : null, + scenes: base.scenes.map((s) => ({ + id: s.id, + narration: s.narration, + onScreenText: s.onScreenText, + })), + }, + input.model ?? contentStudioAssetModel() + ); + if (!text) return base; + try { + const parsed = JSON.parse(text) as { + hook?: string; + voiceoverText?: string; + scenes?: Array<{ id: string; narration?: string; onScreenText?: string }>; + }; + const sceneMap = new Map((parsed.scenes ?? []).map((s) => [s.id, s])); + return { + ...base, + hook: parsed.hook ?? base.hook, + voiceover: { + ...base.voiceover, + text: parsed.voiceoverText ?? base.voiceover.text, + }, + scenes: base.scenes.map((s) => { + const refined = sceneMap.get(s.id); + return refined + ? { + ...s, + narration: refined.narration ?? s.narration, + onScreenText: refined.onScreenText ?? s.onScreenText, + } + : s; + }), + }; + } catch { + return base; + } +} diff --git a/packages/web/lib/content-studio/assets/plan.ts b/packages/web/lib/content-studio/assets/plan.ts new file mode 100644 index 00000000..a2ae2b16 --- /dev/null +++ b/packages/web/lib/content-studio/assets/plan.ts @@ -0,0 +1,240 @@ +import { + assetPlanSchema, + shortFormPlanSchema, + type AssetTargetFormat, + type ContentPacket, + type ContentVariant, + type ResearchRun, + type ShortFormPlatform, +} from "../schemas"; + +type BuildAssetPlanInput = { + packet: ContentPacket; + researchRun?: ResearchRun; + variants: ContentVariant[]; + assetTypes: AssetTargetFormat[]; + useResearchInCopy?: boolean; + embedHeadline?: boolean; +}; + +type BuildShortFormPlanInput = { + packet: ContentPacket; + researchRun?: ResearchRun; + variants: ContentVariant[]; + platform: ShortFormPlatform; + durationSeconds: number; + useResearchInCopy?: boolean; +}; + +const FORMAT_SIZE: Record = { + instagram_feed: "1024x1280", + instagram_story: "1080x1920", + instagram_carousel_cover: "1080x1080", + youtube_thumbnail: "1280x720", + pinterest_pin: "1000x1500", +}; + +const FORMAT_COMPOSITION: Record = { + instagram_feed: + "Vertical 4:5 editorial magazine spread layout, NOT a single solo portrait. Composition reads as a real Vogue / Self Service / Re-Edition page: subject framed three-quarter or full-figure within a styled environment (props, furniture, architectural detail, on-set styling pieces in frame), positioned slightly off-center, with intentional empty space along the top for masthead/typography overlay later. Avoid generic studio headshots. Use cinematic three-quarter lighting with soft falloff and a single rim light. Muted palette with one accent pulled from the outfit. Medium format film aesthetic, 85mm, shallow depth of field, fine grain.", + instagram_story: + "Vertical 9:16 magazine spread layout. Full-length editorial fashion-spread frame: subject in a styled environment or set (atelier, runway backstage, gallery, hotel suite, street corner — pick what fits the concept), offset to lower third so the upper two-thirds is breathable for headline typography. Layered foreground + midground + background depth. Tungsten-cooled key light with deep shadow side, Vogue Italia editorial mood. Hint of fabric motion. Hasselblad 80mm aesthetic, fine grain.", + instagram_carousel_cover: + "Square 1:1 editorial cover composition. Tight crop from chest up OR mid-thigh up — pick whichever better isolates the styling silhouette — with subject in front of a textured paper or plaster backdrop in a single muted tone (cream, slate, oxblood, sage). Direct gaze, intentional posture. Add one cover-line area of negative space (top or right) for typography overlay. Treat it as a printed magazine cover — not a phone snapshot. Medium format film, gallery-grade color.", + youtube_thumbnail: + "Horizontal 16:9 editorial cover composition. Subject occupies the right two-thirds in three-quarter pose with eyes to camera, while the left third is a clean negative-space block for a future bold cover headline. Background is a single-tone textured wall (concrete, plaster, seamless paper). Dramatic side light, Interview Magazine cover mood. Hyper-detailed wardrobe — fabric weave visible. No on-image text or letters; typography is layered in post.", + pinterest_pin: + "Vertical 2:3 editorial lookbook page layout. Full-length figure on a seamless neutral backdrop with one prop or styling piece in frame to break the headshot-only feel. Subject centered or slightly right. Soft north-window daylight. Wardrobe + silhouette are the hero. Generous breathing room top and bottom for caption blocks. 35mm film with mild halation, lookbook editorial polish.", +}; + +function describeItems(packet: ContentPacket): string { + if (!packet.detectedItems || packet.detectedItems.length === 0) return ""; + const items = packet.detectedItems + .slice(0, 4) + .map((item) => [item.brand, item.title].filter(Boolean).join(" ").trim()) + .filter((s) => s.length > 0); + if (items.length === 0) return ""; + return `Wardrobe references: ${items.join(", ")}.`; +} + +function describeResearchKeywords(researchRun?: ResearchRun): string { + if (!researchRun) return ""; + const insightLines = (researchRun.insights ?? []) + .slice(0, 4) + .map((insight) => insight.topic.trim()) + .filter((s) => s.length > 0); + const keywords = Array.from(new Set(insightLines)); + if (keywords.length === 0) return ""; + return `Reference style/scene keywords from current trend research: ${keywords.join(", ")}.`; +} + +function describeContext(packet: ContentPacket): string { + const ctx = (packet.context || "").toLowerCase(); + if (!ctx) return ""; + const sceneByContext: Record = { + photoshoot: + "Treat the scene like an actual editorial photoshoot setup with subtle on-set props (light stand glow, paper backdrop edge, magazine-rack of styling references in soft background).", + stage: + "Subject is photographed as if backstage or stage-side: dressing room mirrors with bulb lights, garment rack, performance lighting bleeding in. Editorial behind-the-scenes mood, not on-stage live shot.", + runway: + "Backstage runway scene: garment rack, fitting mirror, makeup chair partially in frame. Editorial behind-the-scenes mood.", + street: + "Editorial streetwear scene framed like a candid magazine spread: real city texture, signage, walls — but composed and lit, not paparazzi.", + studio: + "Editorial studio scene with seamless backdrop, visible studio prop (stool, ladder, Bowens light just out of focus).", + }; + for (const key of Object.keys(sceneByContext)) { + if (ctx.includes(key)) return sceneByContext[key]; + } + return `Scene context: ${packet.context}.`; +} + +const HEADLINE_PLACEMENT: Record = { + instagram_feed: + "Place the headline as a bold magazine cover-line stretched across the upper third of the frame, large all-caps condensed serif (think W Magazine / i-D), high contrast against the background. Subhead beneath in smaller sans-serif tracking-wide caps.", + instagram_story: + "Stack the headline across the top third in oversized condensed display serif, all caps, edge-to-edge. Subhead just below it in a thin sans-serif single line. Treat it like a real magazine cover.", + instagram_carousel_cover: + "Center-aligned editorial cover-line across the top quarter of the square, big bold serif all caps. Subhead one line below in light sans-serif. Magazine cover energy, gallery-grade typography.", + youtube_thumbnail: + "Headline lives on the LEFT third in massive bold compressed sans-serif (Druk / Inter Display Black) all caps, stacked on 2-3 lines, high contrast. Subhead below it, smaller, italic serif accent.", + pinterest_pin: + "Headline at the very bottom strip of the image in clean editorial serif, centered, with the subhead in a smaller italic line above it. Lookbook caption-block style.", +}; + +function shortenHeadline(text: string, maxWords: number): string { + const cleaned = text.replace(/\s+/g, " ").trim(); + const colon = cleaned.indexOf(":"); + const base = colon > -1 && colon < 60 ? cleaned.slice(0, colon) : cleaned; + const words = base.split(" ").filter(Boolean); + if (words.length <= maxWords) return base.trim(); + return words.slice(0, maxWords).join(" ").trim(); +} + +function imagePrompt( + packet: ContentPacket, + format: AssetTargetFormat, + researchRun?: ResearchRun, + embedHeadline?: boolean +) { + const subject = packet.artist || "fashion editorial subject"; + const composition = FORMAT_COMPOSITION[format]; + const items = describeItems(packet); + const research = describeResearchKeywords(researchRun); + const context = describeContext(packet); + + let textBlock = ""; + let textConstraints = + "Photorealistic. NO on-image text, NO letters or words, NO watermarks, NO logos overlaid, NO additional people, NO surreal or fantasy elements. Magazine-quality color grading, controlled highlights, rich shadow detail. Treat the empty/negative space as intentional layout breathing room for typography to be added in post."; + + if (embedHeadline) { + const headline = shortenHeadline(packet.hook || packet.title, 8); + const subhead = shortenHeadline(packet.styleSummary, 10); + textBlock = `Render this exact headline text directly on the image as designed editorial typography (do NOT misspell, do NOT add extra words): HEADLINE — "${headline.toUpperCase()}"${subhead ? `; SUBHEAD — "${subhead}"` : ""}. ${HEADLINE_PLACEMENT[format]} Typography must be sharp, kerned, and look like real magazine cover printing — not a watermark, not a sticker. Spell every word correctly.`; + textConstraints = + "Photorealistic image with the headline typography baked in. Render only the headline and subhead specified above, with NO additional words, slogans, brand logos, or watermarks. Magazine-quality color grading, controlled highlights, rich shadow detail. Typography must be crisp and accurately spelled."; + } + + return [ + `Editorial fashion magazine ${embedHeadline ? "cover" : "image"} of ${subject}, in the visual language of Vogue, Interview, Self Service, Re-Edition, and Highsnobiety editorial spreads. This is a magazine page, not a portrait headshot.`, + `Concept: ${packet.hook}.`, + `Style direction: ${packet.styleSummary}.`, + items, + context, + research, + composition, + textBlock, + textConstraints, + ] + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +export function buildAssetPlan(input: BuildAssetPlanInput) { + const now = new Date().toISOString(); + const plan = { + id: `asset_plan_${input.packet.id}`, + packetId: input.packet.id, + researchRunId: input.researchRun?.id ?? null, + status: "draft" as const, + createdAt: now, + updatedAt: now, + imageAssets: input.assetTypes.map((format, index) => ({ + id: `${format}_${index + 1}`, + format, + prompt: imagePrompt( + input.packet, + format, + input.useResearchInCopy ? input.researchRun : undefined, + input.embedHeadline + ), + size: FORMAT_SIZE[format], + editMode: "generate" as const, + previewUrl: null, + altText: `${input.packet.title} ${format}`, + })), + overlayText: input.assetTypes.map((format, index) => ({ + id: `${format}_overlay_${index + 1}`, + format, + headline: input.packet.hook, + subheadline: + input.useResearchInCopy && input.researchRun + ? input.researchRun.query + : input.packet.styleSummary, + })), + provenance: { + sourcePacketId: input.packet.id, + sourceResearchRunId: input.researchRun?.id ?? null, + }, + }; + + return assetPlanSchema.parse(plan); +} + +export function buildShortFormPlan(input: BuildShortFormPlanInput) { + const now = new Date().toISOString(); + const detailSeconds = Math.max(2, input.durationSeconds - 4); + const plan = { + id: `short_form_${input.packet.id}`, + packetId: input.packet.id, + researchRunId: input.researchRun?.id ?? null, + platform: input.platform, + durationSeconds: input.durationSeconds, + status: "draft" as const, + createdAt: now, + updatedAt: now, + hook: input.packet.hook, + voiceover: { + text: input.packet.whyItWorks, + tone: "decoded_editorial", + targetReadingSeconds: Math.max(3, Math.ceil(input.durationSeconds / 5)), + }, + scenes: [ + { + id: "scene_1", + order: 1, + seconds: 4, + visualDirection: "Open with the full silhouette and first impression.", + onScreenText: input.packet.hook, + narration: input.packet.hook, + }, + { + id: "scene_2", + order: 2, + seconds: Math.min(detailSeconds, 30), + visualDirection: "Show the key details and supporting items.", + onScreenText: input.packet.styleSummary, + narration: input.packet.styleSummary, + }, + ], + cta: "Save for reference", + provenance: { + sourcePacketId: input.packet.id, + sourceResearchRunId: input.researchRun?.id ?? null, + }, + }; + + return shortFormPlanSchema.parse(plan); +} diff --git a/packages/web/lib/content-studio/assets/service.ts b/packages/web/lib/content-studio/assets/service.ts new file mode 100644 index 00000000..9989b060 --- /dev/null +++ b/packages/web/lib/content-studio/assets/service.ts @@ -0,0 +1,101 @@ +import { + assetPlanRequestSchema, + shortFormPlanRequestSchema, + type AssetPlan, + type AssetPlanRequest, + type ShortFormPlan, + type ShortFormPlanRequest, +} from "../schemas"; +import { buildAssetPlan, buildShortFormPlan } from "./plan"; +import { + isContentStudioAssetLlmEnabled, + refineAssetPlanWithOpenAI, + refineShortFormPlanWithOpenAI, +} from "./openai-client"; + +export type AssetPlanResult = { + plan: AssetPlan; + warning: string | null; +}; + +export type ShortFormPlanResult = { + plan: ShortFormPlan; + warning: string | null; +}; + +export async function generateAssetPlan( + input: AssetPlanRequest +): Promise { + const base = buildAssetPlan({ + packet: input.packet, + researchRun: input.researchRun, + variants: input.variants ?? [], + assetTypes: input.assetTypes, + useResearchInCopy: input.useResearchInCopy, + embedHeadline: input.embedHeadline, + }); + + if (!isContentStudioAssetLlmEnabled()) { + return { plan: base, warning: null }; + } + + try { + const refined = await refineAssetPlanWithOpenAI(base, { + packet: input.packet, + researchRun: input.researchRun, + variants: input.variants ?? [], + assetTypes: input.assetTypes, + useResearchInCopy: input.useResearchInCopy, + model: input.model, + }); + return { plan: refined, warning: null }; + } catch (error) { + return { + plan: base, + warning: + error instanceof Error + ? error.message + : "OpenAI refinement failed; returning base plan", + }; + } +} + +export async function generateShortFormPlan( + input: ShortFormPlanRequest +): Promise { + const base = buildShortFormPlan({ + packet: input.packet, + researchRun: input.researchRun, + variants: input.variants ?? [], + platform: input.platform, + durationSeconds: input.durationSeconds, + useResearchInCopy: input.useResearchInCopy, + }); + + if (!isContentStudioAssetLlmEnabled()) { + return { plan: base, warning: null }; + } + + try { + const refined = await refineShortFormPlanWithOpenAI(base, { + packet: input.packet, + researchRun: input.researchRun, + variants: input.variants ?? [], + platform: input.platform, + durationSeconds: input.durationSeconds, + useResearchInCopy: input.useResearchInCopy, + model: input.model, + }); + return { plan: refined, warning: null }; + } catch (error) { + return { + plan: base, + warning: + error instanceof Error + ? error.message + : "OpenAI refinement failed; returning base plan", + }; + } +} + +export { assetPlanRequestSchema, shortFormPlanRequestSchema }; diff --git a/packages/web/lib/content-studio/governance-check.ts b/packages/web/lib/content-studio/governance-check.ts new file mode 100644 index 00000000..01a49af2 --- /dev/null +++ b/packages/web/lib/content-studio/governance-check.ts @@ -0,0 +1,122 @@ +import type { + ContentPacket, + ContentVariant, + GovernanceResult, +} from "./schemas"; + +export function runGovernanceCheck( + packet: ContentPacket, + variants: ContentVariant[] +): GovernanceResult { + const flags: string[] = []; + const requiredActions: string[] = []; + + const hasInterpretation = + packet.detectedItems.length > 0 || + (packet.styleSummary.trim().length > 0 && + packet.styleSummary !== "No interpretation layer is available yet."); + + if (!hasInterpretation) { + flags.push("thin_repost"); + requiredActions.push("Add interpretation layer before publishing"); + } + + if (packet.disclosureFlags.rightsRisk) { + flags.push("celebrity_likeness_review"); + requiredActions.push("Human review before publishing"); + } + + if (packet.disclosureFlags.syntheticMedia) { + flags.push("synthetic_media_disclosure"); + requiredActions.push("Add synthetic media disclosure"); + } + + const lowConfidenceCount = packet.detectedItems.filter( + (item) => item.confidence === "low" + ).length; + if (lowConfidenceCount > 0) { + flags.push("low_confidence_item"); + requiredActions.push("Verify low-confidence item identity"); + } + + if (variants.some((variant) => variant.disclosure.trim().length === 0)) { + flags.push("missing_disclosure"); + requiredActions.push("Add disclosure copy"); + } + + const researchSourceIds = new Set( + packet.externalEvidence?.sources + .filter((source) => source.url.trim().length > 0) + .map((source) => source.id) ?? [] + ); + + for (const variant of variants) { + if ( + variant.researchProvenance?.useResearchInCopy === false && + variant.researchProvenance.usedEvidenceRefs.length > 0 + ) { + flags.push("RESEARCH_COPY_WITHOUT_EVIDENCE"); + requiredActions.push( + "Remove research-backed copy or enable research copy usage" + ); + } + + for (const claim of variant.claims ?? []) { + if (claim.source === "research" && claim.evidenceRefs.length === 0) { + flags.push("UNSUPPORTED_CLAIM"); + requiredActions.push("Add source evidence for research-backed claims"); + } + + if ( + claim.source === "research" && + claim.evidenceRefs.length > 0 && + !claim.evidenceRefs.every((ref) => researchSourceIds.has(ref)) + ) { + flags.push("TREND_WITHOUT_SOURCE"); + requiredActions.push("Attach a valid source URL for research claims"); + } + + if (claim.source === "research" && claim.confidence < 0.5) { + flags.push("LOW_CONFIDENCE_RESEARCH_CLAIM"); + requiredActions.push("Human review for low-confidence research claim"); + } + } + } + + const hasResearchRisk = flags.some((flag) => + [ + "RESEARCH_COPY_WITHOUT_EVIDENCE", + "UNSUPPORTED_CLAIM", + "TREND_WITHOUT_SOURCE", + "LOW_CONFIDENCE_RESEARCH_CLAIM", + ].includes(flag) + ); + + if (flags.includes("thin_repost")) { + return { + verdict: "reject", + riskLevel: "high", + flags, + requiredActions, + }; + } + + if (packet.riskLevel !== "low" || flags.length > 0) { + return { + verdict: "needs_review", + riskLevel: + hasResearchRisk && packet.riskLevel === "low" + ? "medium" + : packet.riskLevel, + flags, + requiredActions, + }; + } + + return { + verdict: "approve", + riskLevel: "low", + flags, + requiredActions, + }; +} diff --git a/packages/web/lib/content-studio/index.ts b/packages/web/lib/content-studio/index.ts new file mode 100644 index 00000000..d6ac725d --- /dev/null +++ b/packages/web/lib/content-studio/index.ts @@ -0,0 +1,6 @@ +export * from "./schemas"; +export * from "./packet-builder"; +export * from "./governance-check"; +export * from "./opportunity-score"; +export * from "./research/query-suggestions"; +export * from "./assets"; diff --git a/packages/web/lib/content-studio/llm-client.ts b/packages/web/lib/content-studio/llm-client.ts new file mode 100644 index 00000000..ea75cc10 --- /dev/null +++ b/packages/web/lib/content-studio/llm-client.ts @@ -0,0 +1,177 @@ +import type { + ContentPacket, + ContentVariant, + ContentVariantFormat, + ResearchInsight, + ResearchSource, +} from "./schemas"; +import { + contentGovernanceLLMJsonSchema, + contentGovernanceLLMSchema, + contentVariantLLMJsonSchema, + contentVariantLLMResponseSchema, + type ContentGovernanceLLM, + type ContentVariantLLMResponse, +} from "./llm-schemas"; +import { CONTENT_CREATOR_SYSTEM_PROMPT } from "./llm-prompts"; + +const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; + +type ResponsesApiOutput = { + output_text?: string; + output?: Array<{ + content?: Array<{ + type?: string; + text?: string; + }>; + }>; +}; + +function extractOutputText(data: ResponsesApiOutput): string | null { + if (typeof data.output_text === "string") return data.output_text; + + for (const output of data.output ?? []) { + for (const content of output.content ?? []) { + if (content.type === "output_text" && typeof content.text === "string") { + return content.text; + } + } + } + + return null; +} + +export function isContentStudioLlmEnabled(): boolean { + return process.env.CONTENT_STUDIO_LLM_ENABLED === "true"; +} + +export function contentStudioModel(): string { + return process.env.CONTENT_STUDIO_MODEL || "gpt-4.1"; +} + +export async function generateContentVariantsWithOpenAI(input: { + packet: ContentPacket; + channels: ContentVariantFormat[]; + locale: "ko-KR" | "en-US"; + tone: string; + model?: string; + researchContext?: { + runId: string; + sources?: ResearchSource[]; + insights: ResearchInsight[]; + }; +}): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not configured"); + } + + const response = await fetch(OPENAI_RESPONSES_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: input.model ?? contentStudioModel(), + input: [ + { + role: "system", + content: CONTENT_CREATOR_SYSTEM_PROMPT, + }, + { + role: "user", + content: JSON.stringify({ + packet: input.packet, + channels: input.channels, + locale: input.locale, + tone: input.tone, + ...(input.researchContext + ? { researchContext: input.researchContext } + : {}), + }), + }, + ], + text: { + format: { + type: "json_schema", + name: "decoded_content_variants", + strict: true, + schema: contentVariantLLMJsonSchema, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI response failed: ${response.status}`); + } + + const data = (await response.json()) as ResponsesApiOutput; + const text = extractOutputText(data); + if (!text) { + throw new Error("OpenAI response did not include output text"); + } + + return contentVariantLLMResponseSchema.parse(JSON.parse(text)); +} + +export async function reviewContentGovernanceWithOpenAI(input: { + packet: ContentPacket; + variants: ContentVariant[]; + ruleFlags: string[]; + locale: "ko-KR" | "en-US"; + model?: string; +}): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not configured"); + } + + const response = await fetch(OPENAI_RESPONSES_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: input.model ?? contentStudioModel(), + input: [ + { + role: "system", + content: CONTENT_CREATOR_SYSTEM_PROMPT, + }, + { + role: "user", + content: JSON.stringify({ + task: "Review generated decoded marketing variants for factual and editorial risk.", + packet: input.packet, + variants: input.variants, + ruleFlags: input.ruleFlags, + locale: input.locale, + }), + }, + ], + text: { + format: { + type: "json_schema", + name: "decoded_content_governance", + strict: true, + schema: contentGovernanceLLMJsonSchema, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI governance response failed: ${response.status}`); + } + + const data = (await response.json()) as ResponsesApiOutput; + const text = extractOutputText(data); + if (!text) { + throw new Error("OpenAI governance response did not include output text"); + } + + return contentGovernanceLLMSchema.parse(JSON.parse(text)); +} diff --git a/packages/web/lib/content-studio/llm-generation.ts b/packages/web/lib/content-studio/llm-generation.ts new file mode 100644 index 00000000..57480ade --- /dev/null +++ b/packages/web/lib/content-studio/llm-generation.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; +import { generateChannelVariants } from "./packet-builder"; +import { + contentVariantLLMResponseSchema, + type ContentVariantLLMResponse, +} from "./llm-schemas"; +import { CONTENT_STUDIO_PROMPT_VERSION } from "./llm-prompts"; +import { + contentStudioModel, + generateContentVariantsWithOpenAI, + isContentStudioLlmEnabled, +} from "./llm-client"; +import type { + ContentGenerationMode, + ContentPacket, + ContentVariant, + ContentVariantFormat, + GovernanceResult, + ResearchInsight, + ResearchSource, +} from "./schemas"; +import type { ContentGovernanceLLM } from "./llm-schemas"; + +export { CONTENT_STUDIO_PROMPT_VERSION } from "./llm-prompts"; + +const DECISION_RANK = { + approve: 0, + needs_review: 1, + reject: 2, +} as const; + +const RISK_RANK = { + low: 0, + medium: 1, + high: 2, +} as const; + +type LlmGenerate = (input: { + packet: ContentPacket; + channels: ContentVariantFormat[]; + locale: "ko-KR" | "en-US"; + tone: string; + model?: string; + researchContext?: { + runId: string; + sources?: ResearchSource[]; + insights: ResearchInsight[]; + }; +}) => Promise; + +export type GenerateVariantsWithModeResult = { + variants: ContentVariant[]; + mode: ContentGenerationMode; + warning: string | null; +}; + +function inputHash(input: unknown): string { + return createHash("sha256") + .update(JSON.stringify(input)) + .digest("hex") + .slice(0, 16); +} + +function evidenceBackedInsights( + insights: ResearchInsight[] +): ResearchInsight[] { + return insights.filter((insight) => insight.evidenceRefs.length > 0); +} + +function withGenerationMetadata( + variants: ContentVariant[], + metadata: { + mode: ContentGenerationMode; + model?: string | null; + hash: string; + } +): ContentVariant[] { + return variants.map((variant) => ({ + ...variant, + generationMode: metadata.mode, + llmModel: metadata.model ?? null, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + generationInputHash: metadata.hash, + })); +} + +function templateResult( + packet: ContentPacket, + warning: string | null, + hash: string +): GenerateVariantsWithModeResult { + return { + variants: withGenerationMetadata(generateChannelVariants(packet), { + mode: "template", + model: null, + hash, + }), + mode: "template", + warning, + }; +} + +function mapLlmResponseToVariants(input: { + packet: ContentPacket; + response: ContentVariantLLMResponse; + templates: ContentVariant[]; + mode: ContentGenerationMode; + model: string; + hash: string; + researchContext?: { + runId: string; + sources?: ResearchSource[]; + insights: ResearchInsight[]; + }; + useResearchInCopy: boolean; +}): ContentVariant[] { + return input.templates.map((template) => { + const generated = input.response.variants.find( + (variant) => variant.format === template.format + ); + if (!generated) { + throw new Error(`Missing LLM variant for ${template.format}`); + } + + return { + ...template, + channel: generated.channel, + format: generated.format, + title: generated.title, + body: generated.body, + mediaPlan: generated.mediaPlan, + hashtags: generated.hashtags, + disclosure: generated.disclosure, + riskNotes: generated.riskNotes, + claims: generated.claims, + researchProvenance: input.researchContext + ? { + researchRunId: input.researchContext.runId, + useResearchInCopy: input.useResearchInCopy, + usedEvidenceRefs: + input.useResearchInCopy === true + ? Array.from( + new Set( + generated.claims.flatMap((claim) => claim.evidenceRefs) + ) + ) + : [], + } + : undefined, + generationMode: input.mode, + llmModel: input.model, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + generationInputHash: input.hash, + }; + }); +} + +export async function generateVariantsWithMode(input: { + packet: ContentPacket; + mode: ContentGenerationMode; + channels?: ContentVariantFormat[]; + locale?: "ko-KR" | "en-US"; + tone?: string; + llmEnabled?: boolean; + model?: string; + llmGenerate?: LlmGenerate; + researchContext?: { + runId: string; + insights: ResearchInsight[]; + }; + useResearchInCopy?: boolean; +}): Promise { + const channels = input.channels ?? [ + "instagram_carousel", + "instagram_reel", + "youtube_shorts", + "x_thread", + ]; + const locale = input.locale ?? "ko-KR"; + const tone = input.tone ?? "decoded_editorial"; + const researchContextForCopy = + input.researchContext && input.useResearchInCopy === true + ? { + runId: input.researchContext.runId, + insights: evidenceBackedInsights(input.researchContext.insights), + } + : undefined; + const hash = inputHash({ + packet: input.packet, + channels, + locale, + tone, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + researchContext: input.researchContext + ? { + runId: input.researchContext.runId, + insights: + input.useResearchInCopy === true && researchContextForCopy + ? researchContextForCopy.insights + : input.researchContext.insights, + useResearchInCopy: input.useResearchInCopy === true, + } + : null, + }); + + if (input.mode === "template") { + return templateResult(input.packet, null, hash); + } + + const llmEnabled = input.llmEnabled ?? isContentStudioLlmEnabled(); + const llmGenerate = input.llmGenerate ?? generateContentVariantsWithOpenAI; + const model = input.model ?? contentStudioModel(); + + if (!llmEnabled || (!input.llmGenerate && !process.env.OPENAI_API_KEY)) { + return templateResult( + input.packet, + "LLM client is not configured; used template fallback.", + hash + ); + } + + try { + const raw = await llmGenerate({ + packet: input.packet, + channels, + locale, + tone, + model, + ...(researchContextForCopy + ? { researchContext: researchContextForCopy } + : {}), + }); + const parsed = contentVariantLLMResponseSchema.safeParse(raw); + + if (!parsed.success) { + return templateResult( + input.packet, + "LLM output failed validation; used template fallback.", + hash + ); + } + + const templates = generateChannelVariants(input.packet).filter((variant) => + channels.includes(variant.format) + ); + return { + variants: mapLlmResponseToVariants({ + packet: input.packet, + response: parsed.data, + templates, + mode: input.mode, + model, + hash, + researchContext: input.researchContext, + useResearchInCopy: input.useResearchInCopy === true, + }), + mode: input.mode, + warning: null, + }; + } catch (error) { + return templateResult( + input.packet, + error instanceof Error + ? `LLM generation failed: ${error.message}; used template fallback.` + : "LLM generation failed; used template fallback.", + hash + ); + } +} + +export function mergeGovernanceResults( + ruleResult: GovernanceResult, + llmResult: GovernanceResult | null +): GovernanceResult { + if (!llmResult) return ruleResult; + + const verdict = + DECISION_RANK[llmResult.verdict] > DECISION_RANK[ruleResult.verdict] + ? llmResult.verdict + : ruleResult.verdict; + const riskLevel = + RISK_RANK[llmResult.riskLevel] > RISK_RANK[ruleResult.riskLevel] + ? llmResult.riskLevel + : ruleResult.riskLevel; + + return { + verdict, + riskLevel, + flags: Array.from(new Set([...ruleResult.flags, ...llmResult.flags])), + requiredActions: Array.from( + new Set([...ruleResult.requiredActions, ...llmResult.requiredActions]) + ), + }; +} + +export function governanceResultFromLlm( + result: ContentGovernanceLLM +): GovernanceResult { + return { + verdict: result.decision, + riskLevel: result.riskLevel, + flags: result.riskReasons, + requiredActions: result.requiredEdits, + }; +} diff --git a/packages/web/lib/content-studio/llm-prompts.ts b/packages/web/lib/content-studio/llm-prompts.ts new file mode 100644 index 00000000..49cb45f7 --- /dev/null +++ b/packages/web/lib/content-studio/llm-prompts.ts @@ -0,0 +1,19 @@ +export const CONTENT_STUDIO_PROMPT_VERSION = "content-studio-v0.2.0"; + +export const CONTENT_CREATOR_SYSTEM_PROMPT = ` +You are the editorial content engine for decoded. + +decoded is a style decoding platform. It turns fashion images into understandable style knowledge. + +Your job is not to invent new facts. Your job is to transform existing decoded post data into channel-native marketing content. + +Rules: +- Do not claim exact product identity unless the provided item confidence is high and source data supports it. +- Do not invent product names, brands, sponsorships, celebrity facts, or publishing rights. +- Prefer "similar item", "same silhouette", and "same mood" when confidence is uncertain. +- Always add interpretation: annotation, explanation, comparison, alternatives, or try-on framing. +- Do not create a thin repost. +- Keep celebrity and image usage respectful and non-misleading. +- Include disclosure copy when the input risk flags require it. +- Output must match the provided JSON schema. +`.trim(); diff --git a/packages/web/lib/content-studio/llm-schemas.ts b/packages/web/lib/content-studio/llm-schemas.ts new file mode 100644 index 00000000..7227e9df --- /dev/null +++ b/packages/web/lib/content-studio/llm-schemas.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; +import { contentVariantFormatSchema, contentChannelSchema } from "./schemas"; + +export const contentPacketEnrichmentSchema = z.object({ + hook: z.string(), + styleAngle: z.string(), + whyItWorks: z.string(), + audience: z.enum(["kpop_fans", "fashion_seekers", "shoppers", "general"]), + contentAngles: z.array(z.string()).min(1).max(5), + recommendedChannels: z.array(contentVariantFormatSchema), + riskNotes: z.array(z.string()), + confidenceNotes: z.array(z.string()), +}); + +export const llmContentVariantSchema = z.object({ + channel: contentChannelSchema, + format: contentVariantFormatSchema, + title: z.string(), + body: z.string(), + hook: z.string(), + mediaPlan: z.record(z.string(), z.unknown()), + hashtags: z.array(z.string()), + disclosure: z.string(), + cta: z.string(), + riskNotes: z.array(z.string()), + claims: z + .array( + z.object({ + text: z.string(), + evidenceRefs: z.array(z.string()), + confidence: z.number().min(0).max(1), + source: z.enum(["packet", "research", "generated"]), + }) + ) + .default([]), +}); + +export const contentVariantLLMResponseSchema = z.object({ + variants: z.array(llmContentVariantSchema).min(1), +}); + +export const contentGovernanceLLMSchema = z.object({ + decision: z.enum(["approve", "needs_review", "reject"]), + riskLevel: z.enum(["low", "medium", "high"]), + riskReasons: z.array(z.string()), + requiredEdits: z.array(z.string()), + safeRewrite: z.string().optional(), +}); + +export const contentVariantLLMJsonSchema = { + type: "object", + additionalProperties: false, + required: ["variants"], + properties: { + variants: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: [ + "channel", + "format", + "title", + "body", + "hook", + "mediaPlan", + "hashtags", + "disclosure", + "cta", + "riskNotes", + "claims", + ], + properties: { + channel: { + type: "string", + enum: ["instagram", "youtube", "x"], + }, + format: { + type: "string", + enum: [ + "instagram_carousel", + "instagram_reel", + "youtube_shorts", + "x_thread", + ], + }, + title: { type: "string" }, + body: { type: "string" }, + hook: { type: "string" }, + mediaPlan: { + type: "object", + additionalProperties: false, + required: ["format", "instructions"], + properties: { + format: { type: "string" }, + instructions: { + type: "array", + items: { type: "string" }, + }, + }, + }, + hashtags: { + type: "array", + items: { type: "string" }, + }, + disclosure: { type: "string" }, + cta: { type: "string" }, + riskNotes: { + type: "array", + items: { type: "string" }, + }, + claims: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["text", "evidenceRefs", "confidence", "source"], + properties: { + text: { type: "string" }, + evidenceRefs: { + type: "array", + items: { type: "string" }, + }, + confidence: { type: "number" }, + source: { + type: "string", + enum: ["packet", "research", "generated"], + }, + }, + }, + }, + }, + }, + }, + }, +} as const; + +export const contentGovernanceLLMJsonSchema = { + type: "object", + additionalProperties: false, + required: [ + "decision", + "riskLevel", + "riskReasons", + "requiredEdits", + "safeRewrite", + ], + properties: { + decision: { + type: "string", + enum: ["approve", "needs_review", "reject"], + }, + riskLevel: { + type: "string", + enum: ["low", "medium", "high"], + }, + riskReasons: { + type: "array", + items: { type: "string" }, + }, + requiredEdits: { + type: "array", + items: { type: "string" }, + }, + safeRewrite: { + type: "string", + }, + }, +} as const; + +export type ContentVariantLLMResponse = z.infer< + typeof contentVariantLLMResponseSchema +>; +export type ContentGovernanceLLM = z.infer; diff --git a/packages/web/lib/content-studio/opportunity-score.ts b/packages/web/lib/content-studio/opportunity-score.ts new file mode 100644 index 00000000..80efb913 --- /dev/null +++ b/packages/web/lib/content-studio/opportunity-score.ts @@ -0,0 +1,29 @@ +export interface OpportunitySignals { + searchIntent: number; + saveRate: number; + tryOnStarts: number; + itemClickRate: number; + editorialScore: number; + externalTrendSignal: number; + freshness: number; + rightsRisk: number; +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +export function scoreMarketingOpportunity(signals: OpportunitySignals): number { + const weighted = + clamp01(signals.searchIntent) * 0.2 + + clamp01(signals.saveRate) * 0.2 + + clamp01(signals.tryOnStarts) * 0.15 + + clamp01(signals.itemClickRate) * 0.15 + + clamp01(signals.editorialScore) * 0.15 + + clamp01(signals.externalTrendSignal) * 0.1 + + clamp01(signals.freshness) * 0.05 - + clamp01(signals.rightsRisk) * 0.3; + + return Math.round(Math.max(0, weighted) * 100); +} diff --git a/packages/web/lib/content-studio/packet-builder.ts b/packages/web/lib/content-studio/packet-builder.ts new file mode 100644 index 00000000..873fb5fa --- /dev/null +++ b/packages/web/lib/content-studio/packet-builder.ts @@ -0,0 +1,242 @@ +import type { + PostDetailResponse, + SpotWithTopSolution, +} from "@/lib/api/generated/models"; +import type { ContentPacket, ContentVariant, ItemEntity } from "./schemas"; + +type MetadataRecord = Record; + +function asRecord(value: unknown): MetadataRecord { + return value != null && typeof value === "object" + ? (value as MetadataRecord) + : {}; +} + +function metadataString(metadata: unknown, key: string): string | null { + const value = asRecord(metadata)[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function packetId(postId: string): string { + return `packet_${postId}`; +} + +function variantId(packetIdValue: string, format: string): string { + return `${packetIdValue}_${format}`; +} + +function confidenceForSpot( + spot: SpotWithTopSolution +): ItemEntity["confidence"] { + if (spot.top_solution?.is_verified || spot.status === "verified") { + return "high"; + } + if (spot.solution_count > 0 || spot.top_solution) { + return "medium"; + } + return "low"; +} + +function itemFromSpot(spot: SpotWithTopSolution): ItemEntity { + const top = spot.top_solution; + return { + id: top?.id ?? spot.id, + title: top?.title ?? "Unidentified item", + brand: metadataString(top?.metadata, "brand"), + thumbnailUrl: top?.thumbnail_url ?? null, + sourceUrl: top?.affiliate_url ?? top?.original_url ?? null, + confidence: confidenceForSpot(spot), + verified: Boolean(top?.is_verified || spot.status === "verified"), + }; +} + +function buildHook(post: PostDetailResponse, items: ItemEntity[]): string { + const subject = + post.artist_name || post.group_name || post.context || "This look"; + const leadItem = items[0]?.title; + if (leadItem) { + return `${subject}: the styling works because the ${leadItem} sets the proportion.`; + } + return `${subject}: decode the item, silhouette, and styling logic.`; +} + +function buildWhyItWorks( + post: PostDetailResponse, + items: ItemEntity[] +): string { + if (post.ai_summary?.trim()) return post.ai_summary.trim(); + if (items.length > 0) { + return `The look is anchored by ${items + .slice(0, 3) + .map((item) => item.title) + .join(", ")}. Match the silhouette before chasing exact brands.`; + } + return "No interpretation layer is available yet."; +} + +function riskLevelForPost( + post: PostDetailResponse +): ContentPacket["riskLevel"] { + if (post.artist_name || post.group_name) return "medium"; + if (post.media_source?.type === "ai_generated") return "high"; + return "low"; +} + +export function buildContentPacketFromPost( + post: PostDetailResponse +): ContentPacket { + const detectedItems = post.spots.map(itemFromSpot); + const id = packetId(post.id); + const now = new Date().toISOString(); + const summary = + post.ai_summary?.trim() || buildWhyItWorks(post, detectedItems); + + return { + id, + postId: post.id, + sourceImage: post.image_url, + title: + post.title || + [post.artist_name, post.context].filter(Boolean).join(" / ") || + "Untitled decoded post", + hook: buildHook(post, detectedItems), + artist: post.artist_name ?? null, + group: post.group_name ?? null, + context: post.context ?? null, + detectedItems, + styleSummary: summary, + whyItWorks: buildWhyItWorks(post, detectedItems), + alternatives: { + budget: [], + mid: detectedItems.filter((item) => item.confidence !== "low"), + premium: [], + }, + disclosureFlags: { + aiGenerated: true, + syntheticMedia: post.media_source?.type === "ai_generated", + sponsored: false, + rightsRisk: Boolean(post.artist_name || post.group_name), + }, + riskLevel: riskLevelForPost(post), + reviewStatus: "draft", + createdAt: now, + }; +} + +function itemList(items: ItemEntity[]): string { + if (items.length === 0) return "- Item identity needs editor review"; + return items + .slice(0, 4) + .map( + (item, idx) => + `${idx + 1}. ${item.brand ? `${item.brand} ` : ""}${item.title}` + ) + .join("\n"); +} + +export function generateChannelVariants( + packet: ContentPacket +): ContentVariant[] { + const tags = ["decoded", "styledecoder", "kfashion"]; + const itemBreakdown = itemList(packet.detectedItems); + const disclosure = packet.disclosureFlags.rightsRisk + ? "AI-assisted style analysis. Source and rights require editor review before publishing." + : "AI-assisted style analysis with editor review required before publishing."; + + return [ + { + id: variantId(packet.id, "instagram_carousel"), + packetId: packet.id, + channel: "instagram", + format: "instagram_carousel", + title: packet.hook, + body: [ + "Slide 1: This look works because of proportion.", + `Slide 2: ${packet.title}`, + `Slide 3: Key items\n${itemBreakdown}`, + `Slide 4: Silhouette\n${packet.whyItWorks}`, + `Slide 5: Mood\n${packet.styleSummary}`, + "Slide 6: Start with silhouette, then choose similar items.", + "Slide 7: Budget / Mid / Premium alternatives need editor selection.", + "Slide 8: Save or try the full decode in decoded.", + ].join("\n\n"), + mediaPlan: { + sourceImage: packet.sourceImage, + slides: [ + "hook", + "full_look", + "key_items", + "silhouette", + "mood", + "how_to_wear", + "alternatives", + "decoded_cta", + ], + }, + hashtags: tags, + disclosure, + status: "draft", + }, + { + id: variantId(packet.id, "instagram_reel"), + packetId: packet.id, + channel: "instagram", + format: "instagram_reel", + title: packet.hook, + body: [ + `Hook: ${packet.hook}`, + `Decode: ${packet.whyItWorks}`, + `Breakdown:\n${itemBreakdown}`, + "CTA: Full decode in decoded.", + ].join("\n\n"), + mediaPlan: { + sourceImage: packet.sourceImage, + beats: ["hook", "look", "items", "why_it_works", "cta"], + }, + hashtags: tags, + disclosure, + status: "draft", + }, + { + id: variantId(packet.id, "youtube_shorts"), + packetId: packet.id, + channel: "youtube", + format: "youtube_shorts", + title: `Why ${packet.title} works`, + body: [ + packet.hook, + packet.whyItWorks, + `decoded found these core items:\n${itemBreakdown}`, + "To wear it yourself, match the silhouette before the exact brand.", + ].join("\n\n"), + mediaPlan: { + sourceImage: packet.sourceImage, + shots: ["opening_claim", "zoom_items", "fit_logic", "cta"], + }, + hashtags: ["decoded", "styleanalysis", "kpopfashion"], + disclosure, + status: "draft", + }, + { + id: variantId(packet.id, "x_thread"), + packetId: packet.id, + channel: "x", + format: "x_thread", + title: packet.hook, + body: [ + `1/ ${packet.hook}`, + `2/ The key is not only the brand. ${packet.whyItWorks}`, + `3/ decoded item read:\n${itemBreakdown}`, + "4/ To recreate it, start with silhouette and proportion.", + "5/ Full decode: [link]", + ].join("\n\n"), + mediaPlan: { + sourceImage: packet.sourceImage, + posts: 5, + }, + hashtags: ["decoded", "styledecoder"], + disclosure, + status: "draft", + }, + ]; +} diff --git a/packages/web/lib/content-studio/post-source.ts b/packages/web/lib/content-studio/post-source.ts new file mode 100644 index 00000000..099c67d8 --- /dev/null +++ b/packages/web/lib/content-studio/post-source.ts @@ -0,0 +1,142 @@ +import type { + MediaSourceDto, + PostDetailResponse, + SpotWithTopSolution, + TopSolutionSummary, +} from "@/lib/api/generated/models"; +import type { + Database, + PostRow, + SolutionRow, + SpotRow, +} from "@/lib/supabase/types"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +type JsonRecord = Record; + +export type ContentStudioPostRow = PostRow & { + spots?: Array | null; +}; + +function asRecord(value: unknown): JsonRecord { + return value != null && typeof value === "object" + ? (value as JsonRecord) + : {}; +} + +function metadataDescription(metadata: unknown): string | null { + const value = asRecord(metadata).description; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function mediaSourceFromPost(post: PostRow): MediaSourceDto { + return { + type: post.media_type, + description: metadataDescription(post.media_metadata), + }; +} + +function rankSolutions(solutions: SolutionRow[]): SolutionRow[] { + return [...solutions].sort((a, b) => { + const aScore = + (a.is_adopted ? 4 : 0) + + (a.is_verified ? 2 : 0) + + (a.status === "active" ? 1 : 0); + const bScore = + (b.is_adopted ? 4 : 0) + + (b.is_verified ? 2 : 0) + + (b.status === "active" ? 1 : 0); + return bScore - aScore; + }); +} + +function topSolutionFromRow(solution: SolutionRow): TopSolutionSummary { + return { + id: solution.id, + title: solution.title, + thumbnail_url: solution.thumbnail_url, + original_url: solution.original_url, + affiliate_url: solution.affiliate_url, + brand_logo_url: null, + is_adopted: solution.is_adopted, + is_verified: solution.is_verified, + metadata: solution.metadata, + }; +} + +function spotFromRow( + spot: SpotRow & { solutions?: SolutionRow[] | null } +): SpotWithTopSolution { + const activeSolutions = (spot.solutions ?? []).filter( + (solution) => solution.status === "active" + ); + const topSolution = rankSolutions(activeSolutions)[0] ?? null; + + return { + id: spot.id, + position_left: spot.position_left, + position_top: spot.position_top, + solution_count: activeSolutions.length, + status: spot.status, + created_at: spot.created_at, + category: null, + top_solution: topSolution ? topSolutionFromRow(topSolution) : null, + }; +} + +export function mapSupabasePostToPostDetail( + post: ContentStudioPostRow +): PostDetailResponse { + return { + id: post.id, + image_url: post.image_url, + image_width: post.image_width, + image_height: post.image_height, + title: post.title, + ai_summary: post.ai_summary, + artist_name: post.artist_name, + artist_profile_image_url: null, + group_name: post.group_name, + group_profile_image_url: null, + context: post.context, + created_at: post.created_at, + updated_at: post.updated_at, + created_with_solutions: post.created_with_solutions, + status: post.status, + style_tags: post.style_tags, + media_source: mediaSourceFromPost(post), + parent_post_id: null, + post_magazine_id: post.post_magazine_id, + user: { + id: post.user_id, + username: "unknown", + rank: "user", + avatar_url: null, + }, + spots: (post.spots ?? []).map(spotFromRow), + comment_count: 0, + like_count: 0, + save_count: 0, + try_count: null, + view_count: post.view_count, + }; +} + +export async function fetchContentStudioPostDetail( + supabase: SupabaseClient, + postId: string +): Promise { + const { data, error } = await supabase + .from("posts") + .select("*, spots(*, solutions(*))") + .eq("id", postId) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + return data + ? mapSupabasePostToPostDetail(data as ContentStudioPostRow) + : null; +} diff --git a/packages/web/lib/content-studio/research/domain-policy.ts b/packages/web/lib/content-studio/research/domain-policy.ts new file mode 100644 index 00000000..d8dccdf8 --- /dev/null +++ b/packages/web/lib/content-studio/research/domain-policy.ts @@ -0,0 +1,131 @@ +export type SearchResultCandidate = { + url: string; + title: string | null; + description: string | null; +}; + +export type DomainPolicy = { + allowedDomains: string[]; + blockedDomains: string[]; +}; + +const DEFAULT_ALLOWED_DOMAINS = [ + // Fashion magazines / editorial + "vogue.com", + "vogue.co.uk", + "vogue.fr", + "vogue.it", + "elle.com", + "harpersbazaar.com", + "wmagazine.com", + "gq.com", + "wwd.com", + "interviewmagazine.com", + "anothermag.com", + "dazeddigital.com", + "i-d.co", + "i-d.vice.com", + "vice.com", + "vman.com", + "papermag.com", + "nylon.com", + "purple.fr", + "selfservicemagazine.com", + "032c.com", + // Streetwear / culture + "highsnobiety.com", + "hypebeast.com", + "hypebae.com", + "complex.com", + "ssense.com", + // Industry / trade + "businessoffashion.com", + "fashionista.com", + "thefashionspot.com", + "refinery29.com", + // Visual reference platforms + "youtube.com", + "youtu.be", + "pinterest.com", + "instagram.com", + "behance.net", + "tumblr.com", + "medium.com", + "substack.com", + // Mainstream culture coverage + "nytimes.com", + "newyorker.com", + "theguardian.com", +]; + +function parseDomains(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); +} + +function researchUrlParts( + url: string +): { host: string; protocol: string } | null { + try { + const parsed = new URL(url); + return { + host: parsed.hostname.toLowerCase(), + protocol: parsed.protocol, + }; + } catch { + return null; + } +} + +function matchesDomain(host: string, domain: string): boolean { + return host === domain || host.endsWith(`.${domain}`); +} + +export function buildDomainPolicy(input?: { + allowedDomains?: string; + blockedDomains?: string; +}): DomainPolicy { + const configuredAllowedDomains = parseDomains( + input?.allowedDomains ?? process.env.CONTENT_STUDIO_RESEARCH_ALLOWED_DOMAINS + ); + + return { + allowedDomains: + configuredAllowedDomains.length > 0 + ? configuredAllowedDomains + : DEFAULT_ALLOWED_DOMAINS, + blockedDomains: parseDomains( + input?.blockedDomains ?? + process.env.CONTENT_STUDIO_RESEARCH_BLOCKED_DOMAINS + ), + }; +} + +export function isAllowedResearchUrl( + url: string, + policy: DomainPolicy +): boolean { + const parsed = researchUrlParts(url); + if (!parsed) return false; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + if ( + policy.blockedDomains.some((domain) => matchesDomain(parsed.host, domain)) + ) { + return false; + } + if (policy.allowedDomains.length === 0) return false; + return policy.allowedDomains.some((domain) => + matchesDomain(parsed.host, domain) + ); +} + +export function filterAllowedSearchResults( + results: T[], + policy: DomainPolicy +): T[] { + return results.filter((result) => isAllowedResearchUrl(result.url, policy)); +} diff --git a/packages/web/lib/content-studio/research/firecrawl-client.ts b/packages/web/lib/content-studio/research/firecrawl-client.ts new file mode 100644 index 00000000..b665fd4c --- /dev/null +++ b/packages/web/lib/content-studio/research/firecrawl-client.ts @@ -0,0 +1,186 @@ +export type FirecrawlSearchResult = { + url: string; + title: string | null; + description: string | null; +}; + +export type FirecrawlScrapeResult = { + url: string; + title: string | null; + markdown: string | null; +}; + +export type FirecrawlClient = { + search(query: string, maxResults: number): Promise; + scrape(url: string): Promise; +}; + +type FirecrawlClientOptions = { + token?: string; + apiBase?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +}; + +const DEFAULT_API_BASE = "https://api.firecrawl.dev"; +const DEFAULT_TIMEOUT_MS = 15_000; + +function resolveToken(input?: string): string { + const token = + input?.trim() || + process.env.FIRECRAWL_TOKEN?.trim() || + process.env.FIRECRAWL_API_KEY?.trim(); + + if (!token) { + throw new Error("Missing Firecrawl token. Set FIRECRAWL_TOKEN."); + } + + return token; +} + +function resolveApiBase(input?: string): string { + return ( + input?.trim() || + process.env.FIRECRAWL_API_BASE?.trim() || + DEFAULT_API_BASE + ).replace(/\/+$/, ""); +} + +function resolveTimeout(input?: number): number { + return input ?? Number(process.env.FIRECRAWL_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function searchItems(raw: unknown): unknown[] { + const root = asRecord(raw); + const data = root.data; + + if (Array.isArray(data)) { + return data; + } + + const dataRecord = asRecord(data); + return Array.isArray(dataRecord.web) ? dataRecord.web : []; +} + +function scrapeData(raw: unknown): Record { + return asRecord(asRecord(raw).data); +} + +function metadataTitle(data: Record): string | null { + const metadata = asRecord(data.metadata); + return stringOrNull(data.title) ?? stringOrNull(metadata.title); +} + +async function readJsonResponse(response: Response): Promise { + const payload = await response.json().catch(() => null); + + if (!response.ok) { + const message = stringOrNull(asRecord(payload).error); + throw new Error(message ?? `Firecrawl HTTP ${response.status}`); + } + + const record = asRecord(payload); + if (record.success === false) { + throw new Error(stringOrNull(record.error) ?? "Firecrawl request failed"); + } + + return payload; +} + +async function postFirecrawlJson( + fetchImpl: typeof fetch, + url: string, + token: string, + timeoutMs: number, + body: Record +): Promise { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + return readJsonResponse(response); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Firecrawl timed out"); + } + + throw error; + } finally { + globalThis.clearTimeout(timeout); + } +} + +export function createFirecrawlClient( + options: FirecrawlClientOptions = {} +): FirecrawlClient { + const token = resolveToken(options.token); + const apiBase = resolveApiBase(options.apiBase); + const timeoutMs = resolveTimeout(options.timeoutMs); + const fetchImpl = options.fetchImpl ?? fetch; + + return { + async search(query, maxResults) { + const raw = await postFirecrawlJson( + fetchImpl, + `${apiBase}/v2/search`, + token, + timeoutMs, + { + query, + limit: maxResults, + sources: ["web"], + ignoreInvalidURLs: true, + } + ); + + return searchItems(raw).map((item) => { + const record = asRecord(item); + return { + url: String(record.url ?? record.link ?? ""), + title: stringOrNull(record.title), + description: stringOrNull(record.description), + }; + }); + }, + async scrape(url) { + const raw = await postFirecrawlJson( + fetchImpl, + `${apiBase}/v2/scrape`, + token, + timeoutMs, + { + url, + formats: ["markdown"], + onlyMainContent: true, + } + ); + const data = scrapeData(raw); + + return { + url, + title: metadataTitle(data), + markdown: stringOrNull(data.markdown), + }; + }, + }; +} diff --git a/packages/web/lib/content-studio/research/index.ts b/packages/web/lib/content-studio/research/index.ts new file mode 100644 index 00000000..af4556f0 --- /dev/null +++ b/packages/web/lib/content-studio/research/index.ts @@ -0,0 +1,6 @@ +export * from "./domain-policy"; +export * from "./firecrawl-client"; +export * from "./normalization"; +export * from "./query-suggestions"; +export * from "./recommendations"; +export * from "./service"; diff --git a/packages/web/lib/content-studio/research/normalization.ts b/packages/web/lib/content-studio/research/normalization.ts new file mode 100644 index 00000000..2d58e6a8 --- /dev/null +++ b/packages/web/lib/content-studio/research/normalization.ts @@ -0,0 +1,88 @@ +import type { + ResearchRun, + ResearchSource, + ResearchSourceType, +} from "../schemas"; +import { deriveResearchRecommendations } from "./recommendations"; + +export type ResearchRecord = { + url: string; + title: string | null; + description: string | null; + markdown: string | null; + sourceType: ResearchSourceType; +}; + +function domainFromUrl(url: string): string { + return new URL(url).hostname.toLowerCase(); +} + +function confidenceFor(record: ResearchRecord): "low" | "medium" | "high" { + const text = `${record.description ?? ""} ${record.markdown ?? ""}`; + if (text.length > 600) return "high"; + if (text.length > 80) return "medium"; + return "low"; +} + +function claimType(sourceType: ResearchSourceType): "trend" | "format_pattern" { + return sourceType === "channel_format" ? "format_pattern" : "trend"; +} + +export function normalizeResearchRun(input: { + runId: string; + packetId: string; + query: string; + sourceTypes: ResearchSourceType[]; + fetchedAt: string; + records: ResearchRecord[]; + failedCount: number; +}): ResearchRun { + const sources: ResearchSource[] = input.records.map((record, index) => ({ + id: `source-${index + 1}`, + runId: input.runId, + url: record.url, + title: record.title, + domain: domainFromUrl(record.url), + sourceType: record.sourceType, + fetchedAt: input.fetchedAt, + confidence: confidenceFor(record), + })); + + const insights = sources.map((source, index) => { + const record = input.records[index]; + const summary = + record.markdown?.trim() || + record.description?.trim() || + source.title || + source.url; + return { + id: `insight-${index + 1}`, + runId: input.runId, + sourceIds: [source.id], + topic: source.title ?? input.query, + summary: summary.slice(0, 280), + claimType: claimType(source.sourceType), + evidenceRefs: [source.id], + confidence: source.confidence, + }; + }); + + return { + id: input.runId, + packetId: input.packetId, + query: input.query, + mode: "manual", + status: + input.records.length === 0 + ? input.failedCount > 0 + ? "failed" + : "completed" + : input.failedCount > 0 + ? "partial" + : "completed", + createdAt: input.fetchedAt, + sources, + insights, + recommendations: deriveResearchRecommendations(insights), + }; +} diff --git a/packages/web/lib/content-studio/research/query-suggestions.ts b/packages/web/lib/content-studio/research/query-suggestions.ts new file mode 100644 index 00000000..1f84df15 --- /dev/null +++ b/packages/web/lib/content-studio/research/query-suggestions.ts @@ -0,0 +1,27 @@ +import type { ContentPacket } from "../schemas"; + +function compact(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function unique(values: string[]): string[] { + return Array.from( + new Set(values.map((value) => value.trim()).filter(Boolean)) + ); +} + +export function suggestResearchQueries(packet: ContentPacket): string[] { + const context = compact(packet.context); + const artistOrGroup = compact(packet.artist) ?? compact(packet.group); + const title = compact(packet.title); + + return unique([ + context ? `${context} fashion trend` : "K-pop airport fashion trend", + artistOrGroup ? `${artistOrGroup} style analysis` : "", + title ? `${title} styling trend` : "", + "Instagram carousel fashion hook style analysis", + "YouTube Shorts fashion analysis format", + "X thread fashion commentary format", + ]).slice(0, 6); +} diff --git a/packages/web/lib/content-studio/research/recommendations.ts b/packages/web/lib/content-studio/research/recommendations.ts new file mode 100644 index 00000000..915fa806 --- /dev/null +++ b/packages/web/lib/content-studio/research/recommendations.ts @@ -0,0 +1,55 @@ +import type { + ContentVariantFormat, + ResearchInsight, + ResearchRecommendations, +} from "../schemas"; + +const CONFIDENCE_SCORE = { + low: 0.25, + medium: 0.5, + high: 0.7, +} as const; + +function formatForInsight(insight: ResearchInsight): ContentVariantFormat { + const text = `${insight.topic} ${insight.summary}`.toLowerCase(); + if (text.includes("short") || text.includes("youtube")) { + return "youtube_shorts"; + } + if (text.includes("thread") || text.includes("x ")) return "x_thread"; + if (text.includes("reel")) return "instagram_reel"; + return "instagram_carousel"; +} + +export function deriveResearchRecommendations( + insights: ResearchInsight[] +): ResearchRecommendations { + const evidenceBacked = insights.filter( + (insight) => insight.evidenceRefs.length > 0 + ); + const externalTrendSignal = + evidenceBacked.length === 0 + ? 0 + : Math.max( + ...evidenceBacked.map( + (insight) => CONFIDENCE_SCORE[insight.confidence] + ) + ); + + const seen = new Set(); + const recommendedChannels = evidenceBacked + .map((insight) => ({ + format: formatForInsight(insight), + reason: insight.summary, + evidenceRefs: insight.evidenceRefs, + })) + .filter((recommendation) => { + if (seen.has(recommendation.format)) return false; + seen.add(recommendation.format); + return true; + }); + + return { + externalTrendSignal, + recommendedChannels, + }; +} diff --git a/packages/web/lib/content-studio/research/service.ts b/packages/web/lib/content-studio/research/service.ts new file mode 100644 index 00000000..f9f6b788 --- /dev/null +++ b/packages/web/lib/content-studio/research/service.ts @@ -0,0 +1,116 @@ +import type { + ContentPacket, + ResearchSourceType, + RunResearchRequest, +} from "../schemas"; +import { buildDomainPolicy, filterAllowedSearchResults } from "./domain-policy"; +import { + createFirecrawlClient, + type FirecrawlClient, +} from "./firecrawl-client"; +import { normalizeResearchRun, type ResearchRecord } from "./normalization"; + +export class ResearchUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "ResearchUnavailableError"; + } +} + +function enabled(): boolean { + return process.env.CONTENT_STUDIO_RESEARCH_ENABLED === "true"; +} + +function runId(packet: ContentPacket): string { + return `research_${packet.id}_${Date.now()}`; +} + +function sourceTypeForIndex( + sourceTypes: ResearchSourceType[], + index: number +): ResearchSourceType { + return sourceTypes[index % sourceTypes.length] ?? "style_trend"; +} + +export async function runContentResearch( + input: RunResearchRequest, + client?: FirecrawlClient +) { + if (!enabled()) { + throw new ResearchUnavailableError("Content Studio research is disabled"); + } + + let researchClient = client; + if (!researchClient) { + try { + researchClient = createFirecrawlClient(); + } catch (error) { + throw new ResearchUnavailableError( + error instanceof Error ? error.message : "Firecrawl is unavailable" + ); + } + } + + const policy = buildDomainPolicy(); + const rawResults = await researchClient.search( + input.query, + Math.max(input.maxResults, 6) + ); + const allowedResults = filterAllowedSearchResults(rawResults, policy); + const blockedDomains = policy.blockedDomains; + const fallbackResults = + allowedResults.length > 0 + ? allowedResults + : rawResults.filter((result) => { + try { + const host = new URL(result.url).hostname.toLowerCase(); + return !blockedDomains.some( + (domain) => host === domain || host.endsWith(`.${domain}`) + ); + } catch { + return false; + } + }); + const searchResults = fallbackResults.slice(0, input.maxResults); + const usedFallback = allowedResults.length === 0 && searchResults.length > 0; + + const records: ResearchRecord[] = []; + let failedCount = searchResults.length === 0 ? 1 : 0; + + for (const [index, result] of searchResults.entries()) { + try { + const scraped = await researchClient.scrape(result.url); + records.push({ + url: result.url, + title: scraped.title ?? result.title, + description: result.description, + markdown: scraped.markdown, + sourceType: sourceTypeForIndex(input.sourceTypes, index), + }); + } catch { + failedCount += 1; + } + } + + const run = normalizeResearchRun({ + runId: runId(input.packet), + packetId: input.packet.id, + query: input.query, + sourceTypes: input.sourceTypes, + fetchedAt: new Date().toISOString(), + records, + failedCount, + }); + + return { + run, + warning: + searchResults.length === 0 + ? "No research sources were found." + : usedFallback + ? "No whitelisted sources matched; falling back to top web results." + : failedCount > 0 + ? `${failedCount} research source${failedCount === 1 ? "" : "s"} failed to scrape.` + : null, + }; +} diff --git a/packages/web/lib/content-studio/schemas.ts b/packages/web/lib/content-studio/schemas.ts new file mode 100644 index 00000000..2486e6de --- /dev/null +++ b/packages/web/lib/content-studio/schemas.ts @@ -0,0 +1,331 @@ +import { z } from "zod"; + +export const contentChannelSchema = z.enum(["instagram", "youtube", "x"]); +export const contentVariantFormatSchema = z.enum([ + "instagram_carousel", + "instagram_reel", + "youtube_shorts", + "x_thread", +]); +export const contentRiskLevelSchema = z.enum(["low", "medium", "high"]); +export const contentReviewStatusSchema = z.enum([ + "draft", + "needs_review", + "approved", + "rejected", +]); +export const governanceVerdictSchema = z.enum([ + "approve", + "needs_review", + "reject", +]); +export const contentGenerationModeSchema = z.enum([ + "template", + "hybrid", + "llm", +]); + +export const researchSourceTypeSchema = z.enum([ + "style_trend", + "channel_format", +]); +export const researchStatusSchema = z.enum(["completed", "partial", "failed"]); +export const researchConfidenceSchema = z.enum(["low", "medium", "high"]); +export const researchClaimTypeSchema = z.enum([ + "trend", + "format_pattern", + "audience_signal", +]); + +export const researchSourceSchema = z.object({ + id: z.string(), + runId: z.string(), + url: z.string().url(), + title: z.string().nullable(), + domain: z.string(), + sourceType: researchSourceTypeSchema, + fetchedAt: z.string(), + confidence: researchConfidenceSchema, +}); + +export const researchInsightSchema = z.object({ + id: z.string(), + runId: z.string(), + sourceIds: z.array(z.string()), + topic: z.string(), + summary: z.string(), + claimType: researchClaimTypeSchema, + evidenceRefs: z.array(z.string()), + confidence: researchConfidenceSchema, +}); + +export const researchRecommendationsSchema = z.object({ + externalTrendSignal: z.number().min(0).max(1), + recommendedChannels: z.array( + z.object({ + format: contentVariantFormatSchema, + reason: z.string(), + evidenceRefs: z.array(z.string()), + }) + ), +}); + +export const researchRunSchema = z.object({ + id: z.string(), + packetId: z.string(), + query: z.string(), + mode: z.literal("manual"), + status: researchStatusSchema, + createdAt: z.string(), + sources: z.array(researchSourceSchema), + insights: z.array(researchInsightSchema), + recommendations: researchRecommendationsSchema, +}); + +export const itemEntitySchema = z.object({ + id: z.string(), + title: z.string(), + brand: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + sourceUrl: z.string().nullable(), + confidence: z.enum(["low", "medium", "high"]), + verified: z.boolean(), +}); + +export const contentPacketSchema = z.object({ + id: z.string(), + postId: z.string(), + sourceImage: z.string(), + title: z.string(), + hook: z.string(), + artist: z.string().nullable(), + group: z.string().nullable(), + context: z.string().nullable(), + detectedItems: z.array(itemEntitySchema), + styleSummary: z.string(), + whyItWorks: z.string(), + alternatives: z.object({ + budget: z.array(itemEntitySchema), + mid: z.array(itemEntitySchema), + premium: z.array(itemEntitySchema), + }), + disclosureFlags: z.object({ + aiGenerated: z.boolean(), + syntheticMedia: z.boolean(), + sponsored: z.boolean(), + rightsRisk: z.boolean(), + }), + riskLevel: contentRiskLevelSchema, + reviewStatus: contentReviewStatusSchema, + createdAt: z.string(), + externalEvidence: z + .object({ + researchRunId: z.string(), + sources: z.array(researchSourceSchema).default([]), + insights: z.array(researchInsightSchema), + }) + .optional(), +}); + +export const contentVariantSchema = z.object({ + id: z.string(), + packetId: z.string(), + channel: contentChannelSchema, + format: contentVariantFormatSchema, + title: z.string(), + body: z.string(), + mediaPlan: z.record(z.string(), z.unknown()), + hashtags: z.array(z.string()), + disclosure: z.string(), + status: contentReviewStatusSchema, + generationMode: contentGenerationModeSchema.optional(), + llmModel: z.string().nullable().optional(), + promptVersion: z.string().nullable().optional(), + generationInputHash: z.string().nullable().optional(), + generationCost: z.record(z.string(), z.unknown()).nullable().optional(), + governanceResult: z.record(z.string(), z.unknown()).nullable().optional(), + riskNotes: z.array(z.string()).optional(), + researchProvenance: z + .object({ + researchRunId: z.string(), + useResearchInCopy: z.boolean(), + usedEvidenceRefs: z.array(z.string()), + }) + .optional(), + claims: z + .array( + z.object({ + text: z.string(), + evidenceRefs: z.array(z.string()), + confidence: z.number().min(0).max(1), + source: z.enum(["packet", "research", "generated"]), + }) + ) + .optional(), + missingFacts: z.array(z.string()).optional(), +}); + +export const governanceResultSchema = z.object({ + verdict: governanceVerdictSchema, + riskLevel: contentRiskLevelSchema, + flags: z.array(z.string()), + requiredActions: z.array(z.string()), +}); + +export const createContentPacketRequestSchema = z.object({ + postId: z.string().min(1), +}); + +export const generateVariantsRequestSchema = z.object({ + packet: contentPacketSchema, + mode: contentGenerationModeSchema.default("template"), + channels: z.array(contentVariantFormatSchema).optional(), + locale: z.enum(["ko-KR", "en-US"]).default("ko-KR"), + tone: z.string().default("decoded_editorial"), + researchContext: z + .object({ + runId: z.string(), + sources: z.array(researchSourceSchema).optional(), + insights: z.array(researchInsightSchema), + }) + .optional(), + useResearchInCopy: z.boolean().optional(), +}); + +export const runResearchRequestSchema = z.object({ + packet: contentPacketSchema, + query: z.string().trim().min(3).max(160), + sourceTypes: z.array(researchSourceTypeSchema).min(1).max(2), + maxResults: z.number().int().min(1).max(5), +}); + +export const reviewVariantRequestSchema = z.object({ + packet: contentPacketSchema, + variants: z.array(contentVariantSchema).default([]), +}); + +export const assetTargetFormatSchema = z.enum([ + "instagram_feed", + "instagram_story", + "instagram_carousel_cover", + "youtube_thumbnail", + "pinterest_pin", +]); + +export const assetEditModeSchema = z.enum(["generate", "edit", "variant"]); +export const assetPlanStatusSchema = z.enum(["draft", "ready", "failed"]); +export const shortFormPlatformSchema = z.enum([ + "instagram_reel", + "youtube_shorts", +]); + +export const assetPlanSchema = z.object({ + id: z.string(), + packetId: z.string(), + researchRunId: z.string().nullable().optional(), + status: assetPlanStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), + imageAssets: z.array( + z.object({ + id: z.string(), + format: assetTargetFormatSchema, + prompt: z.string(), + size: z.string(), + editMode: assetEditModeSchema, + previewUrl: z.string().url().nullable(), + altText: z.string(), + }) + ), + overlayText: z.array( + z.object({ + id: z.string(), + format: assetTargetFormatSchema, + headline: z.string(), + subheadline: z.string().nullable(), + }) + ), + provenance: z.object({ + sourcePacketId: z.string(), + sourceResearchRunId: z.string().nullable(), + }), +}); + +export const shortFormPlanSchema = z.object({ + id: z.string(), + packetId: z.string(), + researchRunId: z.string().nullable().optional(), + platform: shortFormPlatformSchema, + durationSeconds: z.number().int().min(6).max(60), + status: assetPlanStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), + hook: z.string(), + voiceover: z.object({ + text: z.string(), + tone: z.string(), + targetReadingSeconds: z.number().int().min(1).max(60), + }), + scenes: z.array( + z.object({ + id: z.string(), + order: z.number().int().min(1), + seconds: z.number().int().min(1).max(30), + visualDirection: z.string(), + onScreenText: z.string(), + narration: z.string(), + }) + ), + cta: z.string(), + provenance: z.object({ + sourcePacketId: z.string(), + sourceResearchRunId: z.string().nullable(), + }), +}); + +export const assetPlanRequestSchema = z.object({ + packet: contentPacketSchema, + researchRun: researchRunSchema.optional(), + variants: z.array(contentVariantSchema).default([]), + assetTypes: z.array(assetTargetFormatSchema).min(1).max(5), + useResearchInCopy: z.boolean().optional(), + embedHeadline: z.boolean().optional(), + model: z.string().optional(), +}); + +export const shortFormPlanRequestSchema = z.object({ + packet: contentPacketSchema, + researchRun: researchRunSchema.optional(), + variants: z.array(contentVariantSchema).default([]), + platform: shortFormPlatformSchema, + durationSeconds: z.number().int().min(6).max(60).default(20), + useResearchInCopy: z.boolean().optional(), + model: z.string().optional(), +}); + +export type ContentChannel = z.infer; +export type ContentVariantFormat = z.infer; +export type ContentRiskLevel = z.infer; +export type ContentReviewStatus = z.infer; +export type ContentGenerationMode = z.infer; +export type GovernanceVerdict = z.infer; +export type ItemEntity = z.infer; +export type ContentPacket = z.infer; +export type ContentVariant = z.infer; +export type GovernanceResult = z.infer; +export type ResearchSourceType = z.infer; +export type ResearchSource = z.infer; +export type ResearchInsight = z.infer; +export type ResearchRecommendations = z.infer< + typeof researchRecommendationsSchema +>; +export type ResearchRun = z.infer; +export type RunResearchRequest = z.infer; +export type AssetTargetFormat = z.infer; +export type AssetEditMode = z.infer; +export type AssetPlanStatus = z.infer; +export type ShortFormPlatform = z.infer; +export type AssetPlan = z.infer; +export type ShortFormPlan = z.infer; +export type AssetPlanRequest = z.infer; +export type ShortFormPlanRequest = z.infer; diff --git a/packages/web/lib/hooks/admin/useVerifyStats.ts b/packages/web/lib/hooks/admin/useVerifyStats.ts new file mode 100644 index 00000000..b3cb7bdd --- /dev/null +++ b/packages/web/lib/hooks/admin/useVerifyStats.ts @@ -0,0 +1,54 @@ +"use client"; + +/** + * Admin verify-stats hook (#admin-verify-observability). + * + * GET /api/admin/verify-stats + * + * Returns per-admin verify counts for today / yesterday / 7d / 30d + nag list + * (어제 0건 admin id). + */ + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; + +export interface AdminVerifyRow { + admin_id: string; + username: string; + display_name: string | null; + email: string; + verify_count_today: number; + verify_count_yesterday: number; + verify_count_7d: number; + verify_count_30d: number; + last_verify_at: string | null; + needs_nag: boolean; +} + +export interface VerifyStatsTotals { + today: number; + yesterday: number; + last_7d: number; + last_30d: number; +} + +export interface VerifyStatsResponse { + as_of: string; + admins: AdminVerifyRow[]; + totals: VerifyStatsTotals; + nag_admin_ids: string[]; +} + +export function useVerifyStats(): UseQueryResult { + return useQuery({ + queryKey: ["admin", "verify-stats"], + queryFn: async ({ signal }) => { + const res = await fetch("/api/admin/verify-stats", { signal }); + if (!res.ok) { + throw new Error(`verify-stats failed: ${res.status}`); + } + return (await res.json()) as VerifyStatsResponse; + }, + refetchInterval: 60_000, + staleTime: 30_000, + }); +} diff --git a/packages/web/lib/supabase/client.ts b/packages/web/lib/supabase/client.ts index d10e02c4..168569b7 100644 --- a/packages/web/lib/supabase/client.ts +++ b/packages/web/lib/supabase/client.ts @@ -8,20 +8,32 @@ import type { Database } from "./types"; const supabaseUrl = process.env.NEXT_PUBLIC_DATABASE_API_URL; const supabaseAnonKey = process.env.NEXT_PUBLIC_DATABASE_ANON_KEY; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error( - "Missing auth environment variables. Please set NEXT_PUBLIC_DATABASE_API_URL and NEXT_PUBLIC_DATABASE_ANON_KEY in your .env.local file." - ); +const missingSupabaseBrowserConfigError = + "Missing auth environment variables. Please set NEXT_PUBLIC_DATABASE_API_URL and NEXT_PUBLIC_DATABASE_ANON_KEY in your .env.local file."; + +export function hasSupabaseBrowserConfig(): boolean { + return Boolean(supabaseUrl && supabaseAnonKey); +} + +function createMissingSupabaseBrowserClient(): SupabaseClient { + return new Proxy({} as SupabaseClient, { + get() { + throw new Error(missingSupabaseBrowserConfigError); + }, + }); } /** * Typed Supabase client for browser use * Uses singleton pattern from @decoded/shared to prevent multiple instances */ -export const supabaseBrowserClient: SupabaseClient = initSupabase( - supabaseUrl, - supabaseAnonKey -) as unknown as SupabaseClient; +export const supabaseBrowserClient: SupabaseClient = + supabaseUrl && supabaseAnonKey + ? (initSupabase( + supabaseUrl, + supabaseAnonKey + ) as unknown as SupabaseClient) + : createMissingSupabaseBrowserClient(); /** * Get the Supabase client instance diff --git a/packages/web/lib/supabase/middleware.ts b/packages/web/lib/supabase/middleware.ts index c1301943..606faea4 100644 --- a/packages/web/lib/supabase/middleware.ts +++ b/packages/web/lib/supabase/middleware.ts @@ -3,9 +3,6 @@ import type { NextRequest, NextResponse } from "next/server"; import type { Database } from "./types"; import { getEnv } from "./env"; -const supabaseUrl = getEnv("NEXT_PUBLIC_DATABASE_API_URL"); -const supabaseAnonKey = getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"); - /** * Creates a Supabase client for use in Next.js middleware. * @@ -26,6 +23,9 @@ export function createSupabaseMiddlewareClient( req: NextRequest, res: NextResponse ) { + const supabaseUrl = getEnv("NEXT_PUBLIC_DATABASE_API_URL"); + const supabaseAnonKey = getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"); + return createServerClient(supabaseUrl, supabaseAnonKey, { cookies: { getAll() { diff --git a/packages/web/lib/supabase/server.ts b/packages/web/lib/supabase/server.ts index c8356899..2587a18b 100644 --- a/packages/web/lib/supabase/server.ts +++ b/packages/web/lib/supabase/server.ts @@ -3,9 +3,6 @@ import { createServerClient } from "@supabase/auth-helpers-nextjs"; import type { Database } from "./types"; import { getEnv } from "./env"; -const supabaseUrl = getEnv("NEXT_PUBLIC_DATABASE_API_URL"); -const supabaseAnonKey = getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"); - /** * Creates a Supabase client for Server Components and Route Handlers. * This client automatically handles cookie-based authentication state. @@ -27,6 +24,8 @@ const supabaseAnonKey = getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"); */ export async function createSupabaseServerClient() { const cookieStore = await cookies(); + const supabaseUrl = getEnv("NEXT_PUBLIC_DATABASE_API_URL"); + const supabaseAnonKey = getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"); return createServerClient(supabaseUrl, supabaseAnonKey, { cookies: { diff --git a/packages/web/proxy.ts b/packages/web/proxy.ts index 214e1eba..47e845a4 100644 --- a/packages/web/proxy.ts +++ b/packages/web/proxy.ts @@ -17,14 +17,22 @@ import { checkIsAdmin } from "@/lib/supabase/admin"; */ export async function proxy(req: NextRequest) { const res = NextResponse.next(); - const supabase = createSupabaseMiddlewareClient(req, res); + const pathname = req.nextUrl.pathname; + let supabase: ReturnType; + + try { + supabase = createSupabaseMiddlewareClient(req, res); + } catch { + if (pathname === "/admin/login") { + return res; + } + return NextResponse.redirect(new URL("/admin/login", req.url)); + } const { data: { session }, } = await supabase.auth.getSession(); - const pathname = req.nextUrl.pathname; - // Protected pages: with implicit OAuth flow, session lives in localStorage // (not server cookies), so server-side redirect is skipped. // Client-side auth check handles 401 → login redirect instead. diff --git a/supabase/migrations/20260507205500_content_studio_assets_bucket.sql b/supabase/migrations/20260507205500_content_studio_assets_bucket.sql new file mode 100644 index 00000000..2ec08f06 --- /dev/null +++ b/supabase/migrations/20260507205500_content_studio_assets_bucket.sql @@ -0,0 +1,13 @@ +-- operation: create content-studio-assets storage bucket +-- +-- Why: Content Studio Asset Generator가 OpenAI Image API로 만든 이미지를 +-- Supabase Storage에 영구 저장하기 위한 public 버킷. +-- 프롬프트 텍스트만으로는 가치가 적고, 잡지/에디토리얼 시안을 admin이 +-- 바로 다운/공유할 수 있어야 함. +-- +-- Public read: 이미지 URL을 OG/공유용으로 그대로 쓰기 위함. +-- Insert/Update/Delete: service_role 만 (라우트 핸들러에서 admin 검증 후 사용). + +INSERT INTO storage.buckets (id, name, public) +VALUES ('content-studio-assets', 'content-studio-assets', true) +ON CONFLICT (id) DO NOTHING;