From 4ff150b1e542476459b6c40b69d650ada0121c5e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 5 Jul 2026 12:36:29 +0300 Subject: [PATCH] Orchestrate commercial demand before market frontier --- seo_mode/seo_mode/app/src/api.ts | 23 +- .../src/keywords/keywordAnalysisWorkflow.ts | 387 +++++++++++++++++- .../server/src/scripts/checkDemandCoverage.ts | 13 + 3 files changed, 415 insertions(+), 8 deletions(-) diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index 8e97a85..e6cdda2 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -1133,12 +1133,27 @@ export type KeywordAnalysisWorkflowContract = { generatedAt: string; status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting"; statusLabel: string; - activeStepId: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis" | null; + activeStepId: + | "business_synthesis" + | "commercial_demand" + | "demand_collection" + | "keyword_cleaning" + | "keyword_proposal" + | "project_context" + | "semantic_basis" + | null; activeStepLabel: string | null; summary: string; reason: string | null; steps: Array<{ - id: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis"; + id: + | "business_synthesis" + | "commercial_demand" + | "demand_collection" + | "keyword_cleaning" + | "keyword_proposal" + | "project_context" + | "semantic_basis"; label: string; status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting"; detail: string; @@ -1147,8 +1162,12 @@ export type KeywordAnalysisWorkflowContract = { completedAt: string | null; }>; artifacts: { + businessSynthesisRunId: string | null; + commercialDemandRunId: string | null; keywordCleaningRunId: string | null; + keywordCleaningModelTaskRunId: string | null; marketGeneratedAt: string | null; + marketFrontierRunId: string | null; modelTaskRunId: string | null; wordstatJobId: string | null; }; diff --git a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts index eb0b315..3cfd856 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts @@ -1,4 +1,6 @@ import crypto from "node:crypto"; +import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js"; +import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercialDemand.js"; import { pool } from "../db/client.js"; import { getAnchorReviewContract } from "./anchorReview.js"; import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js"; @@ -12,6 +14,8 @@ type KeywordAnalysisWorkflowStatus = "blocked" | "completed" | "failed" | "queue type KeywordAnalysisWorkflowStepId = | "semantic_basis" | "project_context" + | "business_synthesis" + | "commercial_demand" | "demand_collection" | "keyword_cleaning" | "keyword_proposal"; @@ -41,8 +45,12 @@ export type KeywordAnalysisWorkflowContract = { reason: string | null; steps: KeywordAnalysisWorkflowStep[]; artifacts: { + businessSynthesisRunId: string | null; + commercialDemandRunId: string | null; keywordCleaningRunId: string | null; + keywordCleaningModelTaskRunId: string | null; marketGeneratedAt: string | null; + marketFrontierRunId: string | null; modelTaskRunId: string | null; wordstatJobId: string | null; }; @@ -73,6 +81,16 @@ const WORKFLOW_STEPS: Array [step.id, step])); + + return { + ...contract, + activeStepLabel: contract.activeStepId ? getStepLabel(contract.activeStepId) : null, + artifacts: { + businessSynthesisRunId: contract.artifacts.businessSynthesisRunId ?? null, + commercialDemandRunId: contract.artifacts.commercialDemandRunId ?? null, + keywordCleaningRunId: contract.artifacts.keywordCleaningRunId ?? null, + keywordCleaningModelTaskRunId: contract.artifacts.keywordCleaningModelTaskRunId ?? null, + marketGeneratedAt: contract.artifacts.marketGeneratedAt ?? null, + marketFrontierRunId: contract.artifacts.marketFrontierRunId ?? null, + modelTaskRunId: contract.artifacts.modelTaskRunId ?? null, + wordstatJobId: contract.artifacts.wordstatJobId ?? null + }, + steps: WORKFLOW_STEPS.map((step) => { + const existing = stepById.get(step.id); + + return existing + ? { + ...step, + ...existing + } + : { + ...step, + completedAt: null, + reason: null, + startedAt: null, + status: "pending" as const + }; + }) + }; +} + function setWorkflowStatus( contract: KeywordAnalysisWorkflowContract, status: KeywordAnalysisWorkflowStatus, @@ -225,7 +282,7 @@ async function saveWorkflow( } function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) { - return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? row.output : null; + return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? normalizeWorkflowContract(row.output) : null; } async function getWorkflowRun(runId: string) { @@ -292,6 +349,219 @@ async function ensureProjectContextReady(projectId: string) { return market; } +async function ensureBusinessSynthesisReady( + contract: KeywordAnalysisWorkflowContract, + projectId: string, + semanticRunId: string, + demandCollectionProfile: "contextual" | "market_wide" +): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> { + if (demandCollectionProfile !== "market_wide") { + const completed = setStepStatus(contract, "business_synthesis", "completed", { + detail: "Контекстный охват не требует market-wide business synthesis перед Wordstat." + }); + await saveWorkflow(completed, "running"); + + return { contract: completed, ready: true }; + } + + const currentBusinessSynthesis = await getLatestAlignedSeoBusinessSynthesis(projectId, semanticRunId); + + if (currentBusinessSynthesis && currentBusinessSynthesis.provider.mode !== "deterministic_fallback") { + const ready = { + ...setStepStatus(contract, "business_synthesis", "completed", { + detail: + `Product-frame готов: ${currentBusinessSynthesis.productFrame.productCategory} · ${currentBusinessSynthesis.productFrame.confidence}.` + }), + artifacts: { + ...contract.artifacts, + businessSynthesisRunId: currentBusinessSynthesis.runId + } + }; + await saveWorkflow(ready, "running"); + + return { contract: ready, ready: true }; + } + + if (contract.artifacts.businessSynthesisRunId) { + const providerStatus = await getSeoModelProviderStatusContract(projectId); + const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.businessSynthesisRunId); + + if (!existingRun || isModelTaskRunning(existingRun.runStatus)) { + const waiting = setWorkflowStatus( + setStepStatus(contract, "business_synthesis", "waiting", { + reason: "AI Workspace собирает business synthesis перед рыночным frontier." + }), + "waiting", + { + activeStepId: "business_synthesis", + reason: "Ждём продуктовую рамку.", + summary: "Модель отделяет core offer, слои продукта, применения и guardrails." + } + ); + waiting.nextActions = ["Дождаться seo.business_synthesis; затем workflow построит commercial demand."]; + await saveWorkflow(waiting, "running"); + + return { contract: waiting, ready: false }; + } + + const failed = await failWorkflow( + contract, + "business_synthesis", + existingRun.errorMessage ?? "Business synthesis завершился без сохраненного product-frame evidence." + ); + return { contract: failed ?? contract, ready: false }; + } + + const modelTaskRun = await runSeoModelTask(projectId, { + providerId: "codex_workspace", + taskType: "seo.business_synthesis" + }); + const nextContract = { + ...contract, + artifacts: { + ...contract.artifacts, + businessSynthesisRunId: modelTaskRun.runId, + modelTaskRunId: modelTaskRun.runId + } + }; + + if (isModelTaskRunning(modelTaskRun.runStatus)) { + const waiting = setWorkflowStatus( + setStepStatus(nextContract, "business_synthesis", "waiting", { + reason: "AI Workspace принял business synthesis; ждём product-frame evidence." + }), + "waiting", + { + activeStepId: "business_synthesis", + reason: "Ждём продуктовую рамку.", + summary: "Модель отделяет core offer, слои продукта, применения и guardrails." + } + ); + waiting.nextActions = ["Дождаться seo.business_synthesis; затем workflow построит commercial demand."]; + await saveWorkflow(waiting, "running"); + + return { contract: waiting, ready: false }; + } + + if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") { + const failed = await failWorkflow( + nextContract, + "business_synthesis", + modelTaskRun.errorMessage ?? "Business synthesis не завершился успешно." + ); + return { contract: failed ?? nextContract, ready: false }; + } + + return { contract: nextContract, ready: true }; +} + +async function ensureCommercialDemandReady( + contract: KeywordAnalysisWorkflowContract, + projectId: string, + semanticRunId: string, + demandCollectionProfile: "contextual" | "market_wide" +): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> { + if (demandCollectionProfile !== "market_wide") { + const completed = setStepStatus(contract, "commercial_demand", "completed", { + detail: "Контекстный охват не требует market-wide commercial demand перед Wordstat." + }); + await saveWorkflow(completed, "running"); + + return { contract: completed, ready: true }; + } + + const currentCommercialDemand = await getLatestAlignedSeoCommercialDemand(projectId, semanticRunId); + + if (currentCommercialDemand && currentCommercialDemand.provider.mode !== "deterministic_fallback") { + const ready = { + ...setStepStatus(contract, "commercial_demand", "completed", { + detail: + `Buyer-intent frame готов: ${currentCommercialDemand.buyerIntentFamilies.length} families, ` + + `${currentCommercialDemand.applicationCommercialAngles.length} application angles.` + }), + artifacts: { + ...contract.artifacts, + commercialDemandRunId: currentCommercialDemand.runId + } + }; + await saveWorkflow(ready, "running"); + + return { contract: ready, ready: true }; + } + + if (contract.artifacts.commercialDemandRunId) { + const providerStatus = await getSeoModelProviderStatusContract(projectId); + const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.commercialDemandRunId); + + if (!existingRun || isModelTaskRunning(existingRun.runStatus)) { + const waiting = setWorkflowStatus( + setStepStatus(contract, "commercial_demand", "waiting", { + reason: "AI Workspace строит buyer-intent frame перед Wordstat." + }), + "waiting", + { + activeStepId: "commercial_demand", + reason: "Ждём коммерческий спрос.", + summary: "Модель переводит продуктовую рамку в покупательские интенты и application demand." + } + ); + waiting.nextActions = ["Дождаться seo.commercial_demand; затем workflow построит market frontier."]; + await saveWorkflow(waiting, "running"); + + return { contract: waiting, ready: false }; + } + + const failed = await failWorkflow( + contract, + "commercial_demand", + existingRun.errorMessage ?? "Commercial demand завершился без сохраненного buyer-intent evidence." + ); + return { contract: failed ?? contract, ready: false }; + } + + const modelTaskRun = await runSeoModelTask(projectId, { + providerId: "codex_workspace", + taskType: "seo.commercial_demand" + }); + const nextContract = { + ...contract, + artifacts: { + ...contract.artifacts, + commercialDemandRunId: modelTaskRun.runId, + modelTaskRunId: modelTaskRun.runId + } + }; + + if (isModelTaskRunning(modelTaskRun.runStatus)) { + const waiting = setWorkflowStatus( + setStepStatus(nextContract, "commercial_demand", "waiting", { + reason: "AI Workspace принял commercial demand; ждём buyer-intent evidence." + }), + "waiting", + { + activeStepId: "commercial_demand", + reason: "Ждём коммерческий спрос.", + summary: "Модель переводит продуктовую рамку в покупательские интенты и application demand." + } + ); + waiting.nextActions = ["Дождаться seo.commercial_demand; затем workflow построит market frontier."]; + await saveWorkflow(waiting, "running"); + + return { contract: waiting, ready: false }; + } + + if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") { + const failed = await failWorkflow( + nextContract, + "commercial_demand", + modelTaskRun.errorMessage ?? "Commercial demand не завершился успешно." + ); + return { contract: failed ?? nextContract, ready: false }; + } + + return { contract: nextContract, ready: true }; +} + async function ensureMarketFrontierDiscoveryReady( contract: KeywordAnalysisWorkflowContract, projectId: string, @@ -301,9 +571,9 @@ async function ensureMarketFrontierDiscoveryReady( return { contract, ready: true }; } - if (contract.artifacts.modelTaskRunId) { + if (contract.artifacts.marketFrontierRunId) { const providerStatus = await getSeoModelProviderStatusContract(projectId); - const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId); + const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.marketFrontierRunId); if (existingRun && isModelTaskRunning(existingRun.runStatus)) { const waiting = setWorkflowStatus( @@ -358,6 +628,7 @@ async function ensureMarketFrontierDiscoveryReady( ...contract, artifacts: { ...contract.artifacts, + marketFrontierRunId: modelTaskRun.runId, modelTaskRunId: modelTaskRun.runId } }; @@ -473,6 +744,31 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) contract = setStepStatus(contract, "project_context", "completed", { detail: "Проектный контекст принят для анализа спроса." }); + + const businessSynthesis = await ensureBusinessSynthesisReady( + contract, + projectId, + anchorReview.semanticRunId, + anchorReview.demandCollectionProfile + ); + contract = businessSynthesis.contract; + + if (!businessSynthesis.ready) { + return; + } + + const commercialDemand = await ensureCommercialDemandReady( + contract, + projectId, + anchorReview.semanticRunId, + anchorReview.demandCollectionProfile + ); + contract = commercialDemand.contract; + + if (!commercialDemand.ready) { + return; + } + contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", { activeStepId: "demand_collection", summary: "Собираем рыночную базу Wordstat." @@ -553,6 +849,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) ...contract, artifacts: { ...contract.artifacts, + keywordCleaningModelTaskRunId: modelTaskRun.runId, modelTaskRunId: modelTaskRun.runId } }; @@ -615,7 +912,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) async function finishKeywordCleaningWorkflow( contract: KeywordAnalysisWorkflowContract, projectId: string, - expectedModelTaskRunId: string | null = contract.artifacts.modelTaskRunId + expectedModelTaskRunId: string | null = contract.artifacts.keywordCleaningModelTaskRunId ?? contract.artifacts.modelTaskRunId ) { const keywordCleaningMatch = expectedModelTaskRunId ? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId) @@ -666,18 +963,96 @@ async function finishKeywordCleaningWorkflow( return completeWorkflow(nextContract); } +function getWaitingModelTaskRunId(contract: KeywordAnalysisWorkflowContract) { + if (contract.activeStepId === "business_synthesis") { + return contract.artifacts.businessSynthesisRunId ?? contract.artifacts.modelTaskRunId; + } + + if (contract.activeStepId === "commercial_demand") { + return contract.artifacts.commercialDemandRunId ?? contract.artifacts.modelTaskRunId; + } + + if (contract.activeStepId === "demand_collection") { + return contract.artifacts.marketFrontierRunId ?? contract.artifacts.modelTaskRunId; + } + + if (contract.activeStepId === "keyword_cleaning") { + return contract.artifacts.keywordCleaningModelTaskRunId ?? contract.artifacts.modelTaskRunId; + } + + return contract.artifacts.modelTaskRunId; +} + async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) { - if (contract.status !== "waiting" || !contract.artifacts.modelTaskRunId) { + const waitingModelTaskRunId = getWaitingModelTaskRunId(contract); + + if (contract.status !== "waiting" || !waitingModelTaskRunId) { return contract; } const providerStatus = await getSeoModelProviderStatusContract(contract.projectId); - const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId) ?? null; + const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === waitingModelTaskRunId) ?? null; if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) { return contract; } + if (contract.activeStepId === "business_synthesis") { + if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") { + const failed = await failWorkflow( + contract, + "business_synthesis", + modelTaskRun.errorMessage ?? "Business synthesis не завершился успешно." + ); + return failed ?? contract; + } + + const resumed = setWorkflowStatus( + setStepStatus(contract, "business_synthesis", "running", { + detail: "Business synthesis готов; продолжаем commercial demand." + }), + "running", + { + activeStepId: "business_synthesis", + reason: null, + summary: "Продолжаем оркестрацию: строим buyer-intent слой." + } + ); + resumed.nextActions = ["Workflow продолжит commercial demand; повторно нажимать кнопку не нужно."]; + const saved = await saveWorkflow(resumed, "running"); + void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId); + + return saved ?? resumed; + } + + if (contract.activeStepId === "commercial_demand") { + if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") { + const failed = await failWorkflow( + contract, + "commercial_demand", + modelTaskRun.errorMessage ?? "Commercial demand не завершился успешно." + ); + return failed ?? contract; + } + + const resumed = setWorkflowStatus( + setStepStatus(contract, "commercial_demand", "running", { + detail: "Commercial demand готов; продолжаем market frontier discovery." + }), + "running", + { + activeStepId: "commercial_demand", + reason: null, + summary: "Продолжаем оркестрацию: строим market frontier перед Wordstat." + } + ); + resumed.nextActions = ["Workflow продолжит market frontier и Wordstat; повторно нажимать кнопку не нужно."]; + const saved = await saveWorkflow(resumed, "running"); + void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId); + + return saved ?? resumed; + } + if (contract.activeStepId === "demand_collection") { const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled"; const detail = taskDidNotFinish diff --git a/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts b/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts index 2e934e4..9a87356 100644 --- a/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts +++ b/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts @@ -114,6 +114,19 @@ assert.ok( keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"), "Keyword cleaning/map must keep education-intent mismatch out of product lanes and preserve real editorial/local long-tail roles." ); +assert.ok( + keywordAnalysisWorkflowSource.includes("ensureBusinessSynthesisReady") && + keywordAnalysisWorkflowSource.includes("ensureCommercialDemandReady") && + keywordAnalysisWorkflowSource.indexOf("ensureBusinessSynthesisReady") < + keywordAnalysisWorkflowSource.indexOf("ensureCommercialDemandReady") && + keywordAnalysisWorkflowSource.indexOf("ensureCommercialDemandReady") < + keywordAnalysisWorkflowSource.indexOf("ensureMarketFrontierDiscoveryReady") && + keywordAnalysisWorkflowSource.includes('taskType: "seo.business_synthesis"') && + keywordAnalysisWorkflowSource.includes('taskType: "seo.commercial_demand"') && + appApiSource.includes('"business_synthesis"') && + appApiSource.includes('"commercial_demand"'), + "Market-wide workflow must run business synthesis and commercial demand before market frontier discovery." +); assert.ok( keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") && keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&