Harden market-wide Wordstat probe routing

This commit is contained in:
DCCONSTRUCTIONS 2026-07-05 13:39:11 +03:00
parent 4ff150b1e5
commit cebc0d9b7d
3 changed files with 199 additions and 76 deletions

View File

@ -4,7 +4,11 @@ import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercial
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";
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js"; import {
getMarketEnrichmentContract,
getWordstatProbePlanPreview,
runMarketEnrichment
} from "../market/marketEnrichment.js";
import { getMarketFrontierDiscoveryContract } from "../market/marketFrontierDiscovery.js"; import { getMarketFrontierDiscoveryContract } from "../market/marketFrontierDiscovery.js";
import { getSeoModelProviderStatusContract, runSeoModelTask } from "../modelProvider/seoModelTaskRuns.js"; import { getSeoModelProviderStatusContract, runSeoModelTask } from "../modelProvider/seoModelTaskRuns.js";
import { updateProjectOntologyApproval } from "../ontology/projectOntologyRepository.js"; import { updateProjectOntologyApproval } from "../ontology/projectOntologyRepository.js";
@ -593,6 +597,30 @@ async function ensureMarketFrontierDiscoveryReady(
return { contract: waiting, ready: false }; 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 }; return { contract, ready: true };
} }
@ -810,10 +838,26 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string)
const latestWordstatJob = market.wordstat.latestJob; const latestWordstatJob = market.wordstat.latestJob;
const latestWordstatJobId = latestWordstatJob?.id ?? null; const latestWordstatJobId = latestWordstatJob?.id ?? null;
const hasFreshWordstatJob = Boolean(latestWordstatJobId && latestWordstatJobId !== wordstatJobIdBeforeCollection);
const hasFreshWordstatFailure = const hasFreshWordstatFailure =
latestWordstatJob?.status === "failed" && latestWordstatJobId !== wordstatJobIdBeforeCollection; latestWordstatJob?.status === "failed" && latestWordstatJobId !== wordstatJobIdBeforeCollection;
const hasReusableWordstatEvidence = market.wordstat.summary.resultCount > 0; 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)) { if (hasFreshWordstatFailure || (latestWordstatJob?.status === "failed" && !hasReusableWordstatEvidence)) {
await failWorkflow( await failWorkflow(
contract, contract,

View File

@ -308,13 +308,15 @@ const EXPANSION_QUERY_BLOCKLIST = [
/\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i /\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_BASE_RUNS = 1;
const DEMAND_COLLECTION_MAX_MARKET_WIDE_RUNS = 1; const DEMAND_COLLECTION_MAX_MARKET_WIDE_RUNS = 1;
const DEMAND_COLLECTION_CONTEXTUAL_PROBE_BUDGET = 8; const DEMAND_COLLECTION_CONTEXTUAL_PROBE_BUDGET = 8;
const DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET = 12; const DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET = 12;
const DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_PROBE_BUDGET = 2; const DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_FALLBACK_PROBE_BUDGET = 2;
const DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET = 8; 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_PATTERN_FAMILY = 4;
const WORDSTAT_PROBE_MAX_PER_CLUSTER = 8; const WORDSTAT_PROBE_MAX_PER_CLUSTER = 8;
@ -359,6 +361,7 @@ type WordstatProbePlanAuditItem = WordstatProbePlanSelectedItem & {
type WordstatProbePlanWarning = { type WordstatProbePlanWarning = {
code: code:
| "frontier_fallback"
| "frontier_missing" | "frontier_missing"
| "high_reuse_blocking" | "high_reuse_blocking"
| "low_diversity" | "low_diversity"
@ -429,43 +432,6 @@ function unique(values: string[]) {
return result; return result;
} }
function interleaveWordstatSeedGroups<T extends WordstatSeedCandidate>(groups: T[][], limit: number) {
const result: T[] = [];
const seenPhrases = new Set<string>();
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"]) { function getPriorityProbeWeight(priority: WordstatSeedCandidate["priority"]) {
if (priority === "high") return 32; if (priority === "high") return 32;
if (priority === "medium") return 20; if (priority === "medium") return 20;
@ -537,20 +503,39 @@ function hasNonProductProbeIntentMismatch(phrase: string) {
function getWeakGenericProbeReason(phrase: string) { function getWeakGenericProbeReason(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase); const normalizedPhrase = normalizePhrase(phrase);
const words = wordCount(normalizedPhrase); const words = wordCount(normalizedPhrase);
const genericObject = /(агент[а-яё]*|данн[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)/i; const genericObject = /(агент[а-яё]*|данн[а-яё]*|контур[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)/i;
if (words <= 2 && genericObject.test(normalizedPhrase) && !hasMarketQualifier(normalizedPhrase)) { if (words <= 2 && genericObject.test(normalizedPhrase) && !hasMarketQualifier(normalizedPhrase)) {
return "Короткая generic-фраза без рыночного/отраслевого квалификатора."; return "Короткая generic-фраза без рыночного/отраслевого квалификатора.";
} }
if ( if (
/^(применени[ея]|внедрени[ея]|разработк[аи]|создани[ея]|интеграц[ияеи])\s+(агент[а-яё]*|данн[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)$/i.test( /^(применени[ея]|внедрени[ея]|разработк[аи]|создани[ея]|интеграц[ияеи]|заказать|заказ|купить)\s+(агент[а-яё]*|данн[а-яё]*|контур[а-яё]*|процесс[а-яё]*|задач[а-яё]*|систем[а-яё]*|платформ[а-яё]*)(\s+для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*))?$/i.test(
normalizedPhrase normalizedPhrase
) )
) { ) {
return "Action + generic object без конкретного рынка, продукта или buyer intent."; 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; return null;
} }
@ -611,6 +596,39 @@ function hasAwkwardWordstatProbeSyntax(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase); const normalizedPhrase = normalizePhrase(phrase);
return ( 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( /(^|\s)для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*)\s+для\s+(бизнеса|предприяти[а-яё]*|компани[а-яё]*)/i.test(
normalizedPhrase normalizedPhrase
) || ) ||
@ -618,7 +636,7 @@ function hasAwkwardWordstatProbeSyntax(phrase: string) {
/(^|\s)для\s+(корпоративная|промышленная|цифровая|автоматизированная)\s+/i.test(normalizedPhrase) || /(^|\s)для\s+(корпоративная|промышленная|цифровая|автоматизированная)\s+/i.test(normalizedPhrase) ||
/^(покупк[аи]|заказ|заказать|купить)\s*\/\s*(покупк[аи]|заказ|заказать|купить)/i.test(normalizedPhrase) || /^(покупк[аи]|заказ|заказать|купить)\s*\/\s*(покупк[аи]|заказ|заказать|купить)/i.test(normalizedPhrase) ||
/(^|\s)(engineering\s+core|core\s+engineering)(\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 normalizedPhrase
) || ) ||
/^(заказать|купить)\s+(digital\s+twin|ai|ии)\s+(платформ[а-яё]*|систем[а-яё]*|сервис[а-яё]*)/i.test( /^(заказать|купить)\s+(digital\s+twin|ai|ии)\s+(платформ[а-яё]*|систем[а-яё]*|сервис[а-яё]*)/i.test(
@ -674,6 +692,7 @@ function isPlausibleWordstatProbePhrase(phrase: string) {
!hasAwkwardWordstatProbeSyntax(normalizedPhrase) && !hasAwkwardWordstatProbeSyntax(normalizedPhrase) &&
!hasNonProductProbeIntentMismatch(normalizedPhrase) && !hasNonProductProbeIntentMismatch(normalizedPhrase) &&
!EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) && !EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) &&
!hasUnsafeWordstatProbeLatinToken(normalizedPhrase) &&
!hasUnsafeExpansionLatinToken(normalizedPhrase) !hasUnsafeExpansionLatinToken(normalizedPhrase)
); );
} }
@ -756,11 +775,13 @@ function selectWordstatProbeSeeds(
options: { options: {
initialFamilyCounts?: Map<string, number>; initialFamilyCounts?: Map<string, number>;
maxPerFamily?: number; maxPerFamily?: number;
minScore?: number;
} = {} } = {}
) { ) {
const reusablePhrases = getReusableWordstatEvidencePhraseSet(wordstat); const reusablePhrases = getReusableWordstatEvidencePhraseSet(wordstat);
const seenPhrases = new Set<string>(); const seenPhrases = new Set<string>();
const maxPerFamily = options.maxPerFamily ?? WORDSTAT_PROBE_MAX_PER_PATTERN_FAMILY; const maxPerFamily = options.maxPerFamily ?? WORDSTAT_PROBE_MAX_PER_PATTERN_FAMILY;
const minScore = options.minScore ?? Number.NEGATIVE_INFINITY;
const familyCounts = new Map(options.initialFamilyCounts ?? []); const familyCounts = new Map(options.initialFamilyCounts ?? []);
const rankedCandidates = candidates const rankedCandidates = candidates
.map((candidate, index) => ({ .map((candidate, index) => ({
@ -770,7 +791,7 @@ function selectWordstatProbeSeeds(
score: scoreWordstatProbeCandidate(candidate) score: scoreWordstatProbeCandidate(candidate)
})) }))
.filter((item) => { .filter((item) => {
if (!Number.isFinite(item.score) || !item.normalizedPhrase) { if (!Number.isFinite(item.score) || item.score < minScore || !item.normalizedPhrase) {
return false; return false;
} }
@ -888,32 +909,36 @@ function selectMarketWideWordstatProbeSeeds(
wordstat: WordstatEvidence, wordstat: WordstatEvidence,
blockedPhrases: Set<string> = new Set() blockedPhrases: Set<string> = new Set()
) { ) {
const anchorProbeSeeds = selectWordstatProbeSeeds( const frontierProbeSeeds = selectWordstatProbeSeeds(
candidates.filter((candidate) => candidate.probeSource === "approved_anchor"), candidates.filter((candidate) => candidate.probeSource === "market_frontier_discovery"),
wordstat, wordstat,
DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_PROBE_BUDGET, Math.min(DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET, DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET),
blockedPhrases 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 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 = const remainingBudget =
DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - anchorProbeSeeds.length - frontierProbeSeeds.length; DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET - frontierProbeSeeds.length - anchorProbeSeeds.length;
const derivedProbeSeeds = const derivedProbeSeeds =
remainingBudget > 0 remainingBudget > 0
? selectWordstatProbeSeeds( ? selectWordstatProbeSeeds(
@ -925,12 +950,15 @@ function selectMarketWideWordstatProbeSeeds(
wordstat, wordstat,
remainingBudget, remainingBudget,
new Set([...blockedPhrases, ...usedAnchorPhrases, ...usedFrontierPhrases]), new Set([...blockedPhrases, ...usedAnchorPhrases, ...usedFrontierPhrases]),
{ initialFamilyCounts: usedFamilyCounts } {
initialFamilyCounts: usedFamilyCounts,
minScore: DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE
}
) )
: []; : [];
return interleaveWordstatSeedGroups( return [...frontierProbeSeeds, ...anchorProbeSeeds, ...derivedProbeSeeds].slice(
[frontierProbeSeeds, anchorProbeSeeds, derivedProbeSeeds], 0,
DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET
); );
} }
@ -1018,6 +1046,13 @@ function hasSpecificExpansionFacet(phrase: string) {
return meaningfulTerms.length > 0; 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) { function hasUnsafeExpansionLatinToken(phrase: string) {
return phrase return phrase
.split(/\s+/) .split(/\s+/)
@ -2008,6 +2043,7 @@ function getRejectedSummary(auditItems: WordstatProbePlanAuditItem[]) {
function buildWordstatProbePlanWarnings(input: { function buildWordstatProbePlanWarnings(input: {
candidateSourceCounts: Record<WordstatProbeSourceKind, number>; candidateSourceCounts: Record<WordstatProbeSourceKind, number>;
demandCollectionProfile: AnchorReviewContract["demandCollectionProfile"]; demandCollectionProfile: AnchorReviewContract["demandCollectionProfile"];
frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null;
frontierProbeSeedCount: number; frontierProbeSeedCount: number;
probeBudget: number; probeBudget: number;
rejectedSummary: Record<Exclude<WordstatProbeAuditStatus, "selected">, number>; rejectedSummary: Record<Exclude<WordstatProbeAuditStatus, "selected">, number>;
@ -2016,6 +2052,15 @@ function buildWordstatProbePlanWarnings(input: {
const warnings: WordstatProbePlanWarning[] = []; const warnings: WordstatProbePlanWarning[] = [];
const selectedCount = input.selectedProbes.length; 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 ( if (
input.demandCollectionProfile === "market_wide" && input.demandCollectionProfile === "market_wide" &&
input.frontierProbeSeedCount === 0 && input.frontierProbeSeedCount === 0 &&
@ -2091,12 +2136,20 @@ function buildWordstatProbePlanWarnings(input: {
function buildWordstatProbePreviewNextActions(input: { function buildWordstatProbePreviewNextActions(input: {
blockers: string[]; blockers: string[];
frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null;
selectedCount: number; selectedCount: number;
}) { }) {
if (input.blockers.length > 0) { if (input.blockers.length > 0) {
return input.blockers; 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) { if (input.selectedCount === 0) {
return ["Новых probe-фраз нет: память рынка считает текущие кандидаты уже requested/collected/suppressed или невалидными."]; return ["Новых probe-фраз нет: память рынка считает текущие кандидаты уже requested/collected/suppressed или невалидными."];
} }
@ -2171,6 +2224,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
let selectedSeeds: WordstatProbeCandidate[] = []; let selectedSeeds: WordstatProbeCandidate[] = [];
let frontierSourceTaskId: string | null = null; let frontierSourceTaskId: string | null = null;
let frontierProbeSeedCount = 0; let frontierProbeSeedCount = 0;
let frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null = null;
if (analysis && blockers.length === 0) { if (analysis && blockers.length === 0) {
const blockedProbePhrases = new Set([...requestedPhrases, ...suppressedPhrases]); const blockedProbePhrases = new Set([...requestedPhrases, ...suppressedPhrases]);
@ -2187,11 +2241,11 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
candidates = marketWidePlan.candidates; candidates = marketWidePlan.candidates;
frontierSourceTaskId = marketWidePlan.marketFrontierDiscovery.sourceTaskId; frontierSourceTaskId = marketWidePlan.marketFrontierDiscovery.sourceTaskId;
frontierProbeSeedCount = marketWidePlan.marketFrontierDiscovery.probeSeeds.length; frontierProbeSeedCount = marketWidePlan.marketFrontierDiscovery.probeSeeds.length;
selectedSeeds = selectMarketWideWordstatProbeSeeds( frontierProviderMode = marketWidePlan.marketFrontierDiscovery.provider.mode;
candidates, selectedSeeds =
marketBeforeCollection.wordstat, frontierProviderMode === "deterministic_fallback"
blockedProbePhrases ? []
); : selectMarketWideWordstatProbeSeeds(candidates, marketBeforeCollection.wordstat, blockedProbePhrases);
} else { } else {
candidates = anchorProbeSeeds; candidates = anchorProbeSeeds;
selectedSeeds = selectContextualWordstatProbeSeeds( selectedSeeds = selectContextualWordstatProbeSeeds(
@ -2217,6 +2271,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
const warnings = buildWordstatProbePlanWarnings({ const warnings = buildWordstatProbePlanWarnings({
candidateSourceCounts, candidateSourceCounts,
demandCollectionProfile: anchorReview.demandCollectionProfile, demandCollectionProfile: anchorReview.demandCollectionProfile,
frontierProviderMode,
frontierProbeSeedCount, frontierProbeSeedCount,
probeBudget, probeBudget,
rejectedSummary, rejectedSummary,
@ -2268,6 +2323,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
.slice(0, 24), .slice(0, 24),
nextActions: buildWordstatProbePreviewNextActions({ nextActions: buildWordstatProbePreviewNextActions({
blockers, blockers,
frontierProviderMode,
selectedCount: selectedProbes.length selectedCount: selectedProbes.length
}) })
}; };
@ -2330,6 +2386,11 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
marketBeforeCollection, marketBeforeCollection,
projectId projectId
}); });
if (marketWidePlan.marketFrontierDiscovery.provider.mode === "deterministic_fallback") {
return marketBeforeCollection;
}
const frontierSeeds = selectMarketWideWordstatProbeSeeds( const frontierSeeds = selectMarketWideWordstatProbeSeeds(
marketWidePlan.candidates, marketWidePlan.candidates,
marketBeforeCollection.wordstat, marketBeforeCollection.wordstat,

View File

@ -71,6 +71,20 @@ assert.ok(
marketEnrichmentSource.includes("mapMarketFrontierProbeSeedsToWordstatCandidates"), marketEnrichmentSource.includes("mapMarketFrontierProbeSeedsToWordstatCandidates"),
"Market enrichment must route market frontier probe seeds into the ranked Wordstat seed queue." "Market enrichment must route market frontier probe seeds into the ranked Wordstat seed queue."
); );
assert.ok(
(getNumericConstant(marketEnrichmentSource, "DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET") ?? 0) >=
(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( assert.ok(
marketEnrichmentSource.includes('schemaVersion: "wordstat-probe-plan-preview.v1"') && marketEnrichmentSource.includes('schemaVersion: "wordstat-probe-plan-preview.v1"') &&
marketEnrichmentSource.includes("getWordstatProbePlanPreview") && marketEnrichmentSource.includes("getWordstatProbePlanPreview") &&
@ -78,6 +92,7 @@ assert.ok(
marketEnrichmentSource.includes("modelCanCallWordstat: false") && marketEnrichmentSource.includes("modelCanCallWordstat: false") &&
marketEnrichmentSource.includes("buildWordstatProbePlanWarnings") && marketEnrichmentSource.includes("buildWordstatProbePlanWarnings") &&
marketEnrichmentSource.includes("getProbePatternFamily") && marketEnrichmentSource.includes("getProbePatternFamily") &&
marketEnrichmentSource.includes("frontier_fallback") &&
marketEnrichmentSource.includes("weak_generic_probes") && marketEnrichmentSource.includes("weak_generic_probes") &&
marketEnrichmentSource.includes("low_diversity"), marketEnrichmentSource.includes("low_diversity"),
"Market enrichment must expose a read-only Wordstat probe preview and critic before spending quota." "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("ensureMarketFrontierDiscoveryReady") &&
keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') && keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&
keywordAnalysisWorkflowSource.includes('taskType: "seo.market_frontier_discovery"') && 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"'), 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( assert.ok(
(getNumericConstant(keywordCleaningSource, "KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT") ?? 999) <= 160 && (getNumericConstant(keywordCleaningSource, "KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT") ?? 999) <= 160 &&