- {composeReady
- ? form.provider === "template"
- ? "Ready to test the workflow. Local sample mode is deterministic and intentionally limited; choose a model provider for production-quality content."
- : "SignalFlow has enough context to build editable drafts. You remain in control of every output and publishing step."
- : "Add a brief, public link, repository, or extractable text file. Keep the first run simple; advanced model controls can stay closed."}
+ {!sourceAndChannelsReady
+ ? "Add product evidence and select at least one destination."
+ : providerReadiness.ready
+ ? "SignalFlow has enough context and a real model route to build editable drafts."
+ : providerReadiness.reason}
{sourceSignals}source signals
diff --git a/frontend/lib/ai/generateStudioPackage.js b/frontend/lib/ai/generateStudioPackage.js
index 95ecd8a..8d880f4 100644
--- a/frontend/lib/ai/generateStudioPackage.js
+++ b/frontend/lib/ai/generateStudioPackage.js
@@ -5,6 +5,7 @@ import { normalizePackage } from "../package/normalizePackage";
import { generateJSON } from "./generateJSON";
import { buildMarkdown } from "../export/markdown";
import { PROVIDERS } from "./types";
+import { assertModelGenerationProvider } from "./generationPolicy.mjs";
import {
CHANNEL_CONTRACTS,
assessChannelDraft,
@@ -110,7 +111,7 @@ function packageDraft(pkg, channel) {
function normalizeDestinationDraft(rawDraft, channel, generationInputs) {
const packageKey = packageKeyForChannel(channel);
- const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs);
+ const normalized = normalizePackage({ posts: { [packageKey]: rawDraft } }, generationInputs, { allowTemplateFallback: false });
return normalized.posts[packageKey];
}
@@ -123,6 +124,39 @@ function statusForTemplate(channels) {
}]));
}
+function emptyDestinationDraft(channel) {
+ switch (channel) {
+ case "linkedin": return { title: "", body: "", hashtags: [], cta: "" };
+ case "x": return { mode: "post_or_thread", posts: [] };
+ case "instagram": return { caption: "", hashtags: [], visualDirection: "" };
+ case "reddit": return { title: "", body: "", subredditSuggestions: [] };
+ case "youtube": return { title: "", description: "", tags: [] };
+ case "tiktok": return { caption: "", hook: "", shotList: [] };
+ case "hackernews": return { title: "", body: "" };
+ case "newsletter": return { subject: "", preview: "", body: "" };
+ case "blog": return { title: "", outline: [], draft: "" };
+ case "release_notes": return { title: "", sections: [] };
+ default: return { body: "" };
+ }
+}
+
+function emptyPackagePosts() {
+ return {
+ linkedin: emptyDestinationDraft("linkedin"),
+ x: emptyDestinationDraft("x"),
+ instagram: emptyDestinationDraft("instagram"),
+ reddit: emptyDestinationDraft("reddit"),
+ facebook: emptyDestinationDraft("facebook"),
+ threads: emptyDestinationDraft("threads"),
+ youtube: emptyDestinationDraft("youtube"),
+ tiktok: emptyDestinationDraft("tiktok"),
+ hackernews: emptyDestinationDraft("hackernews"),
+ newsletter: emptyDestinationDraft("newsletter"),
+ blog: emptyDestinationDraft("blog"),
+ releaseNotes: emptyDestinationDraft("release_notes"),
+ };
+}
+
async function generateDestination({
channel,
context,
@@ -193,13 +227,12 @@ async function generateDestination({
},
};
} catch (error) {
- const fallback = normalizePackage({}, generationInputs).posts[packageKey];
return {
channel,
packageKey,
- draft: fallback,
+ draft: emptyDestinationDraft(channel),
status: {
- status: "template_fallback",
+ status: "failed",
attempts: firstDraft ? 1 : 0,
qualityScore: firstQuality?.score ?? null,
issues: [`Destination generation failed: ${error.message}`],
@@ -229,12 +262,14 @@ export async function generateStudioPackage(inputs) {
mediaItems = [],
selectedChannels = [],
selectedOutputs = [],
- generator = "template",
+ generator: requestedGenerator = "",
model_name = "",
appUrl = "",
config = {},
} = inputs;
+ const generator = assertModelGenerationProvider(requestedGenerator);
+
const generationInputs = {
projectName,
notes,
@@ -253,50 +288,9 @@ export async function generateStudioPackage(inputs) {
const contextWarnings = Array.isArray(context.warnings) ? context.warnings : [];
const campaignBriefPrompt = buildCampaignBriefPrompt(context);
- if (generator === "prompt") {
- const result = templateResult(
- generationInputs,
- "Prompt route selected. The prompt now separates product strategy from destination writing instead of requesting one compressed campaign object.",
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "prompt",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
- }
-
- if (generator === "template" || generator === "offline") {
- const result = templateResult(
- generationInputs,
- "Local template route created deterministic fallback copy. Connect a capable model for staged destination-specific editorial generation.",
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "template",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
- }
-
const providerMeta = PROVIDERS[generator];
if (!providerMeta) {
- const result = templateResult(
- generationInputs,
- `Unknown provider "${generator}". The local template route was used instead.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: "template",
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
+ throw new Error(`Unsupported model provider: ${generator}.`);
}
const temporaryKey = Boolean(config?.apiKey);
@@ -313,18 +307,7 @@ export async function generateStudioPackage(inputs) {
const requirement = generator === "custom"
? "an OpenAI-compatible base URL"
: (providerMeta.requiredEnv || []).join(" or ") || "provider credentials";
- const result = templateResult(
- generationInputs,
- `${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key; the deterministic template route was used.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: generator,
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: buildPromptBundle(context, result.package, channels),
- };
+ throw new Error(`${providerMeta.label} is not configured. Add ${requirement} or a temporary personal key.`);
}
const modelOverride = model_name || config?.modelName || providerMeta.defaultModel;
@@ -337,8 +320,9 @@ export async function generateStudioPackage(inputs) {
config,
});
- const pkg = normalizePackage(rawBrief, generationInputs);
+ const pkg = normalizePackage(rawBrief, generationInputs, { allowTemplateFallback: false });
pkg.strategy.destinationAngles = rawBrief?.strategy?.destinationAngles || {};
+ pkg.posts = emptyPackagePosts();
const generatedDestinations = await Promise.all(channels.map((channel) => generateDestination({
channel,
@@ -353,15 +337,20 @@ export async function generateStudioPackage(inputs) {
const generationStatus = {};
const generationWarnings = [];
for (const result of generatedDestinations) {
- pkg.posts[result.packageKey] = result.draft;
+ if (result.status.status !== "failed") pkg.posts[result.packageKey] = result.draft;
generationStatus[result.channel] = result.status;
- if (result.status.status === "template_fallback") {
- generationWarnings.push(`${result.channel}: model generation failed, so deterministic fallback copy is shown.`);
+ if (result.status.status === "failed") {
+ generationWarnings.push(`${result.channel}: model generation failed and no substitute copy was inserted.`);
} else if (result.status.status === "needs_review") {
generationWarnings.push(`${result.channel}: the best draft still failed one or more editorial quality checks.`);
}
}
+ const failedDestinations = generatedDestinations.filter((item) => item.status.status === "failed");
+ if (failedDestinations.length === generatedDestinations.length) {
+ throw new Error(`Every selected destination failed: ${failedDestinations.map((item) => item.channel).join(", ")}.`);
+ }
+
pkg.generation = {
mode: "staged_agent",
provider: generator,
@@ -375,13 +364,13 @@ export async function generateStudioPackage(inputs) {
const description = pkg.project.description || notes || pkg.project.oneLine || "Review-ready campaign draft";
const fileSlug = slug(name);
const svgContent = buildCampaignCard(name, description);
- const fallbackUsed = generatedDestinations.some((item) => item.status.status === "template_fallback");
+ const partialFailureUsed = failedDestinations.length > 0;
return {
ok: true,
providerUsed: generator,
- fallbackUsed,
- partialFallbackUsed: fallbackUsed,
+ fallbackUsed: false,
+ partialFailureUsed,
generation_status: generationStatus,
chatbot_prompt: campaignBriefPrompt,
warnings: Array.from(new Set([...contextWarnings, ...generationWarnings])),
@@ -415,17 +404,7 @@ export async function generateStudioPackage(inputs) {
},
};
} catch (error) {
- const result = templateResult(
- generationInputs,
- `${providerMeta.label} campaign strategy generation failed: ${error.message}. Deterministic fallback copy was created instead.`,
- contextWarnings,
- );
- return {
- ...result,
- providerUsed: generator,
- fallbackUsed: true,
- generation_status: statusForTemplate(channels),
- chatbot_prompt: campaignBriefPrompt,
- };
+ throw new Error(`${providerMeta.label} campaign generation failed: ${error.message}`);
+
}
}
diff --git a/frontend/lib/package/normalizePackage.js b/frontend/lib/package/normalizePackage.js
index 599f7b2..42de40a 100644
--- a/frontend/lib/package/normalizePackage.js
+++ b/frontend/lib/package/normalizePackage.js
@@ -144,9 +144,49 @@ function normalizeReleaseNotes(value, fallback) {
};
}
-/** Ensures every provider response is complete and safe for the active UI. */
-export function normalizePackage(rawPackage, inputs) {
- const baseline = generateLocalTemplatePackage(inputs).package;
+function strictBaseline(inputs = {}) {
+ return {
+ project: {
+ name: String(inputs.projectName || "SignalFlow campaign"),
+ oneLine: "",
+ description: String(inputs.notes || ""),
+ audience: String(inputs.audience || ""),
+ category: "",
+ stage: "",
+ },
+ context: {
+ confirmedFacts: [], inferredFacts: [], missingContext: [], features: [], techStack: [],
+ repoInsights: [], docsInsights: [], linkInsights: [], mediaInsights: [],
+ },
+ strategy: {
+ coreAngle: "", positioning: "", hooks: [], proofPoints: [], risks: [], safeClaims: [], avoidClaims: [],
+ },
+ posts: {
+ linkedin: { title: "", body: "", hashtags: [], cta: "" },
+ x: { mode: "post_or_thread", posts: [] },
+ instagram: { caption: "", hashtags: [], visualDirection: "" },
+ reddit: { title: "", body: "", subredditSuggestions: [] },
+ facebook: { body: "" },
+ threads: { body: "" },
+ youtube: { title: "", description: "", tags: [] },
+ tiktok: { caption: "", hook: "", shotList: [] },
+ hackernews: { title: "", body: "" },
+ blog: { title: "", outline: [], draft: "" },
+ newsletter: { subject: "", preview: "", body: "" },
+ releaseNotes: { title: "", sections: [] },
+ },
+ media: {
+ screenshotPlan: [], videoScript: [], voiceoverScript: [], shotList: [], recordingGuide: [],
+ carouselPlan: [], thumbnailIdeas: [], videoTimeline: [], altText: [], assetChecklist: [],
+ videoPrompt: "", thumbnailPrompt: "",
+ },
+ publishing: { platformChecklist: [], manualPostingSteps: [], apiPublishingNotes: "", warnings: [] },
+ };
+}
+
+/** Ensures provider responses match the active UI contract. */
+export function normalizePackage(rawPackage, inputs, { allowTemplateFallback = true } = {}) {
+ const baseline = allowTemplateFallback ? generateLocalTemplatePackage(inputs).package : strictBaseline(inputs);
const raw = object(rawPackage);
if (!raw) return baseline;
@@ -222,7 +262,7 @@ export function normalizePackage(rawPackage, inputs) {
},
};
- if (!normalized.media.videoPrompt) {
+ if (allowTemplateFallback && !normalized.media.videoPrompt) {
normalized.media.videoPrompt = buildVideoPrompt(normalized);
}
From add7751f40ede8c214768817a6e2fb9a4e5b2855 Mon Sep 17 00:00:00 2001
From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:14:12 +0530
Subject: [PATCH 17/29] ci: validate Studio, MCP, frontend, and backend
together
---
.github/workflows/ci.yml | 24 ------------------------
1 file changed, 24 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f8e0263..769f8b8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,9 +6,6 @@ on:
pull_request:
branches: [main, master]
-permissions:
- contents: write
-
jobs:
mcp-tests:
runs-on: ubuntu-latest
@@ -22,27 +19,6 @@ jobs:
working-directory: mcp
run: npm test
- apply-studio-product-migration:
- if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'product/studio-end-to-end-mcp' && github.actor != 'github-actions[bot]'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: product/studio-end-to-end-mcp
- fetch-depth: 0
- - uses: actions/setup-python@v5
- with:
- python-version: "3.10"
- - name: Apply Studio, strict generation, and MCP migration
- run: python .github/scripts/apply-studio-product-mcp.py
- - name: Commit only migrated product source
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add frontend
- git commit -m "feat: rebuild Studio model workflow and strict generation"
- git push origin HEAD:product/studio-end-to-end-mcp
-
python-tests:
runs-on: ubuntu-latest
steps:
From 9d9c6d41559fd1259bafea4c3487480f669d76bc Mon Sep 17 00:00:00 2001
From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:14:44 +0530
Subject: [PATCH 18/29] chore: remove completed one-time product migration
---
.github/scripts/apply-studio-product-mcp.py | 724 --------------------
1 file changed, 724 deletions(-)
delete mode 100644 .github/scripts/apply-studio-product-mcp.py
diff --git a/.github/scripts/apply-studio-product-mcp.py b/.github/scripts/apply-studio-product-mcp.py
deleted file mode 100644
index 5fb89d8..0000000
--- a/.github/scripts/apply-studio-product-mcp.py
+++ /dev/null
@@ -1,724 +0,0 @@
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parents[2]
-
-
-def read(path):
- return (ROOT / path).read_text()
-
-
-def write(path, value):
- (ROOT / path).write_text(value)
-
-
-def replace_required(path, old, new, label):
- text = read(path)
- if old not in text:
- raise RuntimeError(f"Missing {label} in {path}")
- write(path, text.replace(old, new, 1))
-
-
-def replace_between(path, start, end, replacement, label):
- text = read(path)
- start_index = text.find(start)
- end_index = text.find(end, start_index + len(start))
- if start_index < 0 or end_index < 0:
- raise RuntimeError(f"Missing {label} boundaries in {path}")
- write(path, text[:start_index] + replacement + text[end_index:])
-
-
-PAGE = "frontend/app/page.js"
-
-replace_required(
- PAGE,
- '} from "../lib/studio/clientReliability.mjs";',
- '} from "../lib/studio/clientReliability.mjs";\nimport {\n evaluateProviderReadiness,\n pickRecommendedProvider,\n} from "../lib/studio/providerReadiness.mjs";',
- "provider readiness import",
-)
-
-replace_between(
- PAGE,
- "const PROVIDERS = [",
- "const FAQS = [",
- '''const PROVIDERS = [
- { id: "gemini", label: "Gemini", hint: "Use a temporary Google AI Studio key or the securely configured server route." },
- { id: "openai", label: "OpenAI", hint: "Use a temporary OpenAI key or the securely configured server route." },
- { id: "claude", label: "Claude", hint: "Use a temporary Anthropic key or the securely configured server route." },
- { id: "openrouter", label: "OpenRouter", hint: "Route generation through a model available in your OpenRouter account." },
- { id: "groq", label: "Groq", hint: "Use a Groq key for fast hosted generation." },
- { id: "custom", label: "Custom gateway", hint: "Use an OpenAI-compatible endpoint and model." },
- { id: "ollama", label: "Ollama", hint: "Use a reachable Ollama endpoint in local or trusted self-hosted deployments." },
- { id: "lmstudio", label: "LM Studio", hint: "Use a reachable LM Studio endpoint in local or trusted self-hosted deployments." },
-];
-
-''',
- "model provider list",
-)
-
-replace_required(
- PAGE,
- 'question: "Can I use my own AI model or no AI at all?",\n answer:\n "Yes. SignalFlow includes a deterministic local template route and supports Gemini, OpenAI, Claude, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints.",',
- 'question: "Can I bring my own model provider?",\n answer:\n "Yes. SignalFlow supports Gemini, OpenAI, Claude, OpenRouter, Groq, Ollama, LM Studio, and custom OpenAI-compatible endpoints. Campaign generation requires a real model route.",',
- "landing FAQ model answer",
-)
-
-replace_required(PAGE, 'provider: "template",', 'provider: "gemini",', "default provider")
-replace_required(
- PAGE,
- ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [accessToken, setAccessToken] = useState("");',
- ' const [connectionsLoading, setConnectionsLoading] = useState(false);\n const [providerStatuses, setProviderStatuses] = useState({});\n const [providerStatusLoading, setProviderStatusLoading] = useState(true);\n const [providerTest, setProviderTest] = useState({ status: "idle", message: "" });\n const [accessToken, setAccessToken] = useState("");',
- "provider status state",
-)
-replace_required(PAGE, ' const [showAdvanced, setShowAdvanced] = useState(false);\n', '', "retired advanced state")
-
-replace_required(
- PAGE,
- ' const composeReady = sourceSignals > 0 && channels.length > 0;',
- ''' const providerReadiness = evaluateProviderReadiness({
- provider: form.provider,
- apiKey: form.apiKey,
- baseUrl: form.baseUrl,
- status: providerStatuses[form.provider],
- });
- const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0;
- const composeReady = sourceAndChannelsReady && providerReadiness.ready;''',
- "provider-aware campaign readiness",
-)
-
-replace_required(
- PAGE,
- ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n }, [entered, accessToken]);',
- ' useEffect(() => {\n if (!entered) return;\n refreshConnections();\n refreshProviderStatus();\n }, [entered, accessToken]);',
- "provider status refresh effect",
-)
-
-replace_required(
- PAGE,
- ' async function syncOwnerSession() {',
- ''' async function refreshProviderStatus() {
- setProviderStatusLoading(true);
- try {
- const response = await fetch("/api/provider_status");
- const data = await readJsonResponse(response, "SignalFlow could not read model provider status.");
- const statuses = data.providers || {};
- setProviderStatuses(statuses);
- const recommended = pickRecommendedProvider({
- defaultProvider: data.defaultProvider,
- statuses,
- fallback: form.provider,
- });
- setForm((previous) => {
- if (previous.apiKey.trim() || previous.baseUrl.trim() || statuses[previous.provider]?.configured) return previous;
- return previous.provider === recommended ? previous : { ...previous, provider: recommended };
- });
- } catch {
- setProviderStatuses({});
- } finally {
- setProviderStatusLoading(false);
- }
- }
-
- async function testProviderConnection() {
- if (!providerReadiness.ready) {
- setProviderTest({ status: "error", message: providerReadiness.reason });
- return;
- }
- setProviderTest({ status: "testing", message: "Testing model route…" });
- try {
- const response = await fetch("/api/provider_test", {
- method: "POST",
- headers: authHeaders({ "Content-Type": "application/json" }),
- body: JSON.stringify({
- provider: form.provider,
- modelName: form.model.trim(),
- baseUrl: form.baseUrl.trim(),
- temporaryApiKey: form.apiKey.trim(),
- }),
- });
- const data = await readJsonResponse(response, "SignalFlow returned an unreadable provider test response.");
- if (!response.ok || !data.ok) throw new Error(data.error || "Model route test failed.");
- setProviderTest({ status: "success", message: data.message || "Model route connected successfully." });
- void refreshProviderStatus();
- } catch (error) {
- setProviderTest({ status: "error", message: error.message });
- }
- }
-
- async function syncOwnerSession() {''',
- "provider functions",
-)
-
-replace_required(
- PAGE,
- ' setBusy(true);\n setMessage(null);',
- ''' if (!providerReadiness.ready) {
- setMessage({ type: "error", text: providerReadiness.reason });
- navigateStudioFlow("destinations");
- return;
- }
-
- setBusy(true);
- setMessage(null);''',
- "provider guard before generation",
-)
-
-replace_required(
- PAGE,
- ''' setMessage({
- type: data.fallbackUsed ? "warning" : "success",
- text: data.fallbackUsed
- ? "Campaign created with a fallback route. Review the generation note before publishing."
- : `Campaign generated with ${data.providerUsed || provider.label}.`,
- });''',
- ''' if (data.fallbackUsed) {
- throw new Error("SignalFlow refused the response because it contained retired template fallback content.");
- }
- const failedChannels = Object.entries(data.generation_status || {})
- .filter(([, item]) => item?.status === "failed")
- .map(([channel]) => channel);
- setMessage({
- type: failedChannels.length ? "warning" : "success",
- text: failedChannels.length
- ? `Campaign generated with ${data.providerUsed || provider.label}; ${failedChannels.join(", ")} failed without template substitution.`
- : `Campaign generated with ${data.providerUsed || provider.label}.`,
- });''',
- "honest generation result",
-)
-
-replace_required(
- PAGE,
- '
\n
{accessToken ? "Owner session" : "Local mode"}',
- '
\n
{providerReadiness.ready ? `${accessToken ? "Owner · " : ""}${provider.label}` : "Model setup needed"}',
- "header model status",
-)
-
-replace_between(
- PAGE,
- '