From 676729a9abe89feec3b17cc3ad89fcc096bcf023 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 20:58:35 +0900 Subject: [PATCH 1/9] fix(content-studio): use crypto.randomUUID() for packet/variant IDs Replace packet_{postId} format that caused 'invalid input syntax for type uuid' errors. DB uniqueness is enforced by content_packets.post_id unique constraint. Co-Authored-By: Claude Opus 4.6 --- .../web/lib/content-studio/packet-builder.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/web/lib/content-studio/packet-builder.ts b/packages/web/lib/content-studio/packet-builder.ts index 873fb5fa..e9aedb31 100644 --- a/packages/web/lib/content-studio/packet-builder.ts +++ b/packages/web/lib/content-studio/packet-builder.ts @@ -17,12 +17,12 @@ function metadataString(metadata: unknown, key: string): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function packetId(postId: string): string { - return `packet_${postId}`; +function packetId(): string { + return crypto.randomUUID(); } -function variantId(packetIdValue: string, format: string): string { - return `${packetIdValue}_${format}`; +function variantId(): string { + return crypto.randomUUID(); } function confidenceForSpot( @@ -86,7 +86,7 @@ export function buildContentPacketFromPost( post: PostDetailResponse ): ContentPacket { const detectedItems = post.spots.map(itemFromSpot); - const id = packetId(post.id); + const id = packetId(); const now = new Date().toISOString(); const summary = post.ai_summary?.trim() || buildWhyItWorks(post, detectedItems); @@ -145,7 +145,7 @@ export function generateChannelVariants( return [ { - id: variantId(packet.id, "instagram_carousel"), + id: variantId(), packetId: packet.id, channel: "instagram", format: "instagram_carousel", @@ -178,7 +178,7 @@ export function generateChannelVariants( status: "draft", }, { - id: variantId(packet.id, "instagram_reel"), + id: variantId(), packetId: packet.id, channel: "instagram", format: "instagram_reel", @@ -198,7 +198,7 @@ export function generateChannelVariants( status: "draft", }, { - id: variantId(packet.id, "youtube_shorts"), + id: variantId(), packetId: packet.id, channel: "youtube", format: "youtube_shorts", @@ -218,7 +218,7 @@ export function generateChannelVariants( status: "draft", }, { - id: variantId(packet.id, "x_thread"), + id: variantId(), packetId: packet.id, channel: "x", format: "x_thread", From 16e914c885522d272ce6b23c18826b961267aa89 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 20:59:51 +0900 Subject: [PATCH 2/9] refactor(content-studio): remove research schemas, add unified content schema Drop all Research/Firecrawl types (ResearchRun, ResearchSource, ResearchInsight, etc.) and research-related fields from ContentPacket, ContentVariant, generateVariantsRequestSchema, assetPlanRequestSchema, shortFormPlanRequestSchema. Add unifiedContentResponseSchema for the new single-call LLM pipeline that generates keywords + imagePrompts + channel variants together. Co-Authored-By: Claude Opus 4.6 --- .../web/lib/content-studio/llm-schemas.ts | 72 ++++++++++- packages/web/lib/content-studio/schemas.ts | 113 ------------------ 2 files changed, 70 insertions(+), 115 deletions(-) diff --git a/packages/web/lib/content-studio/llm-schemas.ts b/packages/web/lib/content-studio/llm-schemas.ts index 7227e9df..fc13359a 100644 --- a/packages/web/lib/content-studio/llm-schemas.ts +++ b/packages/web/lib/content-studio/llm-schemas.ts @@ -29,7 +29,7 @@ export const llmContentVariantSchema = z.object({ text: z.string(), evidenceRefs: z.array(z.string()), confidence: z.number().min(0).max(1), - source: z.enum(["packet", "research", "generated"]), + source: z.enum(["packet", "generated"]), }) ) .default([]), @@ -124,7 +124,7 @@ export const contentVariantLLMJsonSchema = { confidence: { type: "number" }, source: { type: "string", - enum: ["packet", "research", "generated"], + enum: ["packet", "generated"], }, }, }, @@ -172,3 +172,71 @@ export type ContentVariantLLMResponse = z.infer< typeof contentVariantLLMResponseSchema >; export type ContentGovernanceLLM = z.infer; + +export const unifiedContentResponseSchema = z.object({ + keywords: z.array(z.string()), + imagePrompts: z.object({ + youtube: z.string(), + instagram_feed: z.string(), + instagram_story: z.string(), + }), + variants: z.array( + z.object({ + channel: contentChannelSchema, + format: contentVariantFormatSchema, + title: z.string(), + body: z.string(), + hashtags: z.array(z.string()), + }) + ), +}); + +export type UnifiedContentResponse = z.infer< + typeof unifiedContentResponseSchema +>; + +export const unifiedContentJsonSchema = { + name: "unified_content", + strict: true, + schema: { + type: "object", + required: ["keywords", "imagePrompts", "variants"], + additionalProperties: false, + properties: { + keywords: { type: "array", items: { type: "string" } }, + imagePrompts: { + type: "object", + required: ["youtube", "instagram_feed", "instagram_story"], + additionalProperties: false, + properties: { + youtube: { type: "string" }, + instagram_feed: { type: "string" }, + instagram_story: { type: "string" }, + }, + }, + variants: { + type: "array", + items: { + type: "object", + required: ["channel", "format", "title", "body", "hashtags"], + additionalProperties: false, + 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" }, + hashtags: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, + }, +} as const; diff --git a/packages/web/lib/content-studio/schemas.ts b/packages/web/lib/content-studio/schemas.ts index 2486e6de..e2cd5f1c 100644 --- a/packages/web/lib/content-studio/schemas.ts +++ b/packages/web/lib/content-studio/schemas.ts @@ -25,63 +25,6 @@ export const contentGenerationModeSchema = z.enum([ "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(), @@ -118,13 +61,6 @@ export const contentPacketSchema = z.object({ 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({ @@ -145,24 +81,6 @@ export const contentVariantSchema = z.object({ 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({ @@ -182,21 +100,6 @@ export const generateVariantsRequestSchema = z.object({ 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({ @@ -222,7 +125,6 @@ export const shortFormPlatformSchema = z.enum([ export const assetPlanSchema = z.object({ id: z.string(), packetId: z.string(), - researchRunId: z.string().nullable().optional(), status: assetPlanStatusSchema, createdAt: z.string(), updatedAt: z.string(), @@ -247,14 +149,12 @@ export const assetPlanSchema = z.object({ ), 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, @@ -279,27 +179,22 @@ export const shortFormPlanSchema = z.object({ 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(), }); @@ -313,14 +208,6 @@ 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; From db88be13c80e7782264216d905dc1243c0e28d52 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 21:11:41 +0900 Subject: [PATCH 3/9] refactor(content-studio): remove research module and all dependencies Remove the entire research subsystem (Firecrawl client, domain policy, normalization, query suggestions, recommendations, service) and clean all references from governance checks, LLM generation, asset/short-form pipelines, UI components, API routes, and tests. Co-Authored-By: Claude Opus 4.6 --- .../app/admin/content-studio/AssetPanel.tsx | 20 +- .../admin/content-studio/ResearchPanel.tsx | 349 -------------- .../admin/content-studio/ShortFormPanel.tsx | 12 +- .../__tests__/AssetPanel.test.tsx | 18 +- .../__tests__/ResearchPanel.test.tsx | 286 ------------ .../__tests__/ShortFormPanel.test.tsx | 18 +- .../web/app/admin/content-studio/page.tsx | 80 +--- .../packets/[id]/generate-variants/route.ts | 2 - .../content/research/__tests__/route.test.ts | 222 --------- .../web/app/api/v1/content/research/route.ts | 74 --- .../content-studio/__tests__/assets.test.ts | 30 +- .../__tests__/content-studio.test.ts | 81 ---- .../__tests__/llm-generation.test.ts | 138 +----- .../content-studio/__tests__/research.test.ts | 429 ------------------ .../content-studio/assets/openai-client.ts | 13 - .../web/lib/content-studio/assets/plan.ts | 37 +- .../web/lib/content-studio/assets/service.ts | 12 +- .../lib/content-studio/governance-check.ts | 53 +-- packages/web/lib/content-studio/index.ts | 1 - packages/web/lib/content-studio/llm-client.ts | 10 - .../web/lib/content-studio/llm-generation.ts | 81 +--- .../content-studio/research/domain-policy.ts | 131 ------ .../research/firecrawl-client.ts | 188 -------- .../web/lib/content-studio/research/index.ts | 6 - .../content-studio/research/normalization.ts | 88 ---- .../research/query-suggestions.ts | 27 -- .../research/recommendations.ts | 55 --- .../lib/content-studio/research/service.ts | 116 ----- 28 files changed, 44 insertions(+), 2533 deletions(-) delete mode 100644 packages/web/app/admin/content-studio/ResearchPanel.tsx delete mode 100644 packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx delete mode 100644 packages/web/app/api/v1/content/research/__tests__/route.test.ts delete mode 100644 packages/web/app/api/v1/content/research/route.ts delete mode 100644 packages/web/lib/content-studio/__tests__/research.test.ts delete mode 100644 packages/web/lib/content-studio/research/domain-policy.ts delete mode 100644 packages/web/lib/content-studio/research/firecrawl-client.ts delete mode 100644 packages/web/lib/content-studio/research/index.ts delete mode 100644 packages/web/lib/content-studio/research/normalization.ts delete mode 100644 packages/web/lib/content-studio/research/query-suggestions.ts delete mode 100644 packages/web/lib/content-studio/research/recommendations.ts delete mode 100644 packages/web/lib/content-studio/research/service.ts diff --git a/packages/web/app/admin/content-studio/AssetPanel.tsx b/packages/web/app/admin/content-studio/AssetPanel.tsx index 5c9e99fb..79bd43c4 100644 --- a/packages/web/app/admin/content-studio/AssetPanel.tsx +++ b/packages/web/app/admin/content-studio/AssetPanel.tsx @@ -14,14 +14,11 @@ import type { AssetTargetFormat, ContentPacket, ContentVariant, - ResearchRun, } from "@/lib/content-studio"; type Props = { packet: ContentPacket | null; - researchRun: ResearchRun | null; variants: ContentVariant[]; - useResearchInCopy: boolean; }; const ALL_FORMATS: AssetTargetFormat[] = [ @@ -60,12 +57,7 @@ function copyText(text: string) { } } -export function AssetPanel({ - packet, - researchRun, - variants, - useResearchInCopy, -}: Props) { +export function AssetPanel({ packet, variants }: Props) { const [selected, setSelected] = useState([ "instagram_feed", ]); @@ -74,7 +66,7 @@ export function AssetPanel({ const [error, setError] = useState(null); const [state, setState] = useState<"idle" | "running" | "error">("idle"); const [imageState, setImageState] = useState<"idle" | "running" | "error">( - "idle" + "idle", ); const [imageError, setImageError] = useState(null); const [useReferenceImages, setUseReferenceImages] = useState(true); @@ -87,7 +79,7 @@ export function AssetPanel({ setSelected((current) => current.includes(format) ? current.filter((item) => item !== format) - : [...current, format] + : [...current, format], ); } @@ -101,10 +93,8 @@ export function AssetPanel({ warning: string | null; }>("/api/v1/content/assets/plan", { packet, - researchRun: researchRun ?? undefined, variants, assetTypes: selected, - useResearchInCopy, embedHeadline, }); setPlan(data.plan); @@ -135,7 +125,7 @@ export function AssetPanel({ setImageState("idle"); } catch (err) { setImageError( - err instanceof Error ? err.message : "Image generation failed" + err instanceof Error ? err.message : "Image generation failed", ); setImageState("error"); } @@ -325,7 +315,7 @@ export function AssetPanel({ copyText( [overlay.headline, overlay.subheadline] .filter(Boolean) - .join("\n") + .join("\n"), ) } className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted" diff --git a/packages/web/app/admin/content-studio/ResearchPanel.tsx b/packages/web/app/admin/content-studio/ResearchPanel.tsx deleted file mode 100644 index f9a11f71..00000000 --- a/packages/web/app/admin/content-studio/ResearchPanel.tsx +++ /dev/null @@ -1,349 +0,0 @@ -"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 index 1ad4b882..863271ad 100644 --- a/packages/web/app/admin/content-studio/ShortFormPanel.tsx +++ b/packages/web/app/admin/content-studio/ShortFormPanel.tsx @@ -5,16 +5,13 @@ 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"]; @@ -48,12 +45,7 @@ function copyText(text: string) { } } -export function ShortFormPanel({ - packet, - researchRun, - variants, - useResearchInCopy, -}: Props) { +export function ShortFormPanel({ packet, variants }: Props) { const [platform, setPlatform] = useState("youtube_shorts"); const [duration, setDuration] = useState(20); const [plan, setPlan] = useState(null); @@ -71,11 +63,9 @@ export function ShortFormPanel({ warning: string | null; }>("/api/v1/content/assets/plan", { packet, - researchRun: researchRun ?? undefined, variants, platform, durationSeconds: duration, - useResearchInCopy, }); setPlan(data.plan); setWarning(data.warning); diff --git a/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx index 6483c77f..b11b6e7f 100644 --- a/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx +++ b/packages/web/app/admin/content-studio/__tests__/AssetPanel.test.tsx @@ -85,14 +85,7 @@ describe("AssetPanel", () => { }); it("renders format toggles when a packet exists", () => { - const view = render( - - ); + const view = render(); expect(view.getByLabelText("instagram feed")).toBeTruthy(); expect(view.getByLabelText("youtube thumbnail")).toBeTruthy(); @@ -140,14 +133,7 @@ describe("AssetPanel", () => { }), } as Response); - const view = render( - - ); + const view = render(); fireEvent.click(view.getByRole("button", { name: "Generate Asset Plan" })); diff --git a/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx b/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx deleted file mode 100644 index e8583dbf..00000000 --- a/packages/web/app/admin/content-studio/__tests__/ResearchPanel.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @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 index fc5a2c63..18b7c2a6 100644 --- a/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx +++ b/packages/web/app/admin/content-studio/__tests__/ShortFormPanel.test.tsx @@ -85,14 +85,7 @@ describe("ShortFormPanel", () => { }); it("renders platform and duration controls when a packet exists", () => { - const view = render( - - ); + const view = render(); expect(view.getByLabelText("Platform")).toBeTruthy(); expect(view.getByLabelText("Duration seconds")).toBeTruthy(); @@ -140,14 +133,7 @@ describe("ShortFormPanel", () => { }), } as Response); - const view = render( - - ); + const view = render(); fireEvent.click(view.getByRole("button", { name: "Generate Short Form" })); diff --git a/packages/web/app/admin/content-studio/page.tsx b/packages/web/app/admin/content-studio/page.tsx index 0ec44913..d0e5caa7 100644 --- a/packages/web/app/admin/content-studio/page.tsx +++ b/packages/web/app/admin/content-studio/page.tsx @@ -11,7 +11,6 @@ import { Sparkles, XCircle, } from "lucide-react"; -import { ResearchPanel } from "./ResearchPanel"; import { AssetPanel } from "./AssetPanel"; import { ShortFormPanel } from "./ShortFormPanel"; import type { @@ -19,7 +18,6 @@ import type { ContentPacket, ContentVariant, GovernanceResult, - ResearchRun, } from "@/lib/content-studio"; type LoadState = "idle" | "loading" | "error"; @@ -294,7 +292,7 @@ function VariantPanel({ variants: ContentVariant[]; onStatusChange: ( variant: ContentVariant, - status: ContentVariant["status"] + status: ContentVariant["status"], ) => void; }) { if (variants.length === 0) return null; @@ -375,29 +373,26 @@ export default function ContentStudioPage() { 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 + 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] + [packet, state], ); async function loadRecentPackets() { try { const data = await getJson<{ items: ContentPacketListItem[] }>( - "/api/v1/content/packets?limit=10" + "/api/v1/content/packets?limit=10", ); setRecentPackets(data.items ?? []); } catch { @@ -415,15 +410,12 @@ export default function ContentStudioPage() { 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() } + { postId: postId.trim() }, ); setPacket(data.packet); await loadRecentPackets(); @@ -449,28 +441,20 @@ export default function ContentStudioPage() { 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 } + { packet, variants: variantData.variants }, ); setGovernance(reviewData.result); setState("idle"); await loadRecentPackets(); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to generate variants" + err instanceof Error ? err.message : "Failed to generate variants", ); setState("error"); } @@ -478,7 +462,7 @@ export default function ContentStudioPage() { async function handleVariantStatusChange( variant: ContentVariant, - status: ContentVariant["status"] + status: ContentVariant["status"], ) { const action = status === "approved" ? "approve" : "reject"; setError(null); @@ -486,15 +470,15 @@ export default function ContentStudioPage() { try { const data = await postJson<{ variant: ContentVariant }>( `/api/v1/content/variants/${variant.id}/${action}`, - variant + variant, ); setVariants((current) => - current.map((item) => (item.id === variant.id ? data.variant : item)) + current.map((item) => (item.id === variant.id ? data.variant : item)), ); await loadRecentPackets(); } catch (err) { setError( - err instanceof Error ? err.message : `Failed to ${action} variant` + err instanceof Error ? err.message : `Failed to ${action} variant`, ); } } @@ -502,9 +486,6 @@ export default function ContentStudioPage() { async function handleOpenPacket(id: string) { setState("loading"); setError(null); - setResearchRun(null); - setResearchWarning(null); - setUseResearchInCopy(false); try { const data = await getJson<{ @@ -638,46 +619,13 @@ export default function ContentStudioPage() { )} {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/api/v1/content/packets/[id]/generate-variants/route.ts b/packages/web/app/api/v1/content/packets/[id]/generate-variants/route.ts index 5a218043..a841d693 100644 --- a/packages/web/app/api/v1/content/packets/[id]/generate-variants/route.ts +++ b/packages/web/app/api/v1/content/packets/[id]/generate-variants/route.ts @@ -40,8 +40,6 @@ async function generatedVariantsResponse( channels: data.channels, locale: data.locale, tone: data.tone, - researchContext: data.researchContext, - useResearchInCopy: data.useResearchInCopy, }); if (supabase && result.variants?.length) { try { diff --git a/packages/web/app/api/v1/content/research/__tests__/route.test.ts b/packages/web/app/api/v1/content/research/__tests__/route.test.ts deleted file mode 100644 index aae31342..00000000 --- a/packages/web/app/api/v1/content/research/__tests__/route.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -type MockState = { - checkIsAdmin: ReturnType Promise>>; - getUser: ReturnType< - typeof vi.fn<() => Promise<{ data: { user: { id: string } | null } }>> - >; - runContentResearch: ReturnType< - typeof vi.fn< - (input: unknown) => Promise> - > - >; -}; - -function defaultResearchResponse() { - return { - run: { - id: "run-1", - packetId: "packet-1", - query: "airport style", - mode: "manual", - status: "completed", - createdAt: "2026-05-07T00:00:00.000Z", - sources: [], - insights: [], - recommendations: { - externalTrendSignal: 0, - recommendedChannels: [], - }, - }, - warning: null, - }; -} - -const mocks: MockState = { - checkIsAdmin: vi.fn(async () => true), - getUser: vi.fn(async () => ({ - data: { user: { id: "admin-1" } }, - })), - runContentResearch: vi.fn(async (_input: unknown) => - defaultResearchResponse() - ), -}; - -vi.mock("@/lib/supabase/server", () => ({ - createSupabaseServerClient: vi.fn(async () => ({ - auth: { - getUser: () => mocks.getUser(), - }, - })), -})); - -vi.mock("@/lib/supabase/admin", () => ({ - checkIsAdmin: () => mocks.checkIsAdmin(), -})); - -vi.mock("@/lib/content-studio/research", () => { - class ResearchUnavailableError extends Error { - constructor(message: string) { - super(message); - this.name = "ResearchUnavailableError"; - } - } - - return { - ResearchUnavailableError, - runContentResearch: (input: unknown) => mocks.runContentResearch(input), - }; -}); - -import { POST } from "../route"; -import { ResearchUnavailableError } from "@/lib/content-studio/research"; - -function request(body: unknown) { - return new NextRequest("http://localhost/api/v1/content/research", { - method: "POST", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -const packet = { - id: "packet-1", - postId: "post-1", - sourceImage: "https://example.com/image.jpg", - title: "Airport look", - hook: "Airport look", - artist: null, - group: null, - context: "Airport", - detectedItems: [], - styleSummary: "Denim airport look", - 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("POST /api/v1/content/research", () => { - beforeEach(() => { - mocks.checkIsAdmin.mockReset(); - mocks.getUser.mockReset(); - mocks.runContentResearch.mockReset(); - mocks.checkIsAdmin.mockResolvedValue(true); - mocks.getUser.mockResolvedValue({ - data: { user: { id: "admin-1" } }, - }); - mocks.runContentResearch.mockResolvedValue(defaultResearchResponse()); - }); - - it("rejects unauthenticated users", async () => { - mocks.getUser.mockResolvedValueOnce({ - data: { user: null }, - }); - - const response = await POST( - request({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }) - ); - - expect(response.status).toBe(401); - expect(mocks.runContentResearch).not.toHaveBeenCalled(); - }); - - it("rejects non-admin users", async () => { - mocks.checkIsAdmin.mockResolvedValueOnce(false); - - const response = await POST( - request({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }) - ); - - expect(response.status).toBe(403); - expect(mocks.runContentResearch).not.toHaveBeenCalled(); - }); - - it("rejects invalid requests", async () => { - const response = await POST( - request({ packet, query: "", sourceTypes: [], maxResults: 0 }) - ); - - expect(response.status).toBe(400); - }); - - it("runs content research for admins", async () => { - const response = await POST( - request({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }) - ); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.run.id).toBe("run-1"); - expect(mocks.runContentResearch).toHaveBeenCalledWith({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }); - }); - - it("maps disabled research to 503", async () => { - mocks.runContentResearch.mockRejectedValueOnce( - new ResearchUnavailableError("Content Studio research is disabled") - ); - - const response = await POST( - request({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }) - ); - const data = await response.json(); - - expect(response.status).toBe(503); - expect(data.error).toBe("research_unavailable"); - }); - - it("returns a failed run when research execution fails", async () => { - mocks.runContentResearch.mockRejectedValueOnce(new Error("Search failed")); - - const response = await POST( - request({ - packet, - query: "airport denim trend", - sourceTypes: ["style_trend"], - maxResults: 2, - }) - ); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.run).toMatchObject({ - id: "research_failed_packet-1", - packetId: "packet-1", - status: "failed", - }); - expect(data.warning).toBe("Search failed"); - }); -}); diff --git a/packages/web/app/api/v1/content/research/route.ts b/packages/web/app/api/v1/content/research/route.ts deleted file mode 100644 index e523a122..00000000 --- a/packages/web/app/api/v1/content/research/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { runResearchRequestSchema } from "@/lib/content-studio/schemas"; -import { - ResearchUnavailableError, - runContentResearch, -} from "@/lib/content-studio/research"; -import { checkIsAdmin } from "@/lib/supabase/admin"; -import { createSupabaseServerClient } from "@/lib/supabase/server"; - -async function requireAdmin() { - const supabase = await createSupabaseServerClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const isAdmin = await checkIsAdmin(supabase, user.id); - if (!isAdmin) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - return null; -} - -export async function POST(request: NextRequest) { - const adminError = await requireAdmin(); - if (adminError) return adminError; - - const parsed = runResearchRequestSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid request", issues: parsed.error.flatten() }, - { status: 400 } - ); - } - - try { - return NextResponse.json(await runContentResearch(parsed.data)); - } catch (error) { - if (error instanceof ResearchUnavailableError) { - return NextResponse.json( - { error: "research_unavailable", message: error.message }, - { status: 503 } - ); - } - - return NextResponse.json( - { - run: { - id: `research_failed_${parsed.data.packet.id}`, - packetId: parsed.data.packet.id, - query: parsed.data.query, - mode: "manual", - status: "failed", - createdAt: new Date().toISOString(), - sources: [], - insights: [], - recommendations: { - externalTrendSignal: 0, - recommendedChannels: [], - }, - }, - warning: - error instanceof Error ? error.message : "Research request failed", - }, - { status: 200 } - ); - } -} diff --git a/packages/web/lib/content-studio/__tests__/assets.test.ts b/packages/web/lib/content-studio/__tests__/assets.test.ts index fd4dd9c7..e784fab2 100644 --- a/packages/web/lib/content-studio/__tests__/assets.test.ts +++ b/packages/web/lib/content-studio/__tests__/assets.test.ts @@ -31,27 +31,11 @@ const packet = { 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", @@ -77,19 +61,17 @@ describe("Content Studio asset schemas", () => { ], provenance: { sourcePacketId: "packet-1", - sourceResearchRunId: "run-1", }, }); expect(parsed.imageAssets[0].format).toBe("instagram_feed"); - expect(parsed.provenance.sourceResearchRunId).toBe("run-1"); + expect(parsed.provenance.sourcePacketId).toBe("packet-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", @@ -114,7 +96,6 @@ describe("Content Studio asset schemas", () => { cta: "Save for reference", provenance: { sourcePacketId: "packet-1", - sourceResearchRunId: "run-1", }, }); @@ -126,25 +107,22 @@ describe("Content Studio asset schemas", () => { const parsed = assetPlanRequestSchema.safeParse({ packet: {}, assetTypes: ["unknown"], - useResearchInCopy: false, }); expect(parsed.success).toBe(false); }); - it("builds image asset prompts from packet and research context", () => { + it("builds image asset prompts from packet", () => { 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"); + expect(plan.provenance.sourcePacketId).toBe("packet-1"); }); it("builds a short form plan with scenes and voiceover", () => { @@ -152,8 +130,6 @@ describe("Content Studio asset schemas", () => { packet, platform: "youtube_shorts", durationSeconds: 20, - useResearchInCopy: true, - researchRun, variants: [], }); diff --git a/packages/web/lib/content-studio/__tests__/content-studio.test.ts b/packages/web/lib/content-studio/__tests__/content-studio.test.ts index 60530248..5e78c6bc 100644 --- a/packages/web/lib/content-studio/__tests__/content-studio.test.ts +++ b/packages/web/lib/content-studio/__tests__/content-studio.test.ts @@ -220,87 +220,6 @@ describe("Content Studio governance", () => { }); }); -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({ diff --git a/packages/web/lib/content-studio/__tests__/llm-generation.test.ts b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts index 50eb1282..d35f42f6 100644 --- a/packages/web/lib/content-studio/__tests__/llm-generation.test.ts +++ b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts @@ -8,11 +8,7 @@ import { generateVariantsWithMode, mergeGovernanceResults, } from "../llm-generation"; -import type { - ContentGenerationMode, - GovernanceResult, - ResearchInsight, -} from "../schemas"; +import type { ContentGenerationMode, GovernanceResult } from "../schemas"; import type { PostDetailResponse } from "@/lib/api/generated/models"; const samplePost: PostDetailResponse = { @@ -170,135 +166,3 @@ describe("Content Studio LLM generation", () => { } ); }); - -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 deleted file mode 100644 index 4e5deff7..00000000 --- a/packages/web/lib/content-studio/__tests__/research.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -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/openai-client.ts b/packages/web/lib/content-studio/assets/openai-client.ts index b59f83a9..a92d2336 100644 --- a/packages/web/lib/content-studio/assets/openai-client.ts +++ b/packages/web/lib/content-studio/assets/openai-client.ts @@ -3,7 +3,6 @@ import type { AssetTargetFormat, ContentPacket, ContentVariant, - ResearchRun, ShortFormPlan, ShortFormPlatform, } from "../schemas"; @@ -282,20 +281,16 @@ export async function generateAssetImagesForPlan( 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; }; @@ -351,10 +346,6 @@ export async function refineAssetPlanWithOpenAI( 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, @@ -398,10 +389,6 @@ export async function refineShortFormPlanWithOpenAI( }, 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, diff --git a/packages/web/lib/content-studio/assets/plan.ts b/packages/web/lib/content-studio/assets/plan.ts index a2ae2b16..0fe7d571 100644 --- a/packages/web/lib/content-studio/assets/plan.ts +++ b/packages/web/lib/content-studio/assets/plan.ts @@ -4,26 +4,21 @@ import { 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 = { @@ -57,17 +52,6 @@ function describeItems(packet: ContentPacket): string { 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 ""; @@ -114,13 +98,11 @@ function shortenHeadline(text: string, maxWords: number): string { function imagePrompt( packet: ContentPacket, format: AssetTargetFormat, - researchRun?: ResearchRun, - embedHeadline?: boolean + 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 = ""; @@ -141,7 +123,6 @@ function imagePrompt( `Style direction: ${packet.styleSummary}.`, items, context, - research, composition, textBlock, textConstraints, @@ -157,19 +138,13 @@ export function buildAssetPlan(input: BuildAssetPlanInput) { 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 - ), + prompt: imagePrompt(input.packet, format, input.embedHeadline), size: FORMAT_SIZE[format], editMode: "generate" as const, previewUrl: null, @@ -179,14 +154,10 @@ export function buildAssetPlan(input: BuildAssetPlanInput) { id: `${format}_overlay_${index + 1}`, format, headline: input.packet.hook, - subheadline: - input.useResearchInCopy && input.researchRun - ? input.researchRun.query - : input.packet.styleSummary, + subheadline: input.packet.styleSummary, })), provenance: { sourcePacketId: input.packet.id, - sourceResearchRunId: input.researchRun?.id ?? null, }, }; @@ -199,7 +170,6 @@ export function buildShortFormPlan(input: BuildShortFormPlanInput) { 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, @@ -232,7 +202,6 @@ export function buildShortFormPlan(input: BuildShortFormPlanInput) { cta: "Save for reference", provenance: { sourcePacketId: input.packet.id, - sourceResearchRunId: input.researchRun?.id ?? null, }, }; diff --git a/packages/web/lib/content-studio/assets/service.ts b/packages/web/lib/content-studio/assets/service.ts index 9989b060..a9c68ec5 100644 --- a/packages/web/lib/content-studio/assets/service.ts +++ b/packages/web/lib/content-studio/assets/service.ts @@ -24,14 +24,12 @@ export type ShortFormPlanResult = { }; export async function generateAssetPlan( - input: AssetPlanRequest + input: AssetPlanRequest, ): Promise { const base = buildAssetPlan({ packet: input.packet, - researchRun: input.researchRun, variants: input.variants ?? [], assetTypes: input.assetTypes, - useResearchInCopy: input.useResearchInCopy, embedHeadline: input.embedHeadline, }); @@ -42,10 +40,8 @@ export async function generateAssetPlan( 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 }; @@ -61,15 +57,13 @@ export async function generateAssetPlan( } export async function generateShortFormPlan( - input: ShortFormPlanRequest + 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()) { @@ -79,11 +73,9 @@ export async function generateShortFormPlan( 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 }; diff --git a/packages/web/lib/content-studio/governance-check.ts b/packages/web/lib/content-studio/governance-check.ts index 01a49af2..9662b947 100644 --- a/packages/web/lib/content-studio/governance-check.ts +++ b/packages/web/lib/content-studio/governance-check.ts @@ -44,54 +44,6 @@ export function runGovernanceCheck( 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", @@ -104,10 +56,7 @@ export function runGovernanceCheck( if (packet.riskLevel !== "low" || flags.length > 0) { return { verdict: "needs_review", - riskLevel: - hasResearchRisk && packet.riskLevel === "low" - ? "medium" - : packet.riskLevel, + riskLevel: packet.riskLevel, flags, requiredActions, }; diff --git a/packages/web/lib/content-studio/index.ts b/packages/web/lib/content-studio/index.ts index d6ac725d..cccde507 100644 --- a/packages/web/lib/content-studio/index.ts +++ b/packages/web/lib/content-studio/index.ts @@ -2,5 +2,4 @@ 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 index ea75cc10..67bb4af6 100644 --- a/packages/web/lib/content-studio/llm-client.ts +++ b/packages/web/lib/content-studio/llm-client.ts @@ -2,8 +2,6 @@ import type { ContentPacket, ContentVariant, ContentVariantFormat, - ResearchInsight, - ResearchSource, } from "./schemas"; import { contentGovernanceLLMJsonSchema, @@ -55,11 +53,6 @@ export async function generateContentVariantsWithOpenAI(input: { 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) { @@ -86,9 +79,6 @@ export async function generateContentVariantsWithOpenAI(input: { channels: input.channels, locale: input.locale, tone: input.tone, - ...(input.researchContext - ? { researchContext: input.researchContext } - : {}), }), }, ], diff --git a/packages/web/lib/content-studio/llm-generation.ts b/packages/web/lib/content-studio/llm-generation.ts index 57480ade..38709bbf 100644 --- a/packages/web/lib/content-studio/llm-generation.ts +++ b/packages/web/lib/content-studio/llm-generation.ts @@ -16,8 +16,6 @@ import type { ContentVariant, ContentVariantFormat, GovernanceResult, - ResearchInsight, - ResearchSource, } from "./schemas"; import type { ContentGovernanceLLM } from "./llm-schemas"; @@ -41,11 +39,6 @@ type LlmGenerate = (input: { locale: "ko-KR" | "en-US"; tone: string; model?: string; - researchContext?: { - runId: string; - sources?: ResearchSource[]; - insights: ResearchInsight[]; - }; }) => Promise; export type GenerateVariantsWithModeResult = { @@ -61,19 +54,13 @@ function inputHash(input: unknown): string { .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, @@ -87,7 +74,7 @@ function withGenerationMetadata( function templateResult( packet: ContentPacket, warning: string | null, - hash: string + hash: string, ): GenerateVariantsWithModeResult { return { variants: withGenerationMetadata(generateChannelVariants(packet), { @@ -107,16 +94,10 @@ function mapLlmResponseToVariants(input: { 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 + (variant) => variant.format === template.format, ); if (!generated) { throw new Error(`Missing LLM variant for ${template.format}`); @@ -132,21 +113,6 @@ function mapLlmResponseToVariants(input: { 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, @@ -164,11 +130,6 @@ export async function generateVariantsWithMode(input: { llmEnabled?: boolean; model?: string; llmGenerate?: LlmGenerate; - researchContext?: { - runId: string; - insights: ResearchInsight[]; - }; - useResearchInCopy?: boolean; }): Promise { const channels = input.channels ?? [ "instagram_carousel", @@ -178,29 +139,12 @@ export async function generateVariantsWithMode(input: { ]; 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") { @@ -215,7 +159,7 @@ export async function generateVariantsWithMode(input: { return templateResult( input.packet, "LLM client is not configured; used template fallback.", - hash + hash, ); } @@ -226,9 +170,6 @@ export async function generateVariantsWithMode(input: { locale, tone, model, - ...(researchContextForCopy - ? { researchContext: researchContextForCopy } - : {}), }); const parsed = contentVariantLLMResponseSchema.safeParse(raw); @@ -236,12 +177,12 @@ export async function generateVariantsWithMode(input: { return templateResult( input.packet, "LLM output failed validation; used template fallback.", - hash + hash, ); } const templates = generateChannelVariants(input.packet).filter((variant) => - channels.includes(variant.format) + channels.includes(variant.format), ); return { variants: mapLlmResponseToVariants({ @@ -251,8 +192,6 @@ export async function generateVariantsWithMode(input: { mode: input.mode, model, hash, - researchContext: input.researchContext, - useResearchInCopy: input.useResearchInCopy === true, }), mode: input.mode, warning: null, @@ -263,14 +202,14 @@ export async function generateVariantsWithMode(input: { error instanceof Error ? `LLM generation failed: ${error.message}; used template fallback.` : "LLM generation failed; used template fallback.", - hash + hash, ); } } export function mergeGovernanceResults( ruleResult: GovernanceResult, - llmResult: GovernanceResult | null + llmResult: GovernanceResult | null, ): GovernanceResult { if (!llmResult) return ruleResult; @@ -288,13 +227,13 @@ export function mergeGovernanceResults( riskLevel, flags: Array.from(new Set([...ruleResult.flags, ...llmResult.flags])), requiredActions: Array.from( - new Set([...ruleResult.requiredActions, ...llmResult.requiredActions]) + new Set([...ruleResult.requiredActions, ...llmResult.requiredActions]), ), }; } export function governanceResultFromLlm( - result: ContentGovernanceLLM + result: ContentGovernanceLLM, ): GovernanceResult { return { verdict: result.decision, diff --git a/packages/web/lib/content-studio/research/domain-policy.ts b/packages/web/lib/content-studio/research/domain-policy.ts deleted file mode 100644 index d8dccdf8..00000000 --- a/packages/web/lib/content-studio/research/domain-policy.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 3fd038f1..00000000 --- a/packages/web/lib/content-studio/research/firecrawl-client.ts +++ /dev/null @@ -1,188 +0,0 @@ -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", { cause: error }); - } - - 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 deleted file mode 100644 index af4556f0..00000000 --- a/packages/web/lib/content-studio/research/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 2d58e6a8..00000000 --- a/packages/web/lib/content-studio/research/normalization.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 1f84df15..00000000 --- a/packages/web/lib/content-studio/research/query-suggestions.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 915fa806..00000000 --- a/packages/web/lib/content-studio/research/recommendations.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index f9f6b788..00000000 --- a/packages/web/lib/content-studio/research/service.ts +++ /dev/null @@ -1,116 +0,0 @@ -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, - }; -} From a95429eaf75a57bc65d1757aaaed49dd4f24d212 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 21:24:14 +0900 Subject: [PATCH 4/9] feat(content-studio): add rule keyword extraction and unified LLM generation Single OpenAI call now returns keywords, image prompts, and channel variants together, replacing the previous multi-call pattern. Template fallback preserved when LLM is unavailable or validation fails. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/llm-generation.test.ts | 95 ++++++++++- packages/web/lib/content-studio/llm-client.ts | 65 +++++++- .../web/lib/content-studio/llm-generation.ts | 149 ++++++++++++++++-- .../web/lib/content-studio/llm-prompts.ts | 35 +++- .../web/lib/content-studio/packet-builder.ts | 21 +++ 5 files changed, 345 insertions(+), 20 deletions(-) diff --git a/packages/web/lib/content-studio/__tests__/llm-generation.test.ts b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts index d35f42f6..37de9ee2 100644 --- a/packages/web/lib/content-studio/__tests__/llm-generation.test.ts +++ b/packages/web/lib/content-studio/__tests__/llm-generation.test.ts @@ -6,8 +6,10 @@ import { import { CONTENT_STUDIO_PROMPT_VERSION, generateVariantsWithMode, + generateUnifiedContent, mergeGovernanceResults, } from "../llm-generation"; +import { extractRuleKeywords } from "../packet-builder"; import type { ContentGenerationMode, GovernanceResult } from "../schemas"; import type { PostDetailResponse } from "@/lib/api/generated/models"; @@ -71,15 +73,15 @@ describe("Content Studio LLM generation", () => { expect(result.mode).toBe("template"); expect(result.warning).toContain("LLM client is not configured"); - expect(result.variants).toEqual( - generateChannelVariants(packet).map((variant) => ({ - ...variant, + expect(result.variants).toHaveLength(4); + for (const variant of result.variants) { + expect(variant).toMatchObject({ 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, @@ -113,8 +115,8 @@ describe("Content Studio LLM generation", () => { 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", + id: expect.any(String), + packetId: packet.id, generationMode: "hybrid", llmModel: "test-model", promptVersion: CONTENT_STUDIO_PROMPT_VERSION, @@ -166,3 +168,82 @@ describe("Content Studio LLM generation", () => { } ); }); + +describe("extractRuleKeywords", () => { + it("extracts brands, artist, group, and context from a packet", () => { + const packet = buildContentPacketFromPost(samplePost); + const keywords = extractRuleKeywords(packet); + + expect(keywords).toContain("decoded"); + expect(keywords).toContain("decoded local"); + expect(keywords).toContain( + "oversized silhouettes meet structured tailoring" + ); + expect(keywords.length).toBeGreaterThan(0); + expect(new Set(keywords).size).toBe(keywords.length); + }); +}); + +describe("generateUnifiedContent", () => { + it("falls back to template with rule keywords when LLM is unavailable", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateUnifiedContent({ + packet, + llmEnabled: true, + }); + + expect(result.mode).toBe("template"); + expect(result.warning).toContain("LLM client is not configured"); + expect(result.keywords.length).toBeGreaterThan(0); + expect(result.imagePrompts).toBeNull(); + expect(result.variants).toHaveLength(4); + }); + + it("returns unified LLM response with keywords and image prompts", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateUnifiedContent({ + packet, + llmEnabled: true, + model: "test-model", + llmGenerate: async () => ({ + keywords: ["layering", "proportion", "street-style"], + imagePrompts: { + youtube: "Bold editorial style decode thumbnail", + instagram_feed: "Clean square style breakdown", + instagram_story: "Vertical immersive fashion decode", + }, + variants: generateChannelVariants(packet).map((v) => ({ + channel: v.channel, + format: v.format, + title: `Unified ${v.title}`, + body: `Unified body for ${v.format}`, + hashtags: ["decoded", "style"], + })), + }), + }); + + expect(result.mode).toBe("llm"); + expect(result.warning).toBeNull(); + expect(result.keywords).toContain("layering"); + expect(result.keywords).toContain("decoded"); + expect(result.imagePrompts).not.toBeNull(); + expect(result.imagePrompts!.youtube).toContain("Bold"); + expect(result.variants).toHaveLength(4); + expect(result.variants[0].title).toContain("Unified"); + expect(result.variants[0].generationMode).toBe("llm"); + }); + + it("falls back on validation failure and returns rule keywords", async () => { + const packet = buildContentPacketFromPost(samplePost); + const result = await generateUnifiedContent({ + packet, + llmEnabled: true, + llmGenerate: async () => ({ bad: "data" }), + }); + + expect(result.mode).toBe("template"); + expect(result.warning).toContain("failed validation"); + expect(result.keywords.length).toBeGreaterThan(0); + expect(result.imagePrompts).toBeNull(); + }); +}); diff --git a/packages/web/lib/content-studio/llm-client.ts b/packages/web/lib/content-studio/llm-client.ts index 67bb4af6..64d7de26 100644 --- a/packages/web/lib/content-studio/llm-client.ts +++ b/packages/web/lib/content-studio/llm-client.ts @@ -8,10 +8,16 @@ import { contentGovernanceLLMSchema, contentVariantLLMJsonSchema, contentVariantLLMResponseSchema, + unifiedContentJsonSchema, + unifiedContentResponseSchema, type ContentGovernanceLLM, type ContentVariantLLMResponse, + type UnifiedContentResponse, } from "./llm-schemas"; -import { CONTENT_CREATOR_SYSTEM_PROMPT } from "./llm-prompts"; +import { + CONTENT_CREATOR_SYSTEM_PROMPT, + UNIFIED_CONTENT_SYSTEM_PROMPT, +} from "./llm-prompts"; const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; @@ -165,3 +171,60 @@ export async function reviewContentGovernanceWithOpenAI(input: { return contentGovernanceLLMSchema.parse(JSON.parse(text)); } + +export async function generateUnifiedContentWithOpenAI(input: { + packet: ContentPacket; + ruleKeywords: string[]; + locale: "ko-KR" | "en-US"; + tone: string; + 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: UNIFIED_CONTENT_SYSTEM_PROMPT, + }, + { + role: "user", + content: JSON.stringify({ + packet: input.packet, + ruleKeywords: input.ruleKeywords, + locale: input.locale, + tone: input.tone, + }), + }, + ], + text: { + format: { + type: "json_schema", + ...unifiedContentJsonSchema, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI unified content failed: ${response.status}`); + } + + const data = (await response.json()) as ResponsesApiOutput; + const text = extractOutputText(data); + if (!text) { + throw new Error("OpenAI unified response did not include output text"); + } + + return unifiedContentResponseSchema.parse(JSON.parse(text)); +} diff --git a/packages/web/lib/content-studio/llm-generation.ts b/packages/web/lib/content-studio/llm-generation.ts index 38709bbf..cf2474fa 100644 --- a/packages/web/lib/content-studio/llm-generation.ts +++ b/packages/web/lib/content-studio/llm-generation.ts @@ -1,13 +1,16 @@ import { createHash } from "node:crypto"; -import { generateChannelVariants } from "./packet-builder"; +import { extractRuleKeywords, generateChannelVariants } from "./packet-builder"; import { contentVariantLLMResponseSchema, + unifiedContentResponseSchema, type ContentVariantLLMResponse, + type UnifiedContentResponse, } from "./llm-schemas"; import { CONTENT_STUDIO_PROMPT_VERSION } from "./llm-prompts"; import { contentStudioModel, generateContentVariantsWithOpenAI, + generateUnifiedContentWithOpenAI, isContentStudioLlmEnabled, } from "./llm-client"; import type { @@ -60,7 +63,7 @@ function withGenerationMetadata( mode: ContentGenerationMode; model?: string | null; hash: string; - }, + } ): ContentVariant[] { return variants.map((variant) => ({ ...variant, @@ -74,7 +77,7 @@ function withGenerationMetadata( function templateResult( packet: ContentPacket, warning: string | null, - hash: string, + hash: string ): GenerateVariantsWithModeResult { return { variants: withGenerationMetadata(generateChannelVariants(packet), { @@ -97,7 +100,7 @@ function mapLlmResponseToVariants(input: { }): ContentVariant[] { return input.templates.map((template) => { const generated = input.response.variants.find( - (variant) => variant.format === template.format, + (variant) => variant.format === template.format ); if (!generated) { throw new Error(`Missing LLM variant for ${template.format}`); @@ -159,7 +162,7 @@ export async function generateVariantsWithMode(input: { return templateResult( input.packet, "LLM client is not configured; used template fallback.", - hash, + hash ); } @@ -177,12 +180,12 @@ export async function generateVariantsWithMode(input: { return templateResult( input.packet, "LLM output failed validation; used template fallback.", - hash, + hash ); } const templates = generateChannelVariants(input.packet).filter((variant) => - channels.includes(variant.format), + channels.includes(variant.format) ); return { variants: mapLlmResponseToVariants({ @@ -202,14 +205,138 @@ export async function generateVariantsWithMode(input: { error instanceof Error ? `LLM generation failed: ${error.message}; used template fallback.` : "LLM generation failed; used template fallback.", - hash, + hash ); } } +export type GenerateUnifiedContentResult = { + keywords: string[]; + imagePrompts: UnifiedContentResponse["imagePrompts"] | null; + variants: ContentVariant[]; + mode: ContentGenerationMode; + warning: string | null; +}; + +type UnifiedLlmGenerate = (input: { + packet: ContentPacket; + ruleKeywords: string[]; + locale: "ko-KR" | "en-US"; + tone: string; + model?: string; +}) => Promise; + +export async function generateUnifiedContent(input: { + packet: ContentPacket; + locale?: "ko-KR" | "en-US"; + tone?: string; + llmEnabled?: boolean; + model?: string; + llmGenerate?: UnifiedLlmGenerate; +}): Promise { + const locale = input.locale ?? "ko-KR"; + const tone = input.tone ?? "decoded_editorial"; + const ruleKeywords = extractRuleKeywords(input.packet); + const hash = inputHash({ + packet: input.packet, + ruleKeywords, + locale, + tone, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + }); + + const llmEnabled = input.llmEnabled ?? isContentStudioLlmEnabled(); + const llmGenerate = input.llmGenerate ?? generateUnifiedContentWithOpenAI; + const model = input.model ?? contentStudioModel(); + + if (!llmEnabled || (!input.llmGenerate && !process.env.OPENAI_API_KEY)) { + return { + keywords: ruleKeywords, + imagePrompts: null, + variants: withGenerationMetadata(generateChannelVariants(input.packet), { + mode: "template", + model: null, + hash, + }), + mode: "template", + warning: "LLM client is not configured; used template fallback.", + }; + } + + try { + const raw = await llmGenerate({ + packet: input.packet, + ruleKeywords, + locale, + tone, + model, + }); + const parsed = unifiedContentResponseSchema.safeParse(raw); + + if (!parsed.success) { + return { + keywords: ruleKeywords, + imagePrompts: null, + variants: withGenerationMetadata( + generateChannelVariants(input.packet), + { mode: "template", model: null, hash } + ), + mode: "template", + warning: + "Unified LLM output failed validation; used template fallback.", + }; + } + + const { keywords, imagePrompts, variants: llmVariants } = parsed.data; + const mergedKeywords = Array.from(new Set([...ruleKeywords, ...keywords])); + + const templates = generateChannelVariants(input.packet); + const finalVariants = templates.map((template) => { + const generated = llmVariants.find((v) => v.format === template.format); + if (!generated) return template; + + return { + ...template, + channel: generated.channel, + format: generated.format, + title: generated.title, + body: generated.body, + hashtags: generated.hashtags, + generationMode: "llm" as const, + llmModel: model, + promptVersion: CONTENT_STUDIO_PROMPT_VERSION, + generationInputHash: hash, + }; + }); + + return { + keywords: mergedKeywords, + imagePrompts, + variants: finalVariants, + mode: "llm", + warning: null, + }; + } catch (error) { + return { + keywords: ruleKeywords, + imagePrompts: null, + variants: withGenerationMetadata(generateChannelVariants(input.packet), { + mode: "template", + model: null, + hash, + }), + mode: "template", + warning: + error instanceof Error + ? `Unified LLM generation failed: ${error.message}; used template fallback.` + : "Unified LLM generation failed; used template fallback.", + }; + } +} + export function mergeGovernanceResults( ruleResult: GovernanceResult, - llmResult: GovernanceResult | null, + llmResult: GovernanceResult | null ): GovernanceResult { if (!llmResult) return ruleResult; @@ -227,13 +354,13 @@ export function mergeGovernanceResults( riskLevel, flags: Array.from(new Set([...ruleResult.flags, ...llmResult.flags])), requiredActions: Array.from( - new Set([...ruleResult.requiredActions, ...llmResult.requiredActions]), + new Set([...ruleResult.requiredActions, ...llmResult.requiredActions]) ), }; } export function governanceResultFromLlm( - result: ContentGovernanceLLM, + result: ContentGovernanceLLM ): GovernanceResult { return { verdict: result.decision, diff --git a/packages/web/lib/content-studio/llm-prompts.ts b/packages/web/lib/content-studio/llm-prompts.ts index 49cb45f7..7257880f 100644 --- a/packages/web/lib/content-studio/llm-prompts.ts +++ b/packages/web/lib/content-studio/llm-prompts.ts @@ -1,4 +1,4 @@ -export const CONTENT_STUDIO_PROMPT_VERSION = "content-studio-v0.2.0"; +export const CONTENT_STUDIO_PROMPT_VERSION = "content-studio-v0.3.0"; export const CONTENT_CREATOR_SYSTEM_PROMPT = ` You are the editorial content engine for decoded. @@ -17,3 +17,36 @@ Rules: - Include disclosure copy when the input risk flags require it. - Output must match the provided JSON schema. `.trim(); + +export const UNIFIED_CONTENT_SYSTEM_PROMPT = ` +You are the editorial content engine for decoded. + +decoded is a style decoding platform. It turns fashion images into understandable style knowledge. + +You receive a ContentPacket (post data, detected items, style analysis) and a set of rule-based keywords extracted from the source. + +Your task is to produce a SINGLE unified response containing: + +1. **keywords**: Merge the provided rule keywords with your own style/trend keywords. Return 8-15 total keywords relevant to content discovery and SEO. + +2. **imagePrompts**: Generate image generation prompts for each channel format. Each prompt should describe a visually compelling thumbnail that represents the decoded style analysis. Reference the source image's style, mood, and key items. + - youtube: landscape 1536×1024, bold text overlay style + - instagram_feed: square 1024×1024, clean editorial style + - instagram_story: portrait 1024×1536, vertical immersive style + +3. **variants**: Channel-native marketing content for each format: + - instagram_carousel: educational slide-based breakdown + - instagram_reel: short vertical video script + - youtube_shorts: short-form style decode + - x_thread: conversational thread format + +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. +- Image prompts must NOT include real celebrity names or likeness descriptions. Describe style and mood only. +- Output must match the provided JSON schema exactly. +`.trim(); diff --git a/packages/web/lib/content-studio/packet-builder.ts b/packages/web/lib/content-studio/packet-builder.ts index e9aedb31..64b958ea 100644 --- a/packages/web/lib/content-studio/packet-builder.ts +++ b/packages/web/lib/content-studio/packet-builder.ts @@ -134,6 +134,27 @@ function itemList(items: ItemEntity[]): string { .join("\n"); } +export function extractRuleKeywords(packet: ContentPacket): string[] { + const raw: string[] = []; + + for (const item of packet.detectedItems) { + if (item.brand) raw.push(item.brand); + const words = item.title + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 2); + raw.push(...words); + } + + if (packet.artist) raw.push(packet.artist); + if (packet.group) raw.push(packet.group); + if (packet.context) raw.push(packet.context); + + return Array.from( + new Set(raw.map((k) => k.toLowerCase().trim()).filter(Boolean)) + ); +} + export function generateChannelVariants( packet: ContentPacket ): ContentVariant[] { From 9944a2f98d13a1f5df7ad9731573dfc477808d64 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 21:39:31 +0900 Subject: [PATCH 5/9] feat(content-studio): add channel thumbnail generation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schemas.ts — thumbnailChannelSchema + generateThumbnailsRequestSchema - assets/service.ts — generateChannelThumbnails(): parallel image gen per channel (youtube/instagram_feed/instagram_story) with partial failure handling - assets/thumbnails/route.ts — POST /api/v1/content/assets/thumbnails with admin auth - assets/index.ts — export new service functions - tests — 6 new tests (schema validation + generation + partial failures), 28 total passing Co-Authored-By: Claude Opus 4.6 --- .../api/v1/content/assets/thumbnails/route.ts | 65 ++++++++++ .../content-studio/__tests__/assets.test.ts | 111 +++++++++++++++++- .../web/lib/content-studio/assets/index.ts | 6 + .../web/lib/content-studio/assets/service.ts | 82 ++++++++++++- packages/web/lib/content-studio/schemas.ts | 21 ++++ 5 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 packages/web/app/api/v1/content/assets/thumbnails/route.ts diff --git a/packages/web/app/api/v1/content/assets/thumbnails/route.ts b/packages/web/app/api/v1/content/assets/thumbnails/route.ts new file mode 100644 index 00000000..aad5de18 --- /dev/null +++ b/packages/web/app/api/v1/content/assets/thumbnails/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateThumbnailsRequestSchema } from "@/lib/content-studio/schemas"; +import { generateChannelThumbnails } from "@/lib/content-studio/assets/service"; +import { checkIsAdmin } from "@/lib/supabase/admin"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +async function requireAdmin() { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const isAdmin = await checkIsAdmin(supabase, user.id); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + return null; +} + +export const maxDuration = 60; + +export async function POST(request: NextRequest) { + const adminError = await requireAdmin(); + if (adminError) return adminError; + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json( + { error: "OPENAI_API_KEY is not configured" }, + { status: 503 } + ); + } + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const parsed = generateThumbnailsRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + try { + const result = await generateChannelThumbnails(parsed.data); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Thumbnail generation failed", + }, + { status: 500 } + ); + } +} diff --git a/packages/web/lib/content-studio/__tests__/assets.test.ts b/packages/web/lib/content-studio/__tests__/assets.test.ts index e784fab2..90c1a890 100644 --- a/packages/web/lib/content-studio/__tests__/assets.test.ts +++ b/packages/web/lib/content-studio/__tests__/assets.test.ts @@ -1,10 +1,27 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { assetPlanRequestSchema, assetPlanSchema, + generateThumbnailsRequestSchema, shortFormPlanSchema, } from "../schemas"; import { buildAssetPlan, buildShortFormPlan } from "../assets"; +import { generateChannelThumbnails } from "../assets/service"; + +vi.mock("../assets/openai-client", () => ({ + generateAssetImage: vi.fn( + async (_prompt: string, _size: string, options?: { assetId?: string }) => { + const id = options?.assetId ?? "unknown"; + return `https://cdn.example.com/${id}.png`; + } + ), + isContentStudioAssetLlmEnabled: vi.fn(() => false), + refineAssetPlanWithOpenAI: vi.fn(), + refineShortFormPlanWithOpenAI: vi.fn(), + contentStudioAssetModel: vi.fn(() => "gpt-4.1"), + contentStudioImageModel: vi.fn(() => "gpt-image-1"), + generateAssetImagesForPlan: vi.fn(), +})); const packet = { id: "packet-1", @@ -138,3 +155,95 @@ describe("Content Studio asset schemas", () => { expect(plan.platform).toBe("youtube_shorts"); }); }); + +const imagePrompts = { + youtube: "Bold editorial style decode thumbnail for YouTube", + instagram_feed: "Clean square style breakdown for Instagram feed", + instagram_story: "Vertical immersive fashion decode for Instagram story", +}; + +describe("generateThumbnailsRequestSchema", () => { + it("parses a valid request with all fields", () => { + const parsed = generateThumbnailsRequestSchema.safeParse({ + packet, + imagePrompts, + channels: ["youtube", "instagram_feed"], + useReferenceImages: true, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.channels).toHaveLength(2); + expect(parsed.data.useReferenceImages).toBe(true); + } + }); + + it("defaults channels to undefined when omitted", () => { + const parsed = generateThumbnailsRequestSchema.safeParse({ + packet, + imagePrompts, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.channels).toBeUndefined(); + } + }); + + it("rejects missing imagePrompts", () => { + const parsed = generateThumbnailsRequestSchema.safeParse({ + packet, + }); + + expect(parsed.success).toBe(false); + }); +}); + +describe("generateChannelThumbnails", () => { + it("generates thumbnails for all channels by default", async () => { + const result = await generateChannelThumbnails({ + packet, + imagePrompts, + }); + + expect(result.failures).toHaveLength(0); + expect(result.thumbnails.youtube).toContain("thumb_youtube_packet-1"); + expect(result.thumbnails.instagram_feed).toContain( + "thumb_instagram_feed_packet-1" + ); + expect(result.thumbnails.instagram_story).toContain( + "thumb_instagram_story_packet-1" + ); + }); + + it("generates thumbnails for selected channels only", async () => { + const result = await generateChannelThumbnails({ + packet, + imagePrompts, + channels: ["youtube"], + }); + + expect(result.failures).toHaveLength(0); + expect(result.thumbnails.youtube).toContain("thumb_youtube_packet-1"); + expect(result.thumbnails.instagram_feed).toBeUndefined(); + expect(result.thumbnails.instagram_story).toBeUndefined(); + }); + + it("handles partial failures gracefully", async () => { + const openaiClient = await import("../assets/openai-client"); + const mockFn = vi.mocked(openaiClient.generateAssetImage); + mockFn.mockRejectedValueOnce(new Error("Rate limit exceeded")); + + const result = await generateChannelThumbnails({ + packet, + imagePrompts, + channels: ["youtube", "instagram_feed"], + }); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0].channel).toBe("youtube"); + expect(result.failures[0].error).toBe("Rate limit exceeded"); + expect(result.thumbnails.youtube).toBeNull(); + expect(result.thumbnails.instagram_feed).toContain("cdn.example.com"); + }); +}); diff --git a/packages/web/lib/content-studio/assets/index.ts b/packages/web/lib/content-studio/assets/index.ts index f63d462e..cfe5e784 100644 --- a/packages/web/lib/content-studio/assets/index.ts +++ b/packages/web/lib/content-studio/assets/index.ts @@ -1 +1,7 @@ export { buildAssetPlan, buildShortFormPlan } from "./plan"; +export { + generateAssetPlan, + generateShortFormPlan, + generateChannelThumbnails, + type ThumbnailResult, +} from "./service"; diff --git a/packages/web/lib/content-studio/assets/service.ts b/packages/web/lib/content-studio/assets/service.ts index a9c68ec5..404d0a2c 100644 --- a/packages/web/lib/content-studio/assets/service.ts +++ b/packages/web/lib/content-studio/assets/service.ts @@ -3,11 +3,15 @@ import { shortFormPlanRequestSchema, type AssetPlan, type AssetPlanRequest, + type ContentPacket, + type GenerateThumbnailsRequest, type ShortFormPlan, type ShortFormPlanRequest, + type ThumbnailChannel, } from "../schemas"; import { buildAssetPlan, buildShortFormPlan } from "./plan"; import { + generateAssetImage, isContentStudioAssetLlmEnabled, refineAssetPlanWithOpenAI, refineShortFormPlanWithOpenAI, @@ -24,7 +28,7 @@ export type ShortFormPlanResult = { }; export async function generateAssetPlan( - input: AssetPlanRequest, + input: AssetPlanRequest ): Promise { const base = buildAssetPlan({ packet: input.packet, @@ -57,7 +61,7 @@ export async function generateAssetPlan( } export async function generateShortFormPlan( - input: ShortFormPlanRequest, + input: ShortFormPlanRequest ): Promise { const base = buildShortFormPlan({ packet: input.packet, @@ -90,4 +94,78 @@ export async function generateShortFormPlan( } } +const THUMBNAIL_SIZE: Record = { + youtube: "1280x720", + instagram_feed: "1024x1280", + instagram_story: "1080x1920", +}; + +const ALL_THUMBNAIL_CHANNELS: ThumbnailChannel[] = [ + "youtube", + "instagram_feed", + "instagram_story", +]; + +export type ThumbnailResult = { + thumbnails: Record; + failures: Array<{ channel: string; error: string }>; +}; + +function collectReferenceUrls(packet: ContentPacket): string[] { + const urls: string[] = []; + if (packet.sourceImage) urls.push(packet.sourceImage); + for (const item of packet.detectedItems) { + if (item.thumbnailUrl) urls.push(item.thumbnailUrl); + if (urls.length >= 4) break; + } + return urls; +} + +export async function generateChannelThumbnails( + input: GenerateThumbnailsRequest +): Promise { + const channels = input.channels ?? ALL_THUMBNAIL_CHANNELS; + const referenceImageUrls = input.useReferenceImages + ? collectReferenceUrls(input.packet) + : undefined; + + const thumbnails: Record = {}; + const failures: Array<{ channel: string; error: string }> = []; + + const results = await Promise.allSettled( + channels.map(async (channel) => { + const prompt = input.imagePrompts[channel]; + const size = THUMBNAIL_SIZE[channel]; + const thumbnailId = `thumb_${channel}_${input.packet.id}`; + + const url = await generateAssetImage(prompt, size, { + planId: `thumbnails_${input.packet.id}`, + assetId: thumbnailId, + referenceImageUrls, + }); + + return { channel, url }; + }) + ); + + for (const result of results) { + if (result.status === "fulfilled") { + thumbnails[result.value.channel] = result.value.url; + } else { + const reason = result.reason; + const channel = channels[results.indexOf(result)] ?? "unknown"; + failures.push({ + channel, + error: + reason instanceof Error + ? reason.message + : "Thumbnail generation failed", + }); + thumbnails[channel] = null; + } + } + + return { thumbnails, failures }; +} + export { assetPlanRequestSchema, shortFormPlanRequestSchema }; diff --git a/packages/web/lib/content-studio/schemas.ts b/packages/web/lib/content-studio/schemas.ts index e2cd5f1c..6f15c726 100644 --- a/packages/web/lib/content-studio/schemas.ts +++ b/packages/web/lib/content-studio/schemas.ts @@ -182,6 +182,23 @@ export const shortFormPlanSchema = z.object({ }), }); +export const thumbnailChannelSchema = z.enum([ + "youtube", + "instagram_feed", + "instagram_story", +]); + +export const generateThumbnailsRequestSchema = z.object({ + packet: contentPacketSchema, + imagePrompts: z.object({ + youtube: z.string(), + instagram_feed: z.string(), + instagram_story: z.string(), + }), + channels: z.array(thumbnailChannelSchema).min(1).max(3).optional(), + useReferenceImages: z.boolean().optional(), +}); + export const assetPlanRequestSchema = z.object({ packet: contentPacketSchema, variants: z.array(contentVariantSchema).default([]), @@ -214,5 +231,9 @@ export type AssetPlanStatus = z.infer; export type ShortFormPlatform = z.infer; export type AssetPlan = z.infer; export type ShortFormPlan = z.infer; +export type ThumbnailChannel = z.infer; +export type GenerateThumbnailsRequest = z.infer< + typeof generateThumbnailsRequestSchema +>; export type AssetPlanRequest = z.infer; export type ShortFormPlanRequest = z.infer; From 0d4c2c12094b86c452babc20b8788ec45ffe9bfe Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 14 May 2026 22:16:54 +0900 Subject: [PATCH 6/9] feat(content-studio): add PostPickerModal and post search API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin 전용 포스트 검색 API(GET /api/v1/content/posts/search)와 PostPickerModal 컴포넌트 추가. ILIKE 검색, cursor 기반 무한스크롤, 이미지 그리드 UI로 Content Studio에서 포스트를 시각적으로 선택 가능. Co-Authored-By: Claude Opus 4.6 --- .../admin/content-studio/PostPickerModal.tsx | 210 ++++++++++++++++++ .../__tests__/post-picker.test.ts | 139 ++++++++++++ .../app/api/v1/content/posts/search/route.ts | 90 ++++++++ 3 files changed, 439 insertions(+) create mode 100644 packages/web/app/admin/content-studio/PostPickerModal.tsx create mode 100644 packages/web/app/admin/content-studio/__tests__/post-picker.test.ts create mode 100644 packages/web/app/api/v1/content/posts/search/route.ts diff --git a/packages/web/app/admin/content-studio/PostPickerModal.tsx b/packages/web/app/admin/content-studio/PostPickerModal.tsx new file mode 100644 index 00000000..8a1004d6 --- /dev/null +++ b/packages/web/app/admin/content-studio/PostPickerModal.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Search, X } from "lucide-react"; + +type PostItem = { + id: string; + image_url: string; + title: string | null; + artist_name: string | null; + group_name: string | null; + context: string | null; + created_at: string; + view_count: number; +}; + +type PostSearchResponse = { + items: PostItem[]; + nextCursor: string | null; +}; + +export function PostPickerModal({ + open, + onClose, + onSelect, +}: { + open: boolean; + onClose: () => void; + onSelect: (postId: string) => void; +}) { + const [query, setQuery] = useState(""); + const [posts, setPosts] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [initialLoad, setInitialLoad] = useState(true); + const sentinelRef = useRef(null); + const debounceRef = useRef>(undefined); + const abortRef = useRef(undefined); + + const fetchPosts = useCallback( + async (searchQuery: string, pageCursor: string | null, append: boolean) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + try { + const params = new URLSearchParams(); + if (searchQuery) params.set("q", searchQuery); + if (pageCursor) params.set("cursor", pageCursor); + + const response = await fetch( + `/api/v1/content/posts/search?${params.toString()}`, + { credentials: "include", signal: controller.signal } + ); + + if (!response.ok) return; + + const data: PostSearchResponse = await response.json(); + + if (append) { + setPosts((prev) => [...prev, ...data.items]); + } else { + setPosts(data.items); + } + setCursor(data.nextCursor); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + } finally { + setLoading(false); + setInitialLoad(false); + } + }, + [] + ); + + useEffect(() => { + if (!open) return; + setInitialLoad(true); + fetchPosts("", null, false); + }, [open, fetchPosts]); + + useEffect(() => { + if (!open) return; + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setCursor(null); + fetchPosts(query, null, false); + }, 300); + return () => clearTimeout(debounceRef.current); + }, [query, open, fetchPosts]); + + useEffect(() => { + if (!open || !cursor) return; + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && cursor && !loading) { + fetchPosts(query, cursor, true); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [open, cursor, loading, query, fetchPosts]); + + useEffect(() => { + if (!open) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

포스트 선택

+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder="아티스트, 그룹, 제목으로 검색..." + className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 text-sm text-foreground outline-none focus:border-foreground" + autoFocus + /> +
+
+ +
+ {initialLoad ? ( +
+ +
+ ) : posts.length === 0 ? ( +
+ {query ? "검색 결과가 없습니다" : "포스트가 없습니다"} +
+ ) : ( + <> +
+ {posts.map((post) => ( + + ))} +
+ +
+ {loading && ( + + )} +
+ + )} +
+
+
+ ); +} diff --git a/packages/web/app/admin/content-studio/__tests__/post-picker.test.ts b/packages/web/app/admin/content-studio/__tests__/post-picker.test.ts new file mode 100644 index 00000000..bd92af54 --- /dev/null +++ b/packages/web/app/admin/content-studio/__tests__/post-picker.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: vi.fn(), +})); + +vi.mock("@/lib/supabase/admin", () => ({ + checkIsAdmin: vi.fn(), +})); + +import { vi } from "vitest"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; + +function makeSupabaseMock(posts: unknown[] = [], error: unknown = null) { + const result = { data: posts, error }; + const builder: Record = {}; + for (const method of ["select", "eq", "or", "lt", "order", "limit"]) { + builder[method] = vi.fn(() => builder); + } + builder.then = (resolve: (v: unknown) => void) => resolve(result); + return { + from: vi.fn(() => builder), + auth: { + getSession: vi.fn().mockResolvedValue({ + data: { session: { user: { id: "admin-1" }, access_token: "tok" } }, + }), + }, + _builder: builder, + }; +} + +describe("GET /api/v1/content/posts/search", () => { + it("returns paginated posts for admin", async () => { + const mockPosts = Array.from({ length: 3 }, (_, i) => ({ + id: `post-${i}`, + image_url: `https://cdn.example.com/${i}.jpg`, + title: `Title ${i}`, + artist_name: `Artist ${i}`, + group_name: null, + context: null, + created_at: `2026-05-0${i + 1}T00:00:00Z`, + view_count: i * 10, + })); + + const supabase = makeSupabaseMock(mockPosts); + vi.mocked(createSupabaseServerClient).mockResolvedValue(supabase as never); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const { GET } = await import("@/app/api/v1/content/posts/search/route"); + + const request = new NextRequest( + "http://localhost/api/v1/content/posts/search" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.items).toHaveLength(3); + expect(data.nextCursor).toBeNull(); + }); + + it("passes search query as ILIKE filter", async () => { + const supabase = makeSupabaseMock([]); + vi.mocked(createSupabaseServerClient).mockResolvedValue(supabase as never); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const { GET } = await import("@/app/api/v1/content/posts/search/route"); + + const request = new NextRequest( + "http://localhost/api/v1/content/posts/search?q=jennie" + ); + await GET(request); + + expect(supabase._builder.or).toHaveBeenCalledWith( + expect.stringContaining("jennie") + ); + }); + + it("returns nextCursor when more items exist", async () => { + const mockPosts = Array.from({ length: 25 }, (_, i) => ({ + id: `post-${i}`, + image_url: `https://cdn.example.com/${i}.jpg`, + title: null, + artist_name: null, + group_name: null, + context: null, + created_at: `2026-05-01T00:${String(i).padStart(2, "0")}:00Z`, + view_count: 0, + })); + + const supabase = makeSupabaseMock(mockPosts); + vi.mocked(createSupabaseServerClient).mockResolvedValue(supabase as never); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const { GET } = await import("@/app/api/v1/content/posts/search/route"); + + const request = new NextRequest( + "http://localhost/api/v1/content/posts/search" + ); + const response = await GET(request); + const data = await response.json(); + + expect(data.items).toHaveLength(24); + expect(data.nextCursor).toBeTruthy(); + }); + + it("rejects non-admin users", async () => { + const supabase = makeSupabaseMock([]); + vi.mocked(createSupabaseServerClient).mockResolvedValue(supabase as never); + vi.mocked(checkIsAdmin).mockResolvedValue(false); + + const { GET } = await import("@/app/api/v1/content/posts/search/route"); + + const request = new NextRequest( + "http://localhost/api/v1/content/posts/search" + ); + const response = await GET(request); + + expect(response.status).toBe(403); + }); + + it("applies cursor-based pagination", async () => { + const supabase = makeSupabaseMock([]); + vi.mocked(createSupabaseServerClient).mockResolvedValue(supabase as never); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const { GET } = await import("@/app/api/v1/content/posts/search/route"); + + const cursorDate = "2026-05-01T00:00:00Z"; + const request = new NextRequest( + `http://localhost/api/v1/content/posts/search?cursor=${cursorDate}` + ); + await GET(request); + + expect(supabase._builder.lt).toHaveBeenCalledWith("created_at", cursorDate); + }); +}); diff --git a/packages/web/app/api/v1/content/posts/search/route.ts b/packages/web/app/api/v1/content/posts/search/route.ts new file mode 100644 index 00000000..fea344d5 --- /dev/null +++ b/packages/web/app/api/v1/content/posts/search/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Database } from "@/lib/supabase/types"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +async function requireAdminSession() { + let supabase: SupabaseClient; + try { + supabase = await createSupabaseServerClient(); + } catch (error) { + return { + error: NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Supabase configuration error", + }, + { status: 500 } + ), + }; + } + + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return { + error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + }; + } + + const isAdmin = await checkIsAdmin(supabase, session.user.id); + if (!isAdmin) { + return { + error: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + + return { session, supabase }; +} + +const PAGE_SIZE = 24; + +export async function GET(request: NextRequest) { + const auth = await requireAdminSession(); + if ("error" in auth) return auth.error; + const { supabase } = auth; + + const { searchParams } = new URL(request.url); + const query = searchParams.get("q")?.trim() ?? ""; + const cursor = searchParams.get("cursor") ?? null; + const limit = Math.min(Number(searchParams.get("limit")) || PAGE_SIZE, 48); + + let builder = supabase + .from("posts") + .select( + "id, image_url, title, artist_name, group_name, context, created_at, view_count" + ) + .eq("status", "active") + .order("created_at", { ascending: false }) + .limit(limit + 1); + + if (query) { + builder = builder.or( + `artist_name.ilike.%${query}%,title.ilike.%${query}%,group_name.ilike.%${query}%` + ); + } + + if (cursor) { + builder = builder.lt("created_at", cursor); + } + + const { data, error } = await builder; + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const posts = data ?? []; + const hasMore = posts.length > limit; + const items = hasMore ? posts.slice(0, limit) : posts; + const nextCursor = hasMore + ? (items[items.length - 1]?.created_at ?? null) + : null; + + return NextResponse.json({ items, nextCursor }); +} From fe19186ab6493d96803e8974f47794b9a54c8c98 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Fri, 15 May 2026 11:17:51 +0900 Subject: [PATCH 7/9] feat(content-studio): integrate PostPickerModal into page.tsx Add Browse button next to Post ID input for visual post selection, wiring PostPickerModal with open/close state and onSelect callback. Co-Authored-By: Claude Opus 4.6 --- .../web/app/admin/content-studio/page.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/web/app/admin/content-studio/page.tsx b/packages/web/app/admin/content-studio/page.tsx index d0e5caa7..57444f1e 100644 --- a/packages/web/app/admin/content-studio/page.tsx +++ b/packages/web/app/admin/content-studio/page.tsx @@ -6,12 +6,14 @@ import { CheckCircle2, Clipboard, FileText, + ImageIcon, Loader2, ShieldCheck, Sparkles, XCircle, } from "lucide-react"; import { AssetPanel } from "./AssetPanel"; +import { PostPickerModal } from "./PostPickerModal"; import { ShortFormPanel } from "./ShortFormPanel"; import type { ContentGenerationMode, @@ -292,7 +294,7 @@ function VariantPanel({ variants: ContentVariant[]; onStatusChange: ( variant: ContentVariant, - status: ContentVariant["status"], + status: ContentVariant["status"] ) => void; }) { if (variants.length === 0) return null; @@ -373,26 +375,27 @@ export default function ContentStudioPage() { 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, + null ); + const [pickerOpen, setPickerOpen] = useState(false); const [state, setState] = useState("idle"); const [error, setError] = useState(null); const canGenerate = useMemo( () => !!packet && state !== "loading", - [packet, state], + [packet, state] ); async function loadRecentPackets() { try { const data = await getJson<{ items: ContentPacketListItem[] }>( - "/api/v1/content/packets?limit=10", + "/api/v1/content/packets?limit=10" ); setRecentPackets(data.items ?? []); } catch { @@ -415,7 +418,7 @@ export default function ContentStudioPage() { try { const data = await postJson<{ packet: ContentPacket }>( "/api/v1/content/packets", - { postId: postId.trim() }, + { postId: postId.trim() } ); setPacket(data.packet); await loadRecentPackets(); @@ -447,14 +450,14 @@ export default function ContentStudioPage() { const reviewData = await postJson<{ result: GovernanceResult }>( "/api/v1/content/variants/draft/review", - { packet, variants: variantData.variants }, + { packet, variants: variantData.variants } ); setGovernance(reviewData.result); setState("idle"); await loadRecentPackets(); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to generate variants", + err instanceof Error ? err.message : "Failed to generate variants" ); setState("error"); } @@ -462,7 +465,7 @@ export default function ContentStudioPage() { async function handleVariantStatusChange( variant: ContentVariant, - status: ContentVariant["status"], + status: ContentVariant["status"] ) { const action = status === "approved" ? "approve" : "reject"; setError(null); @@ -470,15 +473,15 @@ export default function ContentStudioPage() { try { const data = await postJson<{ variant: ContentVariant }>( `/api/v1/content/variants/${variant.id}/${action}`, - variant, + variant ); setVariants((current) => - current.map((item) => (item.id === variant.id ? data.variant : item)), + current.map((item) => (item.id === variant.id ? data.variant : item)) ); await loadRecentPackets(); } catch (err) { setError( - err instanceof Error ? err.message : `Failed to ${action} variant`, + err instanceof Error ? err.message : `Failed to ${action} variant` ); } } @@ -522,17 +525,27 @@ export default function ContentStudioPage() { onSubmit={handleCreatePacket} className="grid gap-3 md:grid-cols-[1fr_auto]" > -