Orchestrate commercial demand before market frontier
This commit is contained in:
parent
e542e212b0
commit
4ff150b1e5
|
|
@ -1133,12 +1133,27 @@ export type KeywordAnalysisWorkflowContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
|
status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
|
||||||
statusLabel: string;
|
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;
|
activeStepLabel: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
steps: Array<{
|
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;
|
label: string;
|
||||||
status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
|
status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
|
||||||
detail: string;
|
detail: string;
|
||||||
|
|
@ -1147,8 +1162,12 @@ export type KeywordAnalysisWorkflowContract = {
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
}>;
|
}>;
|
||||||
artifacts: {
|
artifacts: {
|
||||||
|
businessSynthesisRunId: string | null;
|
||||||
|
commercialDemandRunId: string | null;
|
||||||
keywordCleaningRunId: string | null;
|
keywordCleaningRunId: string | null;
|
||||||
|
keywordCleaningModelTaskRunId: string | null;
|
||||||
marketGeneratedAt: string | null;
|
marketGeneratedAt: string | null;
|
||||||
|
marketFrontierRunId: string | null;
|
||||||
modelTaskRunId: string | null;
|
modelTaskRunId: string | null;
|
||||||
wordstatJobId: string | null;
|
wordstatJobId: string | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js";
|
||||||
|
import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercialDemand.js";
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getAnchorReviewContract } from "./anchorReview.js";
|
import { getAnchorReviewContract } from "./anchorReview.js";
|
||||||
import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js";
|
import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js";
|
||||||
|
|
@ -12,6 +14,8 @@ type KeywordAnalysisWorkflowStatus = "blocked" | "completed" | "failed" | "queue
|
||||||
type KeywordAnalysisWorkflowStepId =
|
type KeywordAnalysisWorkflowStepId =
|
||||||
| "semantic_basis"
|
| "semantic_basis"
|
||||||
| "project_context"
|
| "project_context"
|
||||||
|
| "business_synthesis"
|
||||||
|
| "commercial_demand"
|
||||||
| "demand_collection"
|
| "demand_collection"
|
||||||
| "keyword_cleaning"
|
| "keyword_cleaning"
|
||||||
| "keyword_proposal";
|
| "keyword_proposal";
|
||||||
|
|
@ -41,8 +45,12 @@ export type KeywordAnalysisWorkflowContract = {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
steps: KeywordAnalysisWorkflowStep[];
|
steps: KeywordAnalysisWorkflowStep[];
|
||||||
artifacts: {
|
artifacts: {
|
||||||
|
businessSynthesisRunId: string | null;
|
||||||
|
commercialDemandRunId: string | null;
|
||||||
keywordCleaningRunId: string | null;
|
keywordCleaningRunId: string | null;
|
||||||
|
keywordCleaningModelTaskRunId: string | null;
|
||||||
marketGeneratedAt: string | null;
|
marketGeneratedAt: string | null;
|
||||||
|
marketFrontierRunId: string | null;
|
||||||
modelTaskRunId: string | null;
|
modelTaskRunId: string | null;
|
||||||
wordstatJobId: string | null;
|
wordstatJobId: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -73,6 +81,16 @@ const WORKFLOW_STEPS: Array<Pick<KeywordAnalysisWorkflowStep, "detail" | "id" |
|
||||||
id: "project_context",
|
id: "project_context",
|
||||||
label: "Проектный контекст"
|
label: "Проектный контекст"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
detail: "Собираем продуктовую рамку: core offer, слои продукта, применения и ограничения расширения.",
|
||||||
|
id: "business_synthesis",
|
||||||
|
label: "Бизнес-смысл"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: "Переводим продуктовую рамку в покупательские интенты, внедрение, интеграции и коммерческие формулировки.",
|
||||||
|
id: "commercial_demand",
|
||||||
|
label: "Коммерческий спрос"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.",
|
detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.",
|
||||||
id: "demand_collection",
|
id: "demand_collection",
|
||||||
|
|
@ -144,8 +162,12 @@ function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysis
|
||||||
reason: null,
|
reason: null,
|
||||||
steps: buildInitialSteps(),
|
steps: buildInitialSteps(),
|
||||||
artifacts: {
|
artifacts: {
|
||||||
|
businessSynthesisRunId: null,
|
||||||
|
commercialDemandRunId: null,
|
||||||
keywordCleaningRunId: null,
|
keywordCleaningRunId: null,
|
||||||
|
keywordCleaningModelTaskRunId: null,
|
||||||
marketGeneratedAt: null,
|
marketGeneratedAt: null,
|
||||||
|
marketFrontierRunId: null,
|
||||||
modelTaskRunId: null,
|
modelTaskRunId: null,
|
||||||
wordstatJobId: null
|
wordstatJobId: null
|
||||||
},
|
},
|
||||||
|
|
@ -153,6 +175,41 @@ function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysis
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowContract(contract: KeywordAnalysisWorkflowContract): KeywordAnalysisWorkflowContract {
|
||||||
|
const stepById = new Map(contract.steps.map((step) => [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(
|
function setWorkflowStatus(
|
||||||
contract: KeywordAnalysisWorkflowContract,
|
contract: KeywordAnalysisWorkflowContract,
|
||||||
status: KeywordAnalysisWorkflowStatus,
|
status: KeywordAnalysisWorkflowStatus,
|
||||||
|
|
@ -225,7 +282,7 @@ async function saveWorkflow(
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) {
|
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) {
|
async function getWorkflowRun(runId: string) {
|
||||||
|
|
@ -292,6 +349,219 @@ async function ensureProjectContextReady(projectId: string) {
|
||||||
return market;
|
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(
|
async function ensureMarketFrontierDiscoveryReady(
|
||||||
contract: KeywordAnalysisWorkflowContract,
|
contract: KeywordAnalysisWorkflowContract,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -301,9 +571,9 @@ async function ensureMarketFrontierDiscoveryReady(
|
||||||
return { contract, ready: true };
|
return { contract, ready: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.artifacts.modelTaskRunId) {
|
if (contract.artifacts.marketFrontierRunId) {
|
||||||
const providerStatus = await getSeoModelProviderStatusContract(projectId);
|
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)) {
|
if (existingRun && isModelTaskRunning(existingRun.runStatus)) {
|
||||||
const waiting = setWorkflowStatus(
|
const waiting = setWorkflowStatus(
|
||||||
|
|
@ -358,6 +628,7 @@ async function ensureMarketFrontierDiscoveryReady(
|
||||||
...contract,
|
...contract,
|
||||||
artifacts: {
|
artifacts: {
|
||||||
...contract.artifacts,
|
...contract.artifacts,
|
||||||
|
marketFrontierRunId: modelTaskRun.runId,
|
||||||
modelTaskRunId: modelTaskRun.runId
|
modelTaskRunId: modelTaskRun.runId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -473,6 +744,31 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
|
||||||
contract = setStepStatus(contract, "project_context", "completed", {
|
contract = setStepStatus(contract, "project_context", "completed", {
|
||||||
detail: "Проектный контекст принят для анализа спроса."
|
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", {
|
contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", {
|
||||||
activeStepId: "demand_collection",
|
activeStepId: "demand_collection",
|
||||||
summary: "Собираем рыночную базу Wordstat."
|
summary: "Собираем рыночную базу Wordstat."
|
||||||
|
|
@ -553,6 +849,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
|
||||||
...contract,
|
...contract,
|
||||||
artifacts: {
|
artifacts: {
|
||||||
...contract.artifacts,
|
...contract.artifacts,
|
||||||
|
keywordCleaningModelTaskRunId: modelTaskRun.runId,
|
||||||
modelTaskRunId: modelTaskRun.runId
|
modelTaskRunId: modelTaskRun.runId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -615,7 +912,7 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
|
||||||
async function finishKeywordCleaningWorkflow(
|
async function finishKeywordCleaningWorkflow(
|
||||||
contract: KeywordAnalysisWorkflowContract,
|
contract: KeywordAnalysisWorkflowContract,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedModelTaskRunId: string | null = contract.artifacts.modelTaskRunId
|
expectedModelTaskRunId: string | null = contract.artifacts.keywordCleaningModelTaskRunId ?? contract.artifacts.modelTaskRunId
|
||||||
) {
|
) {
|
||||||
const keywordCleaningMatch = expectedModelTaskRunId
|
const keywordCleaningMatch = expectedModelTaskRunId
|
||||||
? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId)
|
? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId)
|
||||||
|
|
@ -666,18 +963,96 @@ async function finishKeywordCleaningWorkflow(
|
||||||
return completeWorkflow(nextContract);
|
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) {
|
async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) {
|
||||||
if (contract.status !== "waiting" || !contract.artifacts.modelTaskRunId) {
|
const waitingModelTaskRunId = getWaitingModelTaskRunId(contract);
|
||||||
|
|
||||||
|
if (contract.status !== "waiting" || !waitingModelTaskRunId) {
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerStatus = await getSeoModelProviderStatusContract(contract.projectId);
|
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)) {
|
if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) {
|
||||||
return contract;
|
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") {
|
if (contract.activeStepId === "demand_collection") {
|
||||||
const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled";
|
const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled";
|
||||||
const detail = taskDidNotFinish
|
const detail = taskDidNotFinish
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,19 @@ assert.ok(
|
||||||
keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"),
|
keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"),
|
||||||
"Keyword cleaning/map must keep education-intent mismatch out of product lanes and preserve real editorial/local long-tail roles."
|
"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(
|
assert.ok(
|
||||||
keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") &&
|
keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") &&
|
||||||
keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&
|
keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue