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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue