Harden market-wide Wordstat probe routing
This commit is contained in:
parent
4ff150b1e5
commit
cebc0d9b7d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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"]) {
|
||||
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<string, number>;
|
||||
maxPerFamily?: number;
|
||||
minScore?: number;
|
||||
} = {}
|
||||
) {
|
||||
const reusablePhrases = getReusableWordstatEvidencePhraseSet(wordstat);
|
||||
const seenPhrases = new Set<string>();
|
||||
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<string> = 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<WordstatProbeSourceKind, number>;
|
||||
demandCollectionProfile: AnchorReviewContract["demandCollectionProfile"];
|
||||
frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null;
|
||||
frontierProbeSeedCount: number;
|
||||
probeBudget: number;
|
||||
rejectedSummary: Record<Exclude<WordstatProbeAuditStatus, "selected">, 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<Wo
|
|||
let selectedSeeds: WordstatProbeCandidate[] = [];
|
||||
let frontierSourceTaskId: string | null = null;
|
||||
let frontierProbeSeedCount = 0;
|
||||
let frontierProviderMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null = null;
|
||||
|
||||
if (analysis && blockers.length === 0) {
|
||||
const blockedProbePhrases = new Set([...requestedPhrases, ...suppressedPhrases]);
|
||||
|
|
@ -2187,11 +2241,11 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
|
|||
candidates = marketWidePlan.candidates;
|
||||
frontierSourceTaskId = marketWidePlan.marketFrontierDiscovery.sourceTaskId;
|
||||
frontierProbeSeedCount = marketWidePlan.marketFrontierDiscovery.probeSeeds.length;
|
||||
selectedSeeds = selectMarketWideWordstatProbeSeeds(
|
||||
candidates,
|
||||
marketBeforeCollection.wordstat,
|
||||
blockedProbePhrases
|
||||
);
|
||||
frontierProviderMode = marketWidePlan.marketFrontierDiscovery.provider.mode;
|
||||
selectedSeeds =
|
||||
frontierProviderMode === "deterministic_fallback"
|
||||
? []
|
||||
: selectMarketWideWordstatProbeSeeds(candidates, marketBeforeCollection.wordstat, blockedProbePhrases);
|
||||
} else {
|
||||
candidates = anchorProbeSeeds;
|
||||
selectedSeeds = selectContextualWordstatProbeSeeds(
|
||||
|
|
@ -2217,6 +2271,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
|
|||
const warnings = buildWordstatProbePlanWarnings({
|
||||
candidateSourceCounts,
|
||||
demandCollectionProfile: anchorReview.demandCollectionProfile,
|
||||
frontierProviderMode,
|
||||
frontierProbeSeedCount,
|
||||
probeBudget,
|
||||
rejectedSummary,
|
||||
|
|
@ -2268,6 +2323,7 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise<Wo
|
|||
.slice(0, 24),
|
||||
nextActions: buildWordstatProbePreviewNextActions({
|
||||
blockers,
|
||||
frontierProviderMode,
|
||||
selectedCount: selectedProbes.length
|
||||
})
|
||||
};
|
||||
|
|
@ -2330,6 +2386,11 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
|
|||
marketBeforeCollection,
|
||||
projectId
|
||||
});
|
||||
|
||||
if (marketWidePlan.marketFrontierDiscovery.provider.mode === "deterministic_fallback") {
|
||||
return marketBeforeCollection;
|
||||
}
|
||||
|
||||
const frontierSeeds = selectMarketWideWordstatProbeSeeds(
|
||||
marketWidePlan.candidates,
|
||||
marketBeforeCollection.wordstat,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,20 @@ assert.ok(
|
|||
marketEnrichmentSource.includes("mapMarketFrontierProbeSeedsToWordstatCandidates"),
|
||||
"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(
|
||||
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 &&
|
||||
|
|
|
|||
Loading…
Reference in New Issue