From cebc0d9b7d6bf886a7fe7162becee5bedb0f766f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 5 Jul 2026 13:39:11 +0300 Subject: [PATCH] Harden market-wide Wordstat probe routing --- .../src/keywords/keywordAnalysisWorkflow.ts | 46 +++- .../server/src/market/marketEnrichment.ts | 209 +++++++++++------- .../server/src/scripts/checkDemandCoverage.ts | 20 +- 3 files changed, 199 insertions(+), 76 deletions(-) diff --git a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts index 3cfd856..f13b136 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts @@ -4,7 +4,11 @@ import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercial import { pool } from "../db/client.js"; import { getAnchorReviewContract } from "./anchorReview.js"; import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js"; -import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js"; +import { + getMarketEnrichmentContract, + getWordstatProbePlanPreview, + runMarketEnrichment +} from "../market/marketEnrichment.js"; import { getMarketFrontierDiscoveryContract } from "../market/marketFrontierDiscovery.js"; import { getSeoModelProviderStatusContract, runSeoModelTask } from "../modelProvider/seoModelTaskRuns.js"; import { updateProjectOntologyApproval } from "../ontology/projectOntologyRepository.js"; @@ -593,6 +597,30 @@ async function ensureMarketFrontierDiscoveryReady( return { contract: waiting, ready: false }; } + if (existingRun && (existingRun.runStatus === "failed" || existingRun.runStatus === "cancelled")) { + const failed = await failWorkflow( + contract, + "demand_collection", + existingRun.errorMessage ?? "Market frontier discovery не завершился успешно." + ); + return { contract: failed ?? contract, ready: false }; + } + + const currentDiscovery = await getMarketFrontierDiscoveryContract(projectId); + + if (currentDiscovery.provider.mode === "deterministic_fallback") { + const blocked = await blockWorkflow( + contract, + "demand_collection", + "Market frontier discovery не дал пригодный model frontier; Wordstat на fallback-плане не запускается.", + [ + "Проверить output seo.market_frontier_discovery и повторить workflow после исправления model frontier.", + "Fallback frontier можно смотреть только в preview; он не должен тратить Wordstat quota." + ] + ); + return { contract: blocked ?? contract, ready: false }; + } + return { contract, ready: true }; } @@ -810,10 +838,26 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) const latestWordstatJob = market.wordstat.latestJob; const latestWordstatJobId = latestWordstatJob?.id ?? null; + const hasFreshWordstatJob = Boolean(latestWordstatJobId && latestWordstatJobId !== wordstatJobIdBeforeCollection); const hasFreshWordstatFailure = latestWordstatJob?.status === "failed" && latestWordstatJobId !== wordstatJobIdBeforeCollection; const hasReusableWordstatEvidence = market.wordstat.summary.resultCount > 0; + if (anchorReview.demandCollectionProfile === "market_wide" && !hasFreshWordstatJob) { + const probePreview = await getWordstatProbePlanPreview(projectId); + const frontierFallback = probePreview.warnings.some((warning) => warning.code === "frontier_fallback"); + + if (frontierFallback) { + await blockWorkflow( + contract, + "demand_collection", + "Market-wide Wordstat-добор не стартовал: свежий model frontier не готов.", + probePreview.nextActions + ); + return; + } + } + if (hasFreshWordstatFailure || (latestWordstatJob?.status === "failed" && !hasReusableWordstatEvidence)) { await failWorkflow( contract, diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts index 029d89c..7efd596 100644 --- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts +++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts @@ -308,13 +308,15 @@ const EXPANSION_QUERY_BLOCKLIST = [ /\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i ]; -const SAFE_SHORT_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "mcp", "ops"]); +const SAFE_SHORT_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "llm", "mcp", "ops"]); +const SAFE_WORDSTAT_PROBE_LATIN_TOKENS = new Set(SAFE_SHORT_EXPANSION_LATIN_TOKENS); const DEMAND_COLLECTION_MAX_BASE_RUNS = 1; const DEMAND_COLLECTION_MAX_MARKET_WIDE_RUNS = 1; const DEMAND_COLLECTION_CONTEXTUAL_PROBE_BUDGET = 8; const DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET = 12; -const DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_PROBE_BUDGET = 2; -const DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET = 8; +const DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_FALLBACK_PROBE_BUDGET = 2; +const DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET = 12; +const DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE = 64; const WORDSTAT_PROBE_MAX_PER_PATTERN_FAMILY = 4; const WORDSTAT_PROBE_MAX_PER_CLUSTER = 8; @@ -359,6 +361,7 @@ type WordstatProbePlanAuditItem = WordstatProbePlanSelectedItem & { type WordstatProbePlanWarning = { code: + | "frontier_fallback" | "frontier_missing" | "high_reuse_blocking" | "low_diversity" @@ -429,43 +432,6 @@ function unique(values: string[]) { return result; } -function interleaveWordstatSeedGroups(groups: T[][], limit: number) { - const result: T[] = []; - const seenPhrases = new Set(); - - for (let index = 0; result.length < limit; index += 1) { - let added = false; - - for (const group of groups) { - const candidate = group[index]; - - if (!candidate) { - continue; - } - - const normalizedPhrase = normalizePhrase(candidate.phrase); - - if (!normalizedPhrase || seenPhrases.has(normalizedPhrase)) { - continue; - } - - seenPhrases.add(normalizedPhrase); - result.push(candidate); - added = true; - - if (result.length >= limit) { - break; - } - } - - if (!added) { - break; - } - } - - return result; -} - function getPriorityProbeWeight(priority: WordstatSeedCandidate["priority"]) { if (priority === "high") return 32; if (priority === "medium") return 20; @@ -537,20 +503,39 @@ function hasNonProductProbeIntentMismatch(phrase: string) { function getWeakGenericProbeReason(phrase: string) { const normalizedPhrase = normalizePhrase(phrase); const words = wordCount(normalizedPhrase); - const genericObject = /(агент[а-яё]*|данн[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)/i; + const genericObject = /(агент[а-яё]*|данн[а-яё]*|контур[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)/i; if (words <= 2 && genericObject.test(normalizedPhrase) && !hasMarketQualifier(normalizedPhrase)) { return "Короткая generic-фраза без рыночного/отраслевого квалификатора."; } if ( - /^(применени[ея]|внедрени[ея]|разработк[аи]|создани[ея]|интеграц[ияеи])\s+(агент[а-яё]*|данн[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)$/i.test( + /^(применени[ея]|внедрени[ея]|разработк[аи]|создани[ея]|интеграц[ияеи]|заказать|заказ|купить)\s+(агент[а-яё]*|данн[а-яё]*|контур[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)(\s+для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*))?$/i.test( normalizedPhrase ) ) { return "Action + generic object без конкретного рынка, продукта или buyer intent."; } + if ( + words >= 4 && + /^(интеграционн[а-яё]*|корпоративн[а-яё]*|платформ[а-яё]*|систем[а-яё]*)\s+/i.test(normalizedPhrase) && + /(^|\s)(enterprise|предприяти[а-яё]*|корпоративн[а-яё]*|закрыт[а-яё]*\s+контур|внутренн[а-яё]*|сотрудник[а-яё]*)(\s|$)/i.test( + normalizedPhrase + ) + ) { + return "Длинная enterprise/platform wrapper-формулировка: как probe она часто даёт ноль, лучше искать короткий рыночный язык спроса."; + } + + if ( + words >= 5 && + /(^|\s)(enterprise|корпоративн[а-яё]*|закрыт[а-яё]*\s+контур|внутренн[а-яё]*|предприяти[а-яё]*)(\s|$)/i.test( + normalizedPhrase + ) + ) { + return "Сконструированная enterprise-формулировка слишком длинная для первого Wordstat probe."; + } + return null; } @@ -611,6 +596,39 @@ function hasAwkwardWordstatProbeSyntax(phrase: string) { const normalizedPhrase = normalizePhrase(phrase); return ( + /(^|\s)запрос[а-яё]*\s+про\s+/i.test(normalizedPhrase) || + /(^|\s)и\s+замена(\s|$)/i.test(normalizedPhrase) || + /[a-z0-9-]+.*\s+и\s+.*[а-яё]/i.test(normalizedPhrase) || + /^(автоматизац[а-яё]*|цифровизац[а-яё]*)\s+(основн[а-яё]*|предложени[а-яё]*)(\s|$)/i.test( + normalizedPhrase + ) || + /^(процесс|процессы)\s+[а-яё]+\s+для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*)$/i.test( + normalizedPhrase + ) || + /^(процесс|процессы)\s+(тендер|закупк[а-яё]*|согласован[а-яё]*)(\s|$)/i.test(normalizedPhrase) || + /^(автоматизац[а-яё]*|систем[а-яё]*|платформ[а-яё]*|сервис[а-яё]*|решени[ея])\s+(процессы|данные|роли|доступы|безопасность|инструменты)\s+/i.test( + normalizedPhrase + ) || + /^(автоматизац[а-яё]*|систем[а-яё]*|платформ[а-яё]*|сервис[а-яё]*|решени[ея])\s+[а-яё]+(?:ые|ие)\s+[а-яё]+/i.test( + normalizedPhrase + ) || + /^(безопасность|роль|роли|доступы|данные|аналитика|инструменты)\s+(роль|роли|платформа|система|данные|аналитика|хранилища|инструменты)(\s|$)/i.test( + normalizedPhrase + ) || + /^(ops|hub|engine)\s+(платформ[а-яё]*|систем[а-яё]*|задач[а-яё]*|статус[а-яё]*|роль|роли)(\s|$)/i.test( + normalizedPhrase + ) || + /^(внешние\s+системы|внешняя\s+система)$/i.test(normalizedPhrase) || + /^(инженерные\s+данные|безопасность\s+роли|процессы\s+согласования)\s+(платформ[а-яё]*|систем[а-яё]*)$/i.test( + normalizedPhrase + ) || + /^(платформ[а-яё]*|систем[а-яё]*|сервис[а-яё]*|решени[ея])\s+[а-яё]+(?:ые|ие)\s+[а-яё]+/i.test( + normalizedPhrase + ) || + /^[а-яё]+(?:ые|ие)\s+[а-яё]+\s+(платформ[а-яё]*|систем[а-яё]*|сервис[а-яё]*|решени[ея])$/i.test( + normalizedPhrase + ) || + /^(enterprise\s+контур|контур\s+enterprise)$/i.test(normalizedPhrase) || /(^|\s)для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*)\s+для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*)/i.test( normalizedPhrase ) || @@ -618,7 +636,7 @@ function hasAwkwardWordstatProbeSyntax(phrase: string) { /(^|\s)для\s+(корпоративная|промышленная|цифровая|автоматизированная)\s+/i.test(normalizedPhrase) || /^(покупк[аи]|заказ|заказать|купить)\s*\/\s*(покупк[аи]|заказ|заказать|купить)/i.test(normalizedPhrase) || /(^|\s)(engineering\s+core|core\s+engineering)(\s|$)/i.test(normalizedPhrase) || - /^(применени[ея]|внедрени[ея]|разработк[аи]|создани[ея]|интеграц(?:ия|ии|ию|ией|ие)|покупк[аи]|заказ|заказать|купить)\s+(?!1c$|ai$|api$|bim$|bpm$|crm$|erp$|iot$|mcp$|ops$)[a-z0-9-]+$/i.test( + /^(применени[ея]|внедрени[ея]|автоматизац[а-яё]*|разработк[аи]|создани[ея]|интеграц(?:ия|ии|ию|ией|ие)|покупк[аи]|заказ|заказать|купить)\s+(?!1c$|ai$|api$|bim$|bpm$|crm$|erp$|iot$|llm$|mcp$|ops$)[a-z0-9-]+$/i.test( normalizedPhrase ) || /^(заказать|купить)\s+(digital\s+twin|ai|ии)\s+(платформ[а-яё]*|систем[а-яё]*|сервис[а-яё]*)/i.test( @@ -674,6 +692,7 @@ function isPlausibleWordstatProbePhrase(phrase: string) { !hasAwkwardWordstatProbeSyntax(normalizedPhrase) && !hasNonProductProbeIntentMismatch(normalizedPhrase) && !EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) && + !hasUnsafeWordstatProbeLatinToken(normalizedPhrase) && !hasUnsafeExpansionLatinToken(normalizedPhrase) ); } @@ -756,11 +775,13 @@ function selectWordstatProbeSeeds( options: { initialFamilyCounts?: Map; maxPerFamily?: number; + minScore?: number; } = {} ) { const reusablePhrases = getReusableWordstatEvidencePhraseSet(wordstat); const seenPhrases = new Set(); const maxPerFamily = options.maxPerFamily ?? WORDSTAT_PROBE_MAX_PER_PATTERN_FAMILY; + const minScore = options.minScore ?? Number.NEGATIVE_INFINITY; const familyCounts = new Map(options.initialFamilyCounts ?? []); const rankedCandidates = candidates .map((candidate, index) => ({ @@ -770,7 +791,7 @@ function selectWordstatProbeSeeds( score: scoreWordstatProbeCandidate(candidate) })) .filter((item) => { - if (!Number.isFinite(item.score) || !item.normalizedPhrase) { + if (!Number.isFinite(item.score) || item.score < minScore || !item.normalizedPhrase) { return false; } @@ -888,32 +909,36 @@ function selectMarketWideWordstatProbeSeeds( wordstat: WordstatEvidence, blockedPhrases: Set = new Set() ) { - const anchorProbeSeeds = selectWordstatProbeSeeds( - candidates.filter((candidate) => candidate.probeSource === "approved_anchor"), + const frontierProbeSeeds = selectWordstatProbeSeeds( + candidates.filter((candidate) => candidate.probeSource === "market_frontier_discovery"), wordstat, - DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_PROBE_BUDGET, - blockedPhrases + Math.min(DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET, DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET), + blockedPhrases, + { minScore: DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE } ); - const usedAnchorPhrases = new Set(anchorProbeSeeds.map((seed) => normalizePhrase(seed.phrase))); - const anchorFamilyCounts = getWordstatProbeFamilyCounts(anchorProbeSeeds); - const frontierBudget = Math.min( - DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET, - Math.max(0, DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - anchorProbeSeeds.length) - ); - const frontierProbeSeeds = - frontierBudget > 0 - ? selectWordstatProbeSeeds( - candidates.filter((candidate) => candidate.probeSource === "market_frontier_discovery"), - wordstat, - frontierBudget, - new Set([...blockedPhrases, ...usedAnchorPhrases]), - { initialFamilyCounts: anchorFamilyCounts, maxPerFamily: 2 } - ) - : []; const usedFrontierPhrases = new Set(frontierProbeSeeds.map((seed) => normalizePhrase(seed.phrase))); - const usedFamilyCounts = getWordstatProbeFamilyCounts([...anchorProbeSeeds, ...frontierProbeSeeds]); + const usedFrontierFamilyCounts = getWordstatProbeFamilyCounts(frontierProbeSeeds); + const anchorBudget = Math.min( + DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_FALLBACK_PROBE_BUDGET, + Math.max(0, DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - frontierProbeSeeds.length) + ); + const anchorProbeSeeds = + anchorBudget > 0 + ? selectWordstatProbeSeeds( + candidates.filter((candidate) => candidate.probeSource === "approved_anchor"), + wordstat, + anchorBudget, + new Set([...blockedPhrases, ...usedFrontierPhrases]), + { + initialFamilyCounts: usedFrontierFamilyCounts, + minScore: DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE + } + ) + : []; + const usedAnchorPhrases = new Set(anchorProbeSeeds.map((seed) => normalizePhrase(seed.phrase))); + const usedFamilyCounts = getWordstatProbeFamilyCounts([...frontierProbeSeeds, ...anchorProbeSeeds]); const remainingBudget = - DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - anchorProbeSeeds.length - frontierProbeSeeds.length; + DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - frontierProbeSeeds.length - anchorProbeSeeds.length; const derivedProbeSeeds = remainingBudget > 0 ? selectWordstatProbeSeeds( @@ -925,12 +950,15 @@ function selectMarketWideWordstatProbeSeeds( wordstat, remainingBudget, new Set([...blockedPhrases, ...usedAnchorPhrases, ...usedFrontierPhrases]), - { initialFamilyCounts: usedFamilyCounts } + { + initialFamilyCounts: usedFamilyCounts, + minScore: DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE + } ) : []; - return interleaveWordstatSeedGroups( - [frontierProbeSeeds, anchorProbeSeeds, derivedProbeSeeds], + return [...frontierProbeSeeds, ...anchorProbeSeeds, ...derivedProbeSeeds].slice( + 0, DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET ); } @@ -1018,6 +1046,13 @@ function hasSpecificExpansionFacet(phrase: string) { return meaningfulTerms.length > 0; } +function hasUnsafeWordstatProbeLatinToken(phrase: string) { + return phrase + .split(/\s+/) + .flatMap((token) => token.match(/[a-z0-9-]+/gi) ?? []) + .some((token) => !SAFE_WORDSTAT_PROBE_LATIN_TOKENS.has(token.toLowerCase())); +} + function hasUnsafeExpansionLatinToken(phrase: string) { return phrase .split(/\s+/) @@ -2008,6 +2043,7 @@ function getRejectedSummary(auditItems: WordstatProbePlanAuditItem[]) { function buildWordstatProbePlanWarnings(input: { candidateSourceCounts: Record; demandCollectionProfile: AnchorReviewContract["demandCollectionProfile"]; + frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null; frontierProbeSeedCount: number; probeBudget: number; rejectedSummary: Record, number>; @@ -2016,6 +2052,15 @@ function buildWordstatProbePlanWarnings(input: { const warnings: WordstatProbePlanWarning[] = []; const selectedCount = input.selectedProbes.length; + if (input.demandCollectionProfile === "market_wide" && input.frontierProviderMode === "deterministic_fallback") { + warnings.push({ + code: "frontier_fallback", + examples: [], + level: "warning", + message: "Текущий frontier plan построен deterministic fallback: нужен свежий model frontier перед реальным рыночным расширением." + }); + } + if ( input.demandCollectionProfile === "market_wide" && input.frontierProbeSeedCount === 0 && @@ -2091,12 +2136,20 @@ function buildWordstatProbePlanWarnings(input: { function buildWordstatProbePreviewNextActions(input: { blockers: string[]; + frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null; selectedCount: number; }) { if (input.blockers.length > 0) { return input.blockers; } + if (input.frontierProviderMode === "deterministic_fallback") { + return [ + "Preview-only: внешний Wordstat не вызван, лимит не потрачен.", + "Нужен свежий model frontier через keyword-analysis-workflow; market-wide Wordstat на fallback-плане не расходуется." + ]; + } + if (input.selectedCount === 0) { return ["Новых probe-фраз нет: память рынка считает текущие кандидаты уже requested/collected/suppressed или невалидными."]; } @@ -2171,6 +2224,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise= + (getNumericConstant(marketEnrichmentSource, "DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET") ?? 99) && + (getNumericConstant(marketEnrichmentSource, "DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE") ?? 0) >= 60 && + marketEnrichmentSource.includes("DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_FALLBACK_PROBE_BUDGET") && + marketEnrichmentSource.includes("SAFE_WORDSTAT_PROBE_LATIN_TOKENS") && + marketEnrichmentSource.includes("hasUnsafeWordstatProbeLatinToken") && + marketEnrichmentSource.includes("контур[а-яё]*") && + marketEnrichmentSource.includes('marketWidePlan.marketFrontierDiscovery.provider.mode === "deterministic_fallback"') && + marketEnrichmentSource.indexOf('candidate.probeSource === "market_frontier_discovery"') < + marketEnrichmentSource.indexOf('candidate.probeSource === "approved_anchor"') && + !marketEnrichmentSource.includes("interleaveWordstatSeedGroups"), + "Market-wide Wordstat selection must be frontier-first; approved anchors can only fill leftover fallback budget." +); assert.ok( marketEnrichmentSource.includes('schemaVersion: "wordstat-probe-plan-preview.v1"') && marketEnrichmentSource.includes("getWordstatProbePlanPreview") && @@ -78,6 +92,7 @@ assert.ok( marketEnrichmentSource.includes("modelCanCallWordstat: false") && marketEnrichmentSource.includes("buildWordstatProbePlanWarnings") && marketEnrichmentSource.includes("getProbePatternFamily") && + marketEnrichmentSource.includes("frontier_fallback") && marketEnrichmentSource.includes("weak_generic_probes") && marketEnrichmentSource.includes("low_diversity"), "Market enrichment must expose a read-only Wordstat probe preview and critic before spending quota." @@ -131,8 +146,11 @@ assert.ok( keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") && keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') && keywordAnalysisWorkflowSource.includes('taskType: "seo.market_frontier_discovery"') && + keywordAnalysisWorkflowSource.includes("getWordstatProbePlanPreview") && + keywordAnalysisWorkflowSource.includes('currentDiscovery.provider.mode === "deterministic_fallback"') && + keywordAnalysisWorkflowSource.includes('warning.code === "frontier_fallback"') && keywordAnalysisWorkflowSource.includes('contract.activeStepId === "demand_collection"'), - "Market-wide workflow must run/await frontier discovery before spending Wordstat requests." + "Market-wide workflow must run/await usable frontier discovery and block fallback plans before spending Wordstat requests." ); assert.ok( (getNumericConstant(keywordCleaningSource, "KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT") ?? 999) <= 160 &&