Compare commits
3 Commits
284c72d75c
...
c3a946879b
| Author | SHA1 | Date |
|---|---|---|
|
|
c3a946879b | |
|
|
2a0e72543e | |
|
|
a1d1a4980f |
|
|
@ -19,7 +19,7 @@ WORDSTAT_PROVIDER=disabled
|
|||
# WORDSTAT_REGION_IDS=225
|
||||
# WORDSTAT_DEVICES=DEVICE_ALL
|
||||
# WORDSTAT_NUM_PHRASES=50
|
||||
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
||||
# WORDSTAT_MAX_SEEDS_PER_RUN=12
|
||||
|
||||
SERP_PROVIDER=disabled
|
||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,246 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type NdcGlassSelectOption<T extends string = string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
actions?: Array<{
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type NdcGlassSelectProps<T extends string = string> = {
|
||||
value: T;
|
||||
options: NdcGlassSelectOption<T>[];
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
menuClassName?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
};
|
||||
|
||||
export function NdcGlassSelect<T extends string = string>({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
className,
|
||||
menuClassName,
|
||||
disabled = false,
|
||||
onChange
|
||||
}: NdcGlassSelectProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const selectId = useMemo(() => `ndc-select-${Math.random().toString(16).slice(2)}`, []);
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
const updateMenuRect = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = root.getBoundingClientRect();
|
||||
const valueRect = root.querySelector<HTMLElement>(".nodedc-select__value")?.getBoundingClientRect();
|
||||
const menuMaxHeight = 232;
|
||||
const bottomTop = rect.bottom + 6;
|
||||
const top =
|
||||
bottomTop + menuMaxHeight > window.innerHeight - 12
|
||||
? Math.max(12, rect.top - menuMaxHeight - 6)
|
||||
: bottomTop;
|
||||
|
||||
setMenuRect({
|
||||
top,
|
||||
left: valueRect?.left ?? rect.left,
|
||||
width: Math.max(180, valueRect?.width ?? rect.width)
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateMenuRect();
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const root = rootRef.current;
|
||||
const menu = menuRef.current;
|
||||
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
root &&
|
||||
!root.contains(event.target) &&
|
||||
(!menu || !menu.contains(event.target))
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleOtherSelectOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ id?: string }>).detail;
|
||||
|
||||
if (detail?.id !== selectId) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleViewportChange = () => updateMenuRect();
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
};
|
||||
}, [open, selectId]);
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen((current) => {
|
||||
if (!current) {
|
||||
updateMenuRect();
|
||||
window.dispatchEvent(new CustomEvent("nodedc-select-open", { detail: { id: selectId } }));
|
||||
}
|
||||
|
||||
return !current;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={["nodedc-select", className].filter(Boolean).join(" ")}>
|
||||
<div className="nodedc-select__control">
|
||||
<div className="nodedc-select__value">{selected?.label}</div>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="nodedc-select__toggle"
|
||||
disabled={disabled}
|
||||
onClick={openMenu}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className="nodedc-select__chevron" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={["nodedc-dropdown-surface nodedc-select__menu", menuClassName].filter(Boolean).join(" ")}
|
||||
role="listbox"
|
||||
style={{
|
||||
left: menuRect.left,
|
||||
position: "fixed",
|
||||
top: menuRect.top,
|
||||
width: menuRect.width
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
aria-disabled={option.disabled === true}
|
||||
aria-selected={option.value === value}
|
||||
className="nodedc-dropdown-option nodedc-select__option"
|
||||
data-disabled={option.disabled === true ? "true" : undefined}
|
||||
data-selected={option.value === value ? "true" : undefined}
|
||||
data-value={option.value}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
tabIndex={option.disabled ? -1 : 0}
|
||||
>
|
||||
<span className="nodedc-select__option-label">{option.label}</span>
|
||||
{option.actions?.length ? (
|
||||
<span className="nodedc-select__option-actions" aria-hidden={false}>
|
||||
{option.actions.map((action) => (
|
||||
<span
|
||||
aria-label={action.ariaLabel}
|
||||
className="nodedc-select__option-action"
|
||||
data-disabled={action.disabled ? "true" : undefined}
|
||||
key={action.ariaLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!action.disabled) {
|
||||
action.onClick();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!action.disabled) {
|
||||
action.onClick();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={action.disabled ? -1 : 0}
|
||||
title={action.title ?? action.ariaLabel}
|
||||
>
|
||||
{action.icon}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -336,6 +336,46 @@ export type SemanticAnalysisRun = {
|
|||
wordCount: number;
|
||||
matchedClusters: string[];
|
||||
}>;
|
||||
textCorpus: {
|
||||
schemaVersion: "seo-text-corpus.v1";
|
||||
extraction: {
|
||||
chunkCount: number;
|
||||
documentCount: number;
|
||||
mode: "clean_visible_text";
|
||||
sourceDocumentCount: number;
|
||||
totalChars: number;
|
||||
totalWords: number;
|
||||
};
|
||||
documents: Array<{
|
||||
chunkIds: string[];
|
||||
id: string;
|
||||
kind: "page" | "content_json";
|
||||
scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source";
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
textCharCount: number;
|
||||
title: string;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
wordCount: number;
|
||||
}>;
|
||||
chunks: Array<{
|
||||
charEnd: number;
|
||||
charStart: number;
|
||||
chunkIndex: number;
|
||||
documentId: string;
|
||||
id: string;
|
||||
kind: "page" | "content_json";
|
||||
scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source";
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
text: string;
|
||||
title: string;
|
||||
totalChunks: number;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
}>;
|
||||
};
|
||||
legacyFindings: Array<{
|
||||
markerId: string;
|
||||
label: string;
|
||||
|
|
@ -556,7 +596,7 @@ export type SeoContextReviewRun = {
|
|||
runId: string;
|
||||
status: string;
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: true;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -616,7 +656,7 @@ export type SeoNormalizationContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "ready";
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -737,9 +777,11 @@ export type AnchorReviewDecision = {
|
|||
sendToSerp: boolean;
|
||||
sendToWordstat: boolean;
|
||||
source: "human" | "model" | "system";
|
||||
status: "approved" | "disabled" | "pending";
|
||||
status: "approved" | "deleted" | "disabled" | "pending";
|
||||
};
|
||||
|
||||
export type DemandCollectionProfile = "contextual" | "market_wide";
|
||||
|
||||
export type AnchorReviewContract = {
|
||||
schemaVersion: "anchor-review.v1";
|
||||
projectId: string;
|
||||
|
|
@ -747,6 +789,7 @@ export type AnchorReviewContract = {
|
|||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
state: "approved" | "empty" | "needs_review" | "not_ready";
|
||||
demandCollectionProfile: DemandCollectionProfile;
|
||||
source: {
|
||||
normalizationGeneratedAt: string | null;
|
||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||
|
|
@ -794,6 +837,13 @@ export type AnchorReviewContract = {
|
|||
};
|
||||
decision: AnchorReviewDecision;
|
||||
}>;
|
||||
deletedAnchors: Array<{
|
||||
deletedAt: string | null;
|
||||
id: string;
|
||||
key: string;
|
||||
phrase: string;
|
||||
reason: string;
|
||||
}>;
|
||||
wordstatQueue: Array<{
|
||||
id: string;
|
||||
anchorId: string;
|
||||
|
|
@ -845,6 +895,7 @@ export type MarketEnrichmentContract = {
|
|||
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
||||
normalization: SeoNormalizationContract;
|
||||
anchorReview: AnchorReviewContract;
|
||||
demandCollectionProfile: DemandCollectionProfile;
|
||||
readiness: {
|
||||
anchorApprovedCount: number;
|
||||
anchorPendingCount: number;
|
||||
|
|
@ -995,6 +1046,115 @@ export type MarketEnrichmentContract = {
|
|||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type WordstatProbePlanSource =
|
||||
| "approved_anchor"
|
||||
| "commercial_demand"
|
||||
| "market_anchor_expansion"
|
||||
| "market_frontier_discovery"
|
||||
| "previous_expansion";
|
||||
|
||||
export type WordstatProbePlanRejectedStatus =
|
||||
| "already_collected"
|
||||
| "already_requested"
|
||||
| "duplicate_candidate"
|
||||
| "invalid_phrase"
|
||||
| "not_selected_budget_or_balance"
|
||||
| "suppressed";
|
||||
|
||||
export type WordstatProbePlanSelectedProbe = {
|
||||
phrase: string;
|
||||
normalizedPhrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
source: WordstatProbePlanSource;
|
||||
score: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type WordstatProbePlanWarning = {
|
||||
code:
|
||||
| "frontier_missing"
|
||||
| "high_reuse_blocking"
|
||||
| "low_diversity"
|
||||
| "low_selected_count"
|
||||
| "weak_generic_probes";
|
||||
level: "info" | "warning";
|
||||
message: string;
|
||||
examples: string[];
|
||||
};
|
||||
|
||||
export type WordstatProbePlanPreviewContract = {
|
||||
schemaVersion: "wordstat-probe-plan-preview.v1";
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
generatedAt: string;
|
||||
demandCollectionProfile: DemandCollectionProfile;
|
||||
orchestration: {
|
||||
mode: "backend_orchestrated_contract_pipeline";
|
||||
executionMode: "preview_only";
|
||||
modelCanCallWordstat: false;
|
||||
steps: string[];
|
||||
};
|
||||
provider: {
|
||||
wordstatStatus: MarketEnrichmentContract["providers"][number]["status"];
|
||||
wordstatMode: string;
|
||||
};
|
||||
readiness: {
|
||||
canExecute: boolean;
|
||||
blockers: string[];
|
||||
probeBudget: number;
|
||||
candidateCount: number;
|
||||
selectedCount: number;
|
||||
};
|
||||
memory: {
|
||||
requestedPhraseCount: number;
|
||||
collectedPhraseCount: number;
|
||||
suppressedPhraseCount: number;
|
||||
frontierSourceTaskId: string | null;
|
||||
frontierProbeSeedCount: number;
|
||||
};
|
||||
selectedProbes: WordstatProbePlanSelectedProbe[];
|
||||
warnings: WordstatProbePlanWarning[];
|
||||
candidateSourceCounts: Record<WordstatProbePlanSource, number>;
|
||||
rejectedSummary: Record<WordstatProbePlanRejectedStatus, number>;
|
||||
rejectedSamples: Array<WordstatProbePlanSelectedProbe & {
|
||||
status: WordstatProbePlanRejectedStatus | "selected";
|
||||
statusLabel: string;
|
||||
}>;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type KeywordAnalysisWorkflowContract = {
|
||||
schemaVersion: "keyword-analysis-workflow.v1";
|
||||
runId: string;
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
generatedAt: string;
|
||||
status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
|
||||
statusLabel: string;
|
||||
activeStepId: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis" | null;
|
||||
activeStepLabel: string | null;
|
||||
summary: string;
|
||||
reason: string | null;
|
||||
steps: Array<{
|
||||
id: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis";
|
||||
label: string;
|
||||
status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
|
||||
detail: string;
|
||||
reason: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}>;
|
||||
artifacts: {
|
||||
keywordCleaningRunId: string | null;
|
||||
marketGeneratedAt: string | null;
|
||||
modelTaskRunId: string | null;
|
||||
wordstatJobId: string | null;
|
||||
};
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type KeywordCleaningItem = {
|
||||
id: string;
|
||||
phrase: string;
|
||||
|
|
@ -1029,7 +1189,7 @@ export type KeywordCleaningContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "ready";
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -1093,8 +1253,11 @@ export type KeywordCleaningContract = {
|
|||
};
|
||||
|
||||
export type SeoModelTaskType =
|
||||
| "seo.business_synthesis"
|
||||
| "seo.commercial_demand"
|
||||
| "seo.context_review"
|
||||
| "seo.keyword_cleaning"
|
||||
| "seo.market_frontier_discovery"
|
||||
| "seo.normalization"
|
||||
| "seo.serp_interpretation"
|
||||
| "seo.strategy_quality_review"
|
||||
|
|
@ -1167,7 +1330,10 @@ export type SeoModelTaskPersistedResult = {
|
|||
schemaVersion: "seo-model-persisted-result.v1";
|
||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||
runType:
|
||||
| "seo_business_synthesis"
|
||||
| "seo_commercial_demand"
|
||||
| "keyword_cleaning"
|
||||
| "seo_context_review"
|
||||
| "seo_model_task"
|
||||
| "seo_normalization"
|
||||
| "seo_strategy_quality_review"
|
||||
|
|
@ -1322,7 +1488,7 @@ export type SerpInterpretationContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "ready";
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: true;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -1466,6 +1632,31 @@ export type KeywordMapRoleOverrideInput = {
|
|||
role: KeywordMapRole;
|
||||
};
|
||||
|
||||
export type KeywordContextSnapshotSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
source: "expansion_checkpoint" | "manual_save";
|
||||
createdAt: string;
|
||||
semanticRunId: string | null;
|
||||
keywordMapGeneratedAt: string | null;
|
||||
keywordCount: number;
|
||||
keywordPhrases: string[];
|
||||
suppressedCount: number;
|
||||
roleOverrideCount: number;
|
||||
};
|
||||
|
||||
export type KeywordContextSnapshotInput = {
|
||||
label?: string;
|
||||
note?: string;
|
||||
source?: "expansion_checkpoint" | "manual_save";
|
||||
curation?: {
|
||||
roleOverrides?: KeywordMapRoleOverrideInput[];
|
||||
suppressedItemIds?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>;
|
||||
|
||||
export type SeoStrategyContract = {
|
||||
schemaVersion: "seo-strategy.v1";
|
||||
projectId: string;
|
||||
|
|
@ -1650,6 +1841,16 @@ export type SeoStrategyContract = {
|
|||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type StrategyPhaseAction = {
|
||||
role?: string;
|
||||
title: string;
|
||||
action?: string;
|
||||
reason?: string;
|
||||
priority?: "high" | "low" | "medium";
|
||||
targetPath?: string | null;
|
||||
evidenceRefs?: string[];
|
||||
};
|
||||
|
||||
export type StrategySynthesisContract = {
|
||||
schemaVersion: "seo-strategy-synthesis.v1";
|
||||
projectId: string;
|
||||
|
|
@ -1658,7 +1859,7 @@ export type StrategySynthesisContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "ready";
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: true;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -1697,7 +1898,13 @@ export type StrategySynthesisContract = {
|
|||
promotionSignals: string[];
|
||||
phaseRoadmap: SeoStrategyContract["phasePlan"];
|
||||
};
|
||||
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||
phaseStrategies: Array<
|
||||
Omit<SeoStrategyContract["phaseStrategies"][number], "doNow" | "hold" | "prepareNext"> & {
|
||||
doNow: Array<string | StrategyPhaseAction>;
|
||||
hold: Array<string | StrategyPhaseAction>;
|
||||
prepareNext: Array<string | StrategyPhaseAction>;
|
||||
}
|
||||
>;
|
||||
recommendedPath: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -1759,7 +1966,7 @@ export type StrategyQualityReviewContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "ready";
|
||||
provider: {
|
||||
mode: "codex_manual" | "deterministic_fallback";
|
||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
modelRequired: true;
|
||||
message: string;
|
||||
};
|
||||
|
|
@ -2874,6 +3081,16 @@ export async function fetchLatestMarketEnrichment(projectId: string) {
|
|||
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
|
||||
}
|
||||
|
||||
export async function fetchWordstatProbePlanPreview(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/market-enrichment/probe-preview`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось построить Wordstat probe preview: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ probePreview: WordstatProbePlanPreviewContract }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestSeoContextReview(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`);
|
||||
|
||||
|
|
@ -2981,9 +3198,26 @@ export async function fetchLatestAnchorReview(projectId: string) {
|
|||
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
||||
}
|
||||
|
||||
export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) {
|
||||
export async function saveAnchorReviewDecisions(
|
||||
projectId: string,
|
||||
decisions?: AnchorReviewDecisionInput[],
|
||||
demandCollectionProfile?: DemandCollectionProfile
|
||||
) {
|
||||
const payload: {
|
||||
decisions?: AnchorReviewDecisionInput[];
|
||||
demandCollectionProfile?: DemandCollectionProfile;
|
||||
} = {};
|
||||
|
||||
if (decisions) {
|
||||
payload.decisions = decisions;
|
||||
}
|
||||
|
||||
if (demandCollectionProfile) {
|
||||
payload.demandCollectionProfile = demandCollectionProfile;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, {
|
||||
body: JSON.stringify({ decisions }),
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
|
|
@ -3051,6 +3285,28 @@ export async function runMarketEnrichment(projectId: string) {
|
|||
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestKeywordAnalysisWorkflow(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow/latest`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось загрузить workflow анализа ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract | null }>;
|
||||
}
|
||||
|
||||
export async function startKeywordAnalysisWorkflow(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow`, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось запустить workflow анализа ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestYandexEvidence(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`);
|
||||
|
||||
|
|
@ -3168,6 +3424,7 @@ export async function approveKeywordMapDecisions(
|
|||
input?: {
|
||||
itemIds?: string[];
|
||||
roleOverrides?: KeywordMapRoleOverrideInput[];
|
||||
suppressedItemIds?: string[];
|
||||
}
|
||||
) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
|
||||
|
|
@ -3190,6 +3447,78 @@ export async function approveKeywordMapDecisions(
|
|||
}>;
|
||||
}
|
||||
|
||||
export async function fetchKeywordContextSnapshots(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось загрузить snapshot-профили ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ snapshots: KeywordContextSnapshotSummary[] }>;
|
||||
}
|
||||
|
||||
export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось сохранить snapshot ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
|
||||
}
|
||||
|
||||
export async function renameKeywordContextSnapshot(projectId: string, snapshotId: string, label: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ label })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось переименовать snapshot ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
|
||||
}
|
||||
|
||||
export async function updateKeywordContextSnapshotCuration(
|
||||
projectId: string,
|
||||
snapshotId: string,
|
||||
curation: KeywordContextSnapshotCurationInput
|
||||
) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}/curation`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(curation)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось сохранить раскладку ключей: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
|
||||
}
|
||||
|
||||
export async function deleteKeywordContextSnapshot(projectId: string, snapshotId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось удалить snapshot ключей: ${response.status}`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLatestRewritePlan(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,49 @@
|
|||
import { StrictMode } from "react";
|
||||
import { Component, StrictMode, type ErrorInfo, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
type AppErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AppErrorBoundaryState = {
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
||||
state: AppErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("NDC SEO UI render error", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<main className="app-fatal-state">
|
||||
<section>
|
||||
<strong>Интерфейс SEO Mode упал при отрисовке</strong>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button onClick={() => window.location.reload()} type="button">
|
||||
Перезагрузить интерфейс
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AppErrorBoundary>
|
||||
<App />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2748,6 +2748,11 @@ input {
|
|||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.stage-6 .market-heading .strategy-stage-run-date {
|
||||
color: var(--ink);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.stage-6 .market-heading > svg {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
|
|
@ -2760,6 +2765,51 @@ input {
|
|||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.app-fatal-state {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(135deg, #f2f2f2, #ffffff);
|
||||
}
|
||||
|
||||
.app-fatal-state section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: min(520px, 100%);
|
||||
padding: 22px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.app-fatal-state strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.app-fatal-state p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 740;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-fatal-state button {
|
||||
justify-self: start;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: var(--ink);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-state-card {
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
|
|
@ -2882,6 +2932,18 @@ input {
|
|||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
||||
}
|
||||
|
||||
.keyword-stage-section.collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.keyword-stage-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-stage-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -2949,6 +3011,20 @@ input {
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.keyword-stage-icon-action.active {
|
||||
color: #fff;
|
||||
background: #e83e8c;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 14px 34px rgba(232, 62, 140, 0.24);
|
||||
}
|
||||
|
||||
.keyword-stage-toggle svg {
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.keyword-stage-toggle.expanded svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.keyword-stage-icon-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
|
|
@ -2970,6 +3046,145 @@ input {
|
|||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.keyword-stage-head > .keyword-stage-heading {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
background: rgba(24, 32, 29, 0.035);
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card.active {
|
||||
background: rgba(232, 62, 140, 0.045);
|
||||
border-color: rgba(232, 62, 140, 0.16);
|
||||
}
|
||||
|
||||
.keyword-stage-process-card.blocked {
|
||||
background: rgba(139, 47, 47, 0.035);
|
||||
border-color: rgba(139, 47, 47, 0.12);
|
||||
}
|
||||
|
||||
.keyword-stage-process-card > svg {
|
||||
flex: 0 0 auto;
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card.blocked > svg {
|
||||
color: #8b2f2f;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card span {
|
||||
justify-self: start;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
color: #2e5a67;
|
||||
background: rgba(46, 90, 103, 0.08);
|
||||
border: 1px solid rgba(46, 90, 103, 0.14);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-stage-process-card small {
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
line-height: 1.38;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.5fr) auto minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list span,
|
||||
.keyword-workflow-step-list strong,
|
||||
.keyword-workflow-step-list small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list strong {
|
||||
justify-self: start;
|
||||
min-height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(46, 90, 103, 0.08);
|
||||
color: #2e5a67;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list .completed strong {
|
||||
background: rgba(47, 122, 89, 0.1);
|
||||
color: #2f7a59;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list .blocked strong,
|
||||
.keyword-workflow-step-list .failed strong {
|
||||
background: rgba(139, 47, 47, 0.08);
|
||||
color: #8b2f2f;
|
||||
}
|
||||
|
||||
.keyword-workflow-step-list .running strong,
|
||||
.keyword-workflow-step-list .waiting strong {
|
||||
background: rgba(232, 62, 140, 0.1);
|
||||
color: #bc2f72;
|
||||
}
|
||||
|
||||
.anchor-review-row > div:first-child span {
|
||||
justify-self: start;
|
||||
min-height: 22px;
|
||||
|
|
@ -3029,11 +3244,11 @@ input {
|
|||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.keyword-curation-card > span {
|
||||
justify-self: start;
|
||||
.keyword-curation-card-meta > span:last-child {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
color: rgba(8, 8, 10, 0.78);
|
||||
background: rgba(8, 8, 10, 0.07);
|
||||
|
|
@ -3041,6 +3256,7 @@ input {
|
|||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-curation-column-head strong,
|
||||
|
|
@ -3146,10 +3362,11 @@ input {
|
|||
}
|
||||
|
||||
.keyword-curation-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
padding: 10px 42px 10px 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: var(--keyword-curation-radius);
|
||||
|
|
@ -3157,6 +3374,40 @@ input {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.keyword-curation-trash {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: #d52978;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1.5px solid rgba(213, 41, 120, 0.72);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 8px 20px rgba(232, 62, 140, 0.08);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.keyword-curation-trash:hover {
|
||||
color: #ffffff;
|
||||
background: #e83e8c;
|
||||
border-color: #e83e8c;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.keyword-curation-trash:focus-visible {
|
||||
outline: 2px solid rgba(232, 62, 140, 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.keyword-curation-drag-preview {
|
||||
position: fixed;
|
||||
left: var(--keyword-drag-x);
|
||||
|
|
@ -3173,6 +3424,29 @@ input {
|
|||
box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22);
|
||||
}
|
||||
|
||||
.keyword-curation-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-curation-freshness {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.keyword-curation-freshness.old {
|
||||
background: #d7a11a;
|
||||
}
|
||||
|
||||
.keyword-curation-freshness.new {
|
||||
background: #18a957;
|
||||
}
|
||||
|
||||
.keyword-curation-empty {
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
|
|
@ -3187,6 +3461,115 @@ input {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: rgba(24, 32, 29, 0.035);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: var(--keyword-curation-radius);
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head > div:first-child {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head span,
|
||||
.wordstat-probe-preview-head small,
|
||||
.wordstat-probe-preview-empty,
|
||||
.wordstat-probe-preview-hash {
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 920;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head .ready,
|
||||
.wordstat-probe-preview-head .blocked {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head .ready {
|
||||
color: #0a6a42;
|
||||
background: rgba(20, 148, 92, 0.1);
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-head .blocked {
|
||||
color: #9c2f2f;
|
||||
background: rgba(210, 66, 66, 0.1);
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-stats,
|
||||
.wordstat-probe-preview-warnings,
|
||||
.wordstat-probe-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-stats span,
|
||||
.wordstat-probe-preview-warnings span,
|
||||
.wordstat-probe-preview-list span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
color: rgba(8, 8, 10, 0.78);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 840;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-warnings span {
|
||||
color: #735018;
|
||||
background: rgba(255, 202, 96, 0.14);
|
||||
border-color: rgba(170, 115, 20, 0.16);
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-warnings span.warning {
|
||||
color: #8d2f24;
|
||||
background: rgba(220, 74, 56, 0.1);
|
||||
border-color: rgba(184, 58, 43, 0.16);
|
||||
}
|
||||
|
||||
.wordstat-probe-preview-list span {
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.keyword-curation-footer {
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
|
|
@ -3236,6 +3619,228 @@ input {
|
|||
font-weight: 900;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 380px);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: rgba(24, 32, 29, 0.034);
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card span {
|
||||
color: #2e5a67;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card strong {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-control {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
justify-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anchor-demand-profile-control > span {
|
||||
justify-self: end;
|
||||
padding: 4px 9px;
|
||||
color: #7a1f4d;
|
||||
background: rgba(232, 62, 140, 0.1);
|
||||
border: 1px solid rgba(232, 62, 140, 0.18);
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.nodedc-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-select__control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-select__value,
|
||||
.nodedc-select__toggle {
|
||||
height: 40px;
|
||||
color: rgba(20, 23, 25, 0.86);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92), 0 12px 28px rgba(18, 22, 25, 0.08);
|
||||
outline: none;
|
||||
transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.nodedc-select__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-select__toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-select__value:hover,
|
||||
.nodedc-select__toggle:hover,
|
||||
.nodedc-select__toggle[aria-expanded="true"] {
|
||||
color: rgba(10, 12, 14, 0.94);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96), 0 14px 34px rgba(18, 22, 25, 0.1);
|
||||
}
|
||||
|
||||
.nodedc-select__toggle:focus,
|
||||
.nodedc-select__toggle:focus-visible,
|
||||
.nodedc-select__value:focus,
|
||||
.nodedc-select__value:focus-visible,
|
||||
.nodedc-select__option:focus,
|
||||
.nodedc-select__option:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-select__chevron {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
opacity: 0.72;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: translateY(-2px) rotate(45deg);
|
||||
}
|
||||
|
||||
.nodedc-dropdown-surface.nodedc-select__menu {
|
||||
z-index: 30000;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 232px;
|
||||
padding: 7px;
|
||||
overflow: auto;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 18px 60px rgba(24, 32, 29, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nodedc-dropdown-surface.nodedc-select__menu::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-dropdown-option.nodedc-select__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
color: rgba(28, 31, 33, 0.74);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-select__option-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-select__option-actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.nodedc-select__option-action {
|
||||
display: grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: rgba(10, 12, 14, 0.68);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.nodedc-select__option-action:hover,
|
||||
.nodedc-select__option-action:focus-visible {
|
||||
color: #e83e8c;
|
||||
background: rgba(232, 62, 140, 0.1);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nodedc-select__option-action[data-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.nodedc-dropdown-option.nodedc-select__option:hover,
|
||||
.nodedc-dropdown-option.nodedc-select__option:focus,
|
||||
.nodedc-dropdown-option.nodedc-select__option[data-selected="true"] {
|
||||
color: rgba(10, 12, 14, 0.95);
|
||||
background: rgba(24, 32, 29, 0.065);
|
||||
}
|
||||
|
||||
.nodedc-dropdown-option.nodedc-select__option[data-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.54;
|
||||
}
|
||||
|
||||
.anchor-review-workspace-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -12258,6 +12863,8 @@ textarea:focus {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.keyword-stage-head,
|
||||
.keyword-anchor-stage-head,
|
||||
.keyword-curation-board-head,
|
||||
.keyword-curation-footer {
|
||||
flex-direction: column;
|
||||
|
|
@ -12271,6 +12878,10 @@ textarea:focus {
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.anchor-demand-profile-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.anchor-review-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
|
|
@ -13075,9 +13686,9 @@ textarea:focus {
|
|||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(10, 12, 14, 0.28);
|
||||
backdrop-filter: var(--nodedc-glass-panel-blur);
|
||||
-webkit-backdrop-filter: var(--nodedc-glass-panel-blur);
|
||||
background: rgba(10, 12, 14, 0.14);
|
||||
backdrop-filter: blur(18px) saturate(1.04);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.04);
|
||||
}
|
||||
|
||||
.seo-project-name-modal {
|
||||
|
|
@ -13148,6 +13759,62 @@ textarea:focus {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.seo-keyword-suppress-modal {
|
||||
width: min(36rem, 100%);
|
||||
}
|
||||
|
||||
.seo-keyword-context-modal {
|
||||
width: min(38rem, 100%);
|
||||
}
|
||||
|
||||
.seo-keyword-suppress-preview {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
padding: 0.88rem;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 12px 32px rgba(24, 32, 29, 0.06);
|
||||
}
|
||||
|
||||
.seo-keyword-suppress-preview span {
|
||||
justify-self: start;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
color: rgba(8, 8, 10, 0.78);
|
||||
background: rgba(8, 8, 10, 0.07);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.seo-keyword-suppress-preview strong {
|
||||
color: var(--ink);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.16;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.seo-keyword-suppress-preview small,
|
||||
.seo-keyword-suppress-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 740;
|
||||
line-height: 1.42;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.seo-project-name-modal-actions .danger-action {
|
||||
background: #e83e8c !important;
|
||||
border-color: #e83e8c !important;
|
||||
box-shadow: 0 14px 30px rgba(232, 62, 140, 0.22) !important;
|
||||
}
|
||||
|
||||
.seo-project-name-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -14297,7 +14964,7 @@ body:has(.seo-launcher-shell) {
|
|||
color: rgba(8, 8, 10, 0.72);
|
||||
}
|
||||
|
||||
.seo-stage-5 .wordstat-quota-strip {
|
||||
.seo-stage-5 .seo-work-panel-toolbar > .wordstat-quota-strip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
|
@ -14305,6 +14972,76 @@ body:has(.seo-launcher-shell) {
|
|||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.nodedc-global-wordstat-quota {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nodedc-global-wordstat-quota .wordstat-quota-strip {
|
||||
width: clamp(14rem, 22vw, 19rem);
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.keyword-context-toolbar {
|
||||
display: inline-grid;
|
||||
grid-template-columns: minmax(12rem, 17rem) 2.42rem;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-context-profile-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.keyword-context-toolbar .nodedc-select__control {
|
||||
grid-template-columns: minmax(0, 1fr) 2.42rem;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.keyword-context-toolbar .nodedc-select__value,
|
||||
.keyword-context-toolbar .nodedc-select__toggle {
|
||||
height: 2.42rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.keyword-context-toolbar > button {
|
||||
display: grid;
|
||||
width: 2.42rem;
|
||||
height: 2.42rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: rgba(10, 12, 14, 0.78);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 10px 28px rgba(24, 32, 29, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.keyword-context-toolbar > button:hover:not(:disabled),
|
||||
.keyword-context-toolbar > button.saving {
|
||||
background: #e83e8c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.keyword-context-toolbar > button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.keyword-context-profile-menu {
|
||||
min-width: 25rem;
|
||||
max-width: min(31rem, calc(100vw - 1.5rem));
|
||||
}
|
||||
|
||||
.keyword-context-profile-menu .nodedc-select__option {
|
||||
min-height: 48px;
|
||||
padding-inline: 12px 8px;
|
||||
}
|
||||
|
||||
.wordstat-quota-head,
|
||||
.wordstat-quota-meta {
|
||||
display: flex;
|
||||
|
|
@ -14419,8 +15156,30 @@ body:has(.seo-launcher-shell) {
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.seo-topbar-status-pill {
|
||||
display: inline-flex;
|
||||
min-height: 2.34rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.38rem;
|
||||
padding: 0 0.86rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(232, 62, 140, 0.09);
|
||||
color: #bc2f72;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 860;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seo-topbar-status-pill.blocked {
|
||||
background: rgba(139, 47, 47, 0.07);
|
||||
color: #8b2f2f;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > button {
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > button,
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill {
|
||||
width: 3rem;
|
||||
min-width: 3rem;
|
||||
padding: 0;
|
||||
|
|
@ -14428,7 +15187,8 @@ body:has(.seo-launcher-shell) {
|
|||
font-size: 0;
|
||||
}
|
||||
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > button svg {
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > button svg,
|
||||
.seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
|
@ -15481,6 +16241,7 @@ body:has(.seo-launcher-shell) {
|
|||
}
|
||||
|
||||
.seo-launcher-shell.project-open .seo-stage-rail {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid var(--nodedc-glass-outline) !important;
|
||||
background: var(--nodedc-glass-panel-surface-strong) !important;
|
||||
|
|
@ -15511,6 +16272,43 @@ body:has(.seo-launcher-shell) {
|
|||
z-index: 3;
|
||||
}
|
||||
|
||||
.seo-launcher-shell.project-open .seo-stage-rail-list {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.seo-stage-rail-wordstat {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 0.72rem 0.78rem;
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 14px 34px rgba(24, 32, 29, 0.08);
|
||||
}
|
||||
|
||||
.seo-stage-rail-wordstat .wordstat-quota-strip {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.seo-stage-rail-wordstat .wordstat-quota-head {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.seo-stage-rail-wordstat .wordstat-quota-meta {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.24rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.seo-stage-rail-wordstat .wordstat-quota-meta span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seo-stage-rail-head {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) 2.38rem;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,37 @@ type ContentDocument = {
|
|||
|
||||
type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
|
||||
|
||||
type TextCorpusChunk = {
|
||||
charEnd: number;
|
||||
charStart: number;
|
||||
chunkIndex: number;
|
||||
documentId: string;
|
||||
id: string;
|
||||
kind: ContentDocument["kind"];
|
||||
scope: ContentDocument["scope"];
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
text: string;
|
||||
title: string;
|
||||
totalChunks: number;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
};
|
||||
|
||||
type TextCorpusDocument = {
|
||||
chunkIds: string[];
|
||||
id: string;
|
||||
kind: ContentDocument["kind"];
|
||||
scope: ContentDocument["scope"];
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
textCharCount: number;
|
||||
title: string;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
wordCount: number;
|
||||
};
|
||||
|
||||
type AnalysisSignal = {
|
||||
id: string;
|
||||
level: "ok" | "warning" | "problem";
|
||||
|
|
@ -272,6 +303,19 @@ export type SemanticAnalysisOutput = {
|
|||
wordCount: number;
|
||||
matchedClusters: string[];
|
||||
}>;
|
||||
textCorpus: {
|
||||
schemaVersion: "seo-text-corpus.v1";
|
||||
extraction: {
|
||||
chunkCount: number;
|
||||
documentCount: number;
|
||||
mode: "clean_visible_text";
|
||||
sourceDocumentCount: number;
|
||||
totalChars: number;
|
||||
totalWords: number;
|
||||
};
|
||||
documents: TextCorpusDocument[];
|
||||
chunks: TextCorpusChunk[];
|
||||
};
|
||||
legacyFindings: Array<{
|
||||
markerId: string;
|
||||
label: string;
|
||||
|
|
@ -484,6 +528,11 @@ const RUSSIAN_STOP_WORDS = new Set([
|
|||
"assets",
|
||||
"attr",
|
||||
"avif",
|
||||
"accesscard",
|
||||
"bodyhtml",
|
||||
"bottominfo",
|
||||
"card",
|
||||
"cards",
|
||||
"class",
|
||||
"collection",
|
||||
"content",
|
||||
|
|
@ -492,16 +541,22 @@ const RUSSIAN_STOP_WORDS = new Set([
|
|||
"data",
|
||||
"features",
|
||||
"footer",
|
||||
"form",
|
||||
"html",
|
||||
"image",
|
||||
"images",
|
||||
"introhtml",
|
||||
"json",
|
||||
"heading",
|
||||
"main",
|
||||
"media",
|
||||
"muted",
|
||||
"node",
|
||||
"site",
|
||||
"src",
|
||||
"static",
|
||||
"staticelements",
|
||||
"target",
|
||||
"text",
|
||||
"txt",
|
||||
"webp",
|
||||
|
|
@ -537,6 +592,8 @@ const CTA_PATTERNS = [
|
|||
"заказать"
|
||||
];
|
||||
|
||||
const TEXT_CORPUS_CHUNK_CHARS = 6000;
|
||||
|
||||
function toIsoDate(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
|
@ -626,12 +683,113 @@ function visibleTextFromHtml(html: string) {
|
|||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
||||
.replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
|
||||
.replace(/<(br|hr)\b[^>]*>/gi, "\n")
|
||||
.replace(/<\/(article|aside|blockquote|dd|details|dialog|div|dl|dt|figcaption|figure|footer|form|h[1-6]|header|li|main|nav|ol|p|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitTextCorpusChunks(text: string, maxChars = TEXT_CORPUS_CHUNK_CHARS) {
|
||||
const chunks: Array<{ charEnd: number; charStart: number; text: string }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < text.length) {
|
||||
const hardEnd = Math.min(text.length, start + maxChars);
|
||||
let end = hardEnd;
|
||||
|
||||
if (hardEnd < text.length) {
|
||||
const windowStart = Math.max(start + Math.floor(maxChars * 0.65), start);
|
||||
const newlineIndex = text.lastIndexOf("\n", hardEnd);
|
||||
const spaceIndex = text.lastIndexOf(" ", hardEnd);
|
||||
const breakIndex = Math.max(newlineIndex, spaceIndex);
|
||||
|
||||
if (breakIndex >= windowStart) {
|
||||
end = breakIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const chunkText = text.slice(start, end).trim();
|
||||
|
||||
if (chunkText) {
|
||||
chunks.push({
|
||||
charEnd: end,
|
||||
charStart: start,
|
||||
text: chunkText
|
||||
});
|
||||
}
|
||||
|
||||
start = end;
|
||||
while (start < text.length && /\s/.test(text[start] ?? "")) {
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildTextCorpus(documents: ContentDocument[]): SemanticAnalysisOutput["textCorpus"] {
|
||||
const sourceDocuments = documents.filter((document) => document.text.trim().length > 0);
|
||||
const corpusDocuments: TextCorpusDocument[] = [];
|
||||
const chunks: TextCorpusChunk[] = [];
|
||||
|
||||
for (const document of sourceDocuments) {
|
||||
const documentChunks = splitTextCorpusChunks(document.text);
|
||||
const chunkIds = documentChunks.map((_, index) => `${document.id}:chunk:${index + 1}`);
|
||||
|
||||
corpusDocuments.push({
|
||||
chunkIds,
|
||||
id: document.id,
|
||||
kind: document.kind,
|
||||
scope: document.scope,
|
||||
selected: document.selected,
|
||||
sourcePath: document.sourcePath,
|
||||
textCharCount: document.text.length,
|
||||
title: document.title,
|
||||
urlPath: document.urlPath,
|
||||
usedForCoverage: document.usedForCoverage,
|
||||
wordCount: document.wordCount
|
||||
});
|
||||
|
||||
documentChunks.forEach((chunk, index) => {
|
||||
chunks.push({
|
||||
charEnd: chunk.charEnd,
|
||||
charStart: chunk.charStart,
|
||||
chunkIndex: index + 1,
|
||||
documentId: document.id,
|
||||
id: chunkIds[index] ?? `${document.id}:chunk:${index + 1}`,
|
||||
kind: document.kind,
|
||||
scope: document.scope,
|
||||
selected: document.selected,
|
||||
sourcePath: document.sourcePath,
|
||||
text: chunk.text,
|
||||
title: document.title,
|
||||
totalChunks: documentChunks.length,
|
||||
urlPath: document.urlPath,
|
||||
usedForCoverage: document.usedForCoverage
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-text-corpus.v1",
|
||||
extraction: {
|
||||
chunkCount: chunks.length,
|
||||
documentCount: corpusDocuments.length,
|
||||
mode: "clean_visible_text",
|
||||
sourceDocumentCount: sourceDocuments.length,
|
||||
totalChars: corpusDocuments.reduce((sum, document) => sum + document.textCharCount, 0),
|
||||
totalWords: corpusDocuments.reduce((sum, document) => sum + document.wordCount, 0)
|
||||
},
|
||||
documents: corpusDocuments,
|
||||
chunks
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string) {
|
||||
return value
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
|
|
@ -1316,7 +1474,7 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
|
|||
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
|
||||
: "Кластер уже найден в контенте и подходит для проверки частотности.";
|
||||
|
||||
return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({
|
||||
return buildClusterSeedPhrases(cluster, ontology.brandName).slice(0, cluster.priority === "high" ? 4 : 2).map((phrase) => ({
|
||||
phrase,
|
||||
clusterId: cluster.id,
|
||||
clusterTitle: cluster.title,
|
||||
|
|
@ -1327,6 +1485,122 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
|
|||
});
|
||||
}
|
||||
|
||||
function getSeedPhraseWords(value: string) {
|
||||
return normalizeSearchText(value)
|
||||
.split(/\s+/)
|
||||
.map((word) => word.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isSemanticBasisPhrase(value: string, brandName: string) {
|
||||
const normalizedValue = normalizeSearchText(value);
|
||||
const words = getSeedPhraseWords(normalizedValue);
|
||||
const normalizedBrand = normalizeSearchText(brandName);
|
||||
const brandWords = new Set(getSeedPhraseWords(normalizedBrand));
|
||||
|
||||
if (words.length < 3 || words.length > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!/[a-zа-яё0-9]/i.test(normalizedValue) ||
|
||||
/\{\{[^}]+}}/.test(value) ||
|
||||
/https?:|\/\/|\.html\b|href\b|logo\b|логотип\b|beforecviz\b|staticelements\b|txt-файл|txt-файла|иконка\b|^[^а-яёa-z0-9]+$/iu.test(normalizedValue) ||
|
||||
/[0-9]{6,}/.test(normalizedValue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedValue === normalizedBrand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meaningfulWords = words.filter((word) => !RUSSIAN_STOP_WORDS.has(word) && !brandWords.has(word) && word.length > 1);
|
||||
|
||||
return meaningfulWords.length >= 2;
|
||||
}
|
||||
|
||||
function cleanSeedPhrase(value: string) {
|
||||
return value
|
||||
.replace(/^\.\.\.\s*/, "")
|
||||
.replace(/\s*\.\.\.$/, "")
|
||||
.replace(/[“”«»"]/g, "")
|
||||
.replace(/[|;:…]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getWindowedPhrase(sentence: string, term: string) {
|
||||
const words = cleanSeedPhrase(sentence).split(/\s+/).filter(Boolean);
|
||||
const normalizedTermWords = getSeedPhraseWords(term);
|
||||
const normalizedWords = words.map((word) => normalizeSearchText(word));
|
||||
const termIndex = normalizedWords.findIndex((word, index) =>
|
||||
normalizedTermWords.every((termWord, termWordIndex) => normalizedWords[index + termWordIndex] === termWord)
|
||||
);
|
||||
|
||||
if (termIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = Math.max(0, termIndex - 2);
|
||||
const end = Math.min(words.length, termIndex + Math.max(normalizedTermWords.length, 1) + 4);
|
||||
|
||||
return cleanSeedPhrase(words.slice(start, end).join(" "));
|
||||
}
|
||||
|
||||
function extractEvidenceSeedPhrases(
|
||||
cluster: ReturnType<typeof analyzeClusters>[number],
|
||||
brandName: string
|
||||
) {
|
||||
const terms = [...cluster.matchedTerms, ...cluster.queryExamples];
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const snippet of cluster.evidenceSnippets) {
|
||||
const sentenceCandidates = snippet.text
|
||||
.split(/[.!?\n\r]+/u)
|
||||
.map(cleanSeedPhrase)
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sentence of sentenceCandidates) {
|
||||
const sentenceHasTerm = terms.some((term) => countTermHits(normalizeSearchText(sentence), term) > 0);
|
||||
|
||||
if (!sentenceHasTerm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSemanticBasisPhrase(sentence, brandName)) {
|
||||
candidates.push(sentence);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const term of terms) {
|
||||
if (countTermHits(normalizeSearchText(sentence), term) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const phrase = getWindowedPhrase(sentence, term);
|
||||
|
||||
if (phrase && isSemanticBasisPhrase(phrase, brandName)) {
|
||||
candidates.push(phrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function buildClusterSeedPhrases(cluster: ReturnType<typeof analyzeClusters>[number], brandName: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
cluster.queryExamples
|
||||
.map(cleanSeedPhrase)
|
||||
.filter((phrase) => isSemanticBasisPhrase(phrase, brandName))
|
||||
.map((phrase) => phrase.toLocaleLowerCase("ru-RU"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getLandingOpportunityPageType(
|
||||
pagePath: string
|
||||
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
|
||||
|
|
@ -2523,6 +2797,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
|||
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
||||
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
||||
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
||||
const textCorpus = buildTextCorpus(coverageDocuments.length > 0 ? coverageDocuments : documents);
|
||||
const clusters = analyzeClusters(coverageDocuments, ontology);
|
||||
const coverageScore = getCoverageScore(clusters);
|
||||
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
||||
|
|
@ -2640,6 +2915,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
|||
wordCount: document.wordCount,
|
||||
matchedClusters: contentClusterMap.get(document.id) ?? []
|
||||
})),
|
||||
textCorpus,
|
||||
legacyFindings,
|
||||
gaps,
|
||||
landingOpportunities,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,732 @@
|
|||
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
||||
import {
|
||||
getLatestAlignedSeoBusinessSynthesis,
|
||||
getSeoBusinessSynthesisContract,
|
||||
type SeoBusinessSynthesisContract
|
||||
} from "../business/seoBusinessSynthesis.js";
|
||||
import { pool } from "../db/client.js";
|
||||
|
||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
type ProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type Grounding = "explicit" | "inferred" | "hypothesis";
|
||||
type BuyerIntent =
|
||||
| "solution_search"
|
||||
| "vendor_selection"
|
||||
| "implementation"
|
||||
| "automation"
|
||||
| "integration"
|
||||
| "replacement"
|
||||
| "pilot"
|
||||
| "commercial";
|
||||
|
||||
type SeoCommercialDemandRunRow = {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
input: Record<string, unknown>;
|
||||
output: SeoCommercialDemandContract | null;
|
||||
error_message: string | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type SeoCommercialDemandModelTaskContract = {
|
||||
taskType: "seo.commercial_demand";
|
||||
schemaVersion: "seo-commercial-demand-task.v1";
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
semanticRunId: string;
|
||||
projectOntologyVersionId: string | null;
|
||||
providerMode: "model_provider_contract";
|
||||
input: {
|
||||
siteSummary: {
|
||||
brandName: string;
|
||||
language: string;
|
||||
topTerms: string[];
|
||||
};
|
||||
businessSynthesis: {
|
||||
productFrame: SeoBusinessSynthesisContract["productFrame"];
|
||||
corePillars: SeoBusinessSynthesisContract["semanticHierarchy"]["corePillars"];
|
||||
applicationVectors: SeoBusinessSynthesisContract["semanticHierarchy"]["applicationVectors"];
|
||||
demandFamilies: SeoBusinessSynthesisContract["demandFamilies"];
|
||||
guardrails: string[];
|
||||
summary: string;
|
||||
};
|
||||
};
|
||||
allowedActions: Array<
|
||||
| "identify_buyer_context"
|
||||
| "convert_architecture_to_commercial_value"
|
||||
| "preserve_core_differentiator"
|
||||
| "separate_commercial_queries_from_informational_queries"
|
||||
| "flag_noncommercial_or_overbroad_angles"
|
||||
>;
|
||||
outputSchema: {
|
||||
schemaVersion: "seo-commercial-demand.v1";
|
||||
requiredTopLevelKeys: string[];
|
||||
};
|
||||
stopConditions: string[];
|
||||
};
|
||||
|
||||
export type SeoCommercialDemandContract = {
|
||||
schemaVersion: "seo-commercial-demand.v1";
|
||||
projectId: string;
|
||||
semanticRunId: string;
|
||||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
provider: {
|
||||
mode: ProviderMode;
|
||||
modelRequired: true;
|
||||
message: string;
|
||||
};
|
||||
sourceTaskId: string;
|
||||
analystReview: {
|
||||
status: "approved" | "needs_changes" | "not_reviewed" | "rejected";
|
||||
reviewer: "codex" | "human" | "system";
|
||||
reviewedAt: string | null;
|
||||
notes: string[];
|
||||
};
|
||||
commercialCore: {
|
||||
coreBuyerProblem: string;
|
||||
coreCommercialValue: string;
|
||||
coreDifferentiator: string;
|
||||
buyingTrigger: string;
|
||||
decisionContext: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
evidenceRefs: string[];
|
||||
};
|
||||
buyerIntentFamilies: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
buyerIntent: BuyerIntent;
|
||||
priority: "high" | "medium" | "low";
|
||||
grounding: Grounding;
|
||||
commercialAngle: string;
|
||||
sourceMeaning: string;
|
||||
coreQualifier: string;
|
||||
queryPatterns: string[];
|
||||
excludedInformationalPatterns: string[];
|
||||
evidenceRefs: string[];
|
||||
}>;
|
||||
applicationCommercialAngles: Array<{
|
||||
id: string;
|
||||
sourceVector: string;
|
||||
relationshipToCore: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
grounding: Grounding;
|
||||
commercialReframe: string;
|
||||
coreQualifier: string;
|
||||
queryPatterns: string[];
|
||||
evidenceRefs: string[];
|
||||
}>;
|
||||
rejectedAngles: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
suggestedCommercialReframe: string | null;
|
||||
}>;
|
||||
guardrails: string[];
|
||||
summary: string;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type SeoCommercialDemandRun = SeoCommercialDemandContract & {
|
||||
runId: string;
|
||||
status: RunStatus;
|
||||
input: Record<string, unknown>;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type SeoCommercialDemandModelProviderFallback = {
|
||||
modelTask: SeoCommercialDemandModelTaskContract;
|
||||
projectOntologyVersionId: string | null;
|
||||
semanticRunId: string;
|
||||
};
|
||||
|
||||
function toIsoDate(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value)) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asLabel(value: unknown): string | null {
|
||||
const direct = asString(value);
|
||||
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
|
||||
return asString(record.title) ?? asString(record.label) ?? asString(record.name) ?? asString(record.summary) ?? asString(record.description);
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return [value.trim()];
|
||||
}
|
||||
|
||||
return asArray(value).map(asLabel).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[“”«»"]/g, "")
|
||||
.replace(/[|,;:…]+/g, " ")
|
||||
.replace(/[\\/]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLocaleLowerCase("ru-RU");
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const normalizedValue = normalizeText(value);
|
||||
|
||||
if (!normalizedValue || seen.has(normalizedValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(normalizedValue);
|
||||
result.push(normalizedValue);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function toId(value: string) {
|
||||
return normalizeText(value)
|
||||
.replace(/[^a-zа-яё0-9]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function normalizeGrounding(value: unknown, fallback: Grounding = "inferred"): Grounding {
|
||||
if (value === "explicit" || value === "inferred" || value === "hypothesis") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = normalizeText(asString(value) ?? "");
|
||||
|
||||
if (/(risk|unsupported|hypothesis|гипотез|не подтверж)/iu.test(text)) {
|
||||
return "hypothesis";
|
||||
}
|
||||
|
||||
if (/(infer|possible|conservative|предполож|вывод)/iu.test(text)) {
|
||||
return "inferred";
|
||||
}
|
||||
|
||||
if (/(explicit|source|evidence|direct|явн|доказ)/iu.test(text)) {
|
||||
return "explicit";
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizePriority(value: unknown): "high" | "medium" | "low" {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = normalizeText(asString(value) ?? "");
|
||||
|
||||
if (/(highest|critical|high|core|главн|высок)/iu.test(text)) {
|
||||
return "high";
|
||||
}
|
||||
|
||||
if (/(low|weak|optional|низк)/iu.test(text)) {
|
||||
return "low";
|
||||
}
|
||||
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function normalizeBuyerIntent(value: unknown, text: string): BuyerIntent {
|
||||
const normalizedValue = normalizeText(`${asString(value) ?? ""} ${text}`);
|
||||
|
||||
if (/(pilot|demo|trial|пилот|демо|заявк|подключ)/iu.test(normalizedValue)) {
|
||||
return "pilot";
|
||||
}
|
||||
|
||||
if (/(implement|deploy|rollout|внедрен|запуск|разверт)/iu.test(normalizedValue)) {
|
||||
return "implementation";
|
||||
}
|
||||
|
||||
if (/(integrat|api|webhook|1c|1с|интеграц|подключ)/iu.test(normalizedValue)) {
|
||||
return "integration";
|
||||
}
|
||||
|
||||
if (/(replace|alternative|вместо|замен|альтернатив)/iu.test(normalizedValue)) {
|
||||
return "replacement";
|
||||
}
|
||||
|
||||
if (/(vendor|platform|service|provider|поставщик|подрядчик|платформ|сервис|решение)/iu.test(normalizedValue)) {
|
||||
return "vendor_selection";
|
||||
}
|
||||
|
||||
if (/(automat|agent|workflow|автоматизац|агент|процесс)/iu.test(normalizedValue)) {
|
||||
return "automation";
|
||||
}
|
||||
|
||||
if (/(commercial|price|tariff|цена|стоим|тариф)/iu.test(normalizedValue)) {
|
||||
return "commercial";
|
||||
}
|
||||
|
||||
return "solution_search";
|
||||
}
|
||||
|
||||
function getDefaultAnalystReview(): SeoCommercialDemandContract["analystReview"] {
|
||||
return {
|
||||
notes: [],
|
||||
reviewedAt: null,
|
||||
reviewer: "system",
|
||||
status: "not_reviewed"
|
||||
};
|
||||
}
|
||||
|
||||
function mapSeoCommercialDemandRun(row: SeoCommercialDemandRunRow): SeoCommercialDemandRun | null {
|
||||
if (!row.output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
runId: row.id,
|
||||
status: row.status,
|
||||
input: row.input,
|
||||
startedAt: toIsoDate(row.started_at),
|
||||
completedAt: toIsoDate(row.completed_at),
|
||||
errorMessage: row.error_message,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
...row.output,
|
||||
analystReview: row.output.analystReview ?? getDefaultAnalystReview()
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoCommercialDemandTask(
|
||||
projectId: string,
|
||||
businessSynthesis: SeoBusinessSynthesisContract
|
||||
): SeoCommercialDemandModelTaskContract {
|
||||
return {
|
||||
taskType: "seo.commercial_demand",
|
||||
schemaVersion: "seo-commercial-demand-task.v1",
|
||||
taskId: `${businessSynthesis.semanticRunId}:seo-commercial-demand:v1`,
|
||||
projectId,
|
||||
semanticRunId: businessSynthesis.semanticRunId,
|
||||
projectOntologyVersionId: businessSynthesis.projectOntologyVersionId,
|
||||
providerMode: "model_provider_contract",
|
||||
input: {
|
||||
siteSummary: {
|
||||
brandName: businessSynthesis.productFrame.coreOffer,
|
||||
language: "mixed",
|
||||
topTerms: []
|
||||
},
|
||||
businessSynthesis: {
|
||||
productFrame: businessSynthesis.productFrame,
|
||||
corePillars: businessSynthesis.semanticHierarchy.corePillars.slice(0, 12),
|
||||
applicationVectors: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 18),
|
||||
demandFamilies: businessSynthesis.demandFamilies.slice(0, 20),
|
||||
guardrails: businessSynthesis.guardrails.slice(0, 20),
|
||||
summary: businessSynthesis.summary
|
||||
}
|
||||
},
|
||||
allowedActions: [
|
||||
"identify_buyer_context",
|
||||
"convert_architecture_to_commercial_value",
|
||||
"preserve_core_differentiator",
|
||||
"separate_commercial_queries_from_informational_queries",
|
||||
"flag_noncommercial_or_overbroad_angles"
|
||||
],
|
||||
outputSchema: {
|
||||
schemaVersion: "seo-commercial-demand.v1",
|
||||
requiredTopLevelKeys: [
|
||||
"commercialCore",
|
||||
"buyerIntentFamilies",
|
||||
"applicationCommercialAngles"
|
||||
]
|
||||
},
|
||||
stopConditions: [
|
||||
"Do not output architecture inventory as keyword demand.",
|
||||
"Every query pattern must express buyer intent, business value, implementation intent, vendor selection, automation, replacement, integration, pilot, or commercial evaluation.",
|
||||
"Preserve the site's core differentiator from productFrame/corePillars; do not detach verticals into unrelated markets.",
|
||||
"If an application vector only makes sense with the core platform differentiator, include that qualifier in query patterns.",
|
||||
"Do not call Wordstat/SERP or claim market demand is proven."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function getEvidenceRefs(record: Record<string, unknown>) {
|
||||
return unique([
|
||||
...asStringArray(record.evidenceRefs),
|
||||
...asStringArray(record.evidence),
|
||||
...asStringArray(record.evidenceBasis),
|
||||
...asStringArray(record.sources)
|
||||
]).slice(0, 12);
|
||||
}
|
||||
|
||||
function normalizeCommercialDemandShape(
|
||||
projectId: string,
|
||||
output: SeoCommercialDemandContract,
|
||||
fallback: SeoCommercialDemandModelProviderFallback
|
||||
): SeoCommercialDemandContract {
|
||||
const rawOutput = asRecord(output);
|
||||
const rawCore = asRecord(rawOutput.commercialCore);
|
||||
const fallbackProductFrame = fallback.modelTask.input.businessSynthesis.productFrame;
|
||||
const coreDifferentiator =
|
||||
asString(rawCore.coreDifferentiator) ??
|
||||
asString(rawCore.differentiator) ??
|
||||
fallbackProductFrame.coreOffer;
|
||||
const commercialCore: SeoCommercialDemandContract["commercialCore"] = {
|
||||
buyingTrigger:
|
||||
asString(rawCore.buyingTrigger) ??
|
||||
asString(rawCore.trigger) ??
|
||||
"Покупатель ищет решение, когда текущие процессы, данные, роли или исполнение требуют системного улучшения.",
|
||||
confidence:
|
||||
rawCore.confidence === "high" || rawCore.confidence === "medium" || rawCore.confidence === "low"
|
||||
? rawCore.confidence
|
||||
: fallbackProductFrame.confidence,
|
||||
coreBuyerProblem:
|
||||
asString(rawCore.coreBuyerProblem) ??
|
||||
asString(rawCore.buyerProblem) ??
|
||||
asString(rawCore.problem) ??
|
||||
fallbackProductFrame.centralThesis,
|
||||
coreCommercialValue:
|
||||
asString(rawCore.coreCommercialValue) ??
|
||||
asString(rawCore.commercialValue) ??
|
||||
asString(rawCore.value) ??
|
||||
fallbackProductFrame.coreOffer,
|
||||
coreDifferentiator,
|
||||
decisionContext:
|
||||
asString(rawCore.decisionContext) ??
|
||||
asString(rawCore.context) ??
|
||||
"Выбор, внедрение или пилот решения внутри бизнес/операционного контура.",
|
||||
evidenceRefs: getEvidenceRefs(rawCore)
|
||||
};
|
||||
const buyerIntentFamilies = asArray(rawOutput.buyerIntentFamilies).map((item, index) => {
|
||||
const record = asRecord(item);
|
||||
const title = asString(record.title) ?? asLabel(record.name) ?? `Коммерческий intent ${index + 1}`;
|
||||
const queryPatterns = unique([
|
||||
...asStringArray(record.queryPatterns),
|
||||
...asStringArray(record.queries),
|
||||
...asStringArray(record.corePhrases),
|
||||
...asStringArray(record.phrases)
|
||||
]).slice(0, 12);
|
||||
|
||||
return {
|
||||
buyerIntent: normalizeBuyerIntent(record.buyerIntent ?? record.intent, `${title} ${queryPatterns.join(" ")}`),
|
||||
commercialAngle:
|
||||
asString(record.commercialAngle) ??
|
||||
asString(record.angle) ??
|
||||
asString(record.rationale) ??
|
||||
"Покупатель оценивает практическую бизнес-ценность решения.",
|
||||
coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator,
|
||||
evidenceRefs: getEvidenceRefs(record),
|
||||
excludedInformationalPatterns: unique([
|
||||
...asStringArray(record.excludedInformationalPatterns),
|
||||
...asStringArray(record.excludedPatterns),
|
||||
...asStringArray(record.nonCommercialPatterns)
|
||||
]).slice(0, 10),
|
||||
grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"),
|
||||
id: asString(record.id) ?? `buyer.${toId(title) || index + 1}`,
|
||||
priority: normalizePriority(record.priority),
|
||||
queryPatterns,
|
||||
sourceMeaning: asString(record.sourceMeaning) ?? asString(record.meaning) ?? title,
|
||||
title
|
||||
};
|
||||
});
|
||||
const applicationCommercialAngles = asArray(rawOutput.applicationCommercialAngles).map((item, index) => {
|
||||
const record = asRecord(item);
|
||||
const sourceVector = asString(record.sourceVector) ?? asString(record.title) ?? asLabel(record.name) ?? `Application vector ${index + 1}`;
|
||||
|
||||
return {
|
||||
commercialReframe:
|
||||
asString(record.commercialReframe) ??
|
||||
asString(record.reframe) ??
|
||||
asString(record.commercialAngle) ??
|
||||
"Переформулировать как покупательский сценарий, связанный с ядром продукта.",
|
||||
coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator,
|
||||
evidenceRefs: getEvidenceRefs(record),
|
||||
grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"),
|
||||
id: asString(record.id) ?? `angle.${toId(sourceVector) || index + 1}`,
|
||||
priority: normalizePriority(record.priority),
|
||||
queryPatterns: unique([
|
||||
...asStringArray(record.queryPatterns),
|
||||
...asStringArray(record.queries),
|
||||
...asStringArray(record.phrases)
|
||||
]).slice(0, 10),
|
||||
relationshipToCore: asString(record.relationshipToCore) ?? asString(record.relationship) ?? "application_of_core_platform",
|
||||
sourceVector
|
||||
};
|
||||
});
|
||||
const fallbackBuyerIntentFamilies =
|
||||
buyerIntentFamilies.length > 0
|
||||
? buyerIntentFamilies
|
||||
: fallback.modelTask.input.businessSynthesis.demandFamilies.slice(0, 10).map((family, index) => ({
|
||||
buyerIntent: normalizeBuyerIntent(family.role, family.title),
|
||||
commercialAngle: family.rationale,
|
||||
coreQualifier: coreDifferentiator,
|
||||
evidenceRefs: [],
|
||||
excludedInformationalPatterns: [],
|
||||
grounding: family.grounding,
|
||||
id: `buyer.${family.id || index + 1}`,
|
||||
priority: family.priority,
|
||||
queryPatterns: family.phrases.slice(0, 8),
|
||||
sourceMeaning: family.title,
|
||||
title: family.title
|
||||
}));
|
||||
|
||||
return {
|
||||
...output,
|
||||
analystReview: output.analystReview ?? getDefaultAnalystReview(),
|
||||
applicationCommercialAngles,
|
||||
buyerIntentFamilies: fallbackBuyerIntentFamilies,
|
||||
commercialCore,
|
||||
generatedAt: asString(rawOutput.generatedAt) ?? new Date().toISOString(),
|
||||
guardrails:
|
||||
asStringArray(rawOutput.guardrails).length > 0
|
||||
? asStringArray(rawOutput.guardrails)
|
||||
: [
|
||||
"Не превращать архитектурный тезис в запрос без покупательской ценности.",
|
||||
"Прикладной сценарий должен оставаться связанным с core differentiator продукта.",
|
||||
"Wordstat/SERP запускаются только после ручной фиксации semantic basis."
|
||||
],
|
||||
nextActions:
|
||||
asStringArray(rawOutput.nextActions).length > 0
|
||||
? asStringArray(rawOutput.nextActions)
|
||||
: ["Передать commercial demand framing в seo.normalization для формирования human-gated basis."],
|
||||
projectId: asString(rawOutput.projectId) ?? projectId,
|
||||
projectOntologyVersionId:
|
||||
asString(rawOutput.projectOntologyVersionId) ?? fallback.projectOntologyVersionId,
|
||||
provider: {
|
||||
message: asString(asRecord(rawOutput.provider).message) ?? "Commercial demand normalized by backend.",
|
||||
mode: "codex_workspace",
|
||||
modelRequired: true
|
||||
},
|
||||
rejectedAngles: asArray(rawOutput.rejectedAngles).map((item, index) => {
|
||||
const record = asRecord(item);
|
||||
|
||||
return {
|
||||
id: asString(record.id) ?? `rejected.${index + 1}`,
|
||||
reason: asString(record.reason) ?? "Недостаточно коммерческого buyer intent.",
|
||||
suggestedCommercialReframe: asString(record.suggestedCommercialReframe) ?? asString(record.suggestedReplacement),
|
||||
title: asString(record.title) ?? asString(record.phrase) ?? `Некоммерческий угол ${index + 1}`
|
||||
};
|
||||
}),
|
||||
schemaVersion: "seo-commercial-demand.v1",
|
||||
semanticRunId: asString(rawOutput.semanticRunId) ?? fallback.semanticRunId,
|
||||
sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback.modelTask.taskId,
|
||||
summary:
|
||||
asString(rawOutput.summary) ??
|
||||
`${commercialCore.coreBuyerProblem} ${commercialCore.coreCommercialValue}`.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCommercialDemandOutput(
|
||||
projectId: string,
|
||||
output: SeoCommercialDemandContract,
|
||||
providerMode: ProviderMode,
|
||||
providerMessage: string,
|
||||
fallback: SeoCommercialDemandModelProviderFallback
|
||||
) {
|
||||
const normalizedOutput = normalizeCommercialDemandShape(projectId, output, fallback);
|
||||
|
||||
if (normalizedOutput.schemaVersion !== "seo-commercial-demand.v1") {
|
||||
throw new Error("Commercial demand output schemaVersion должен быть seo-commercial-demand.v1.");
|
||||
}
|
||||
|
||||
if (normalizedOutput.projectId !== projectId || !normalizedOutput.semanticRunId || !normalizedOutput.sourceTaskId) {
|
||||
throw new Error("Commercial demand output должен иметь projectId, semanticRunId и sourceTaskId.");
|
||||
}
|
||||
|
||||
if (!normalizedOutput.commercialCore.coreBuyerProblem || !normalizedOutput.commercialCore.coreDifferentiator) {
|
||||
throw new Error("Commercial demand output должен иметь commercialCore.coreBuyerProblem и coreDifferentiator.");
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizedOutput,
|
||||
generatedAt: new Date().toISOString(),
|
||||
provider: {
|
||||
message: providerMessage,
|
||||
mode: providerMode,
|
||||
modelRequired: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackCommercialDemand(
|
||||
projectId: string,
|
||||
businessSynthesis: SeoBusinessSynthesisContract
|
||||
): SeoCommercialDemandContract {
|
||||
const task = buildSeoCommercialDemandTask(projectId, businessSynthesis);
|
||||
const coreDifferentiator = businessSynthesis.productFrame.coreOffer;
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-commercial-demand.v1",
|
||||
projectId,
|
||||
semanticRunId: businessSynthesis.semanticRunId,
|
||||
projectOntologyVersionId: businessSynthesis.projectOntologyVersionId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
provider: {
|
||||
mode: "deterministic_fallback",
|
||||
modelRequired: true,
|
||||
message: "Commercial demand fallback собран из business synthesis; model pass должен заменить архитектурные тезисы buyer-intent формулировками."
|
||||
},
|
||||
sourceTaskId: task.taskId,
|
||||
analystReview: getDefaultAnalystReview(),
|
||||
commercialCore: {
|
||||
buyingTrigger: "Покупатель ищет практическое решение вокруг выявленного ядра продукта.",
|
||||
confidence: businessSynthesis.productFrame.confidence,
|
||||
coreBuyerProblem: businessSynthesis.productFrame.centralThesis,
|
||||
coreCommercialValue: businessSynthesis.productFrame.coreOffer,
|
||||
coreDifferentiator,
|
||||
decisionContext: "Выбор, внедрение или пилот решения.",
|
||||
evidenceRefs: businessSynthesis.productFrame.evidenceRefs.slice(0, 8)
|
||||
},
|
||||
buyerIntentFamilies: businessSynthesis.demandFamilies.slice(0, 10).map((family, index) => ({
|
||||
buyerIntent: normalizeBuyerIntent(family.role, family.title),
|
||||
commercialAngle: family.rationale,
|
||||
coreQualifier: coreDifferentiator,
|
||||
evidenceRefs: [],
|
||||
excludedInformationalPatterns: [],
|
||||
grounding: family.grounding,
|
||||
id: `buyer.${family.id || index + 1}`,
|
||||
priority: family.priority,
|
||||
queryPatterns: family.phrases.slice(0, 8),
|
||||
sourceMeaning: family.title,
|
||||
title: family.title
|
||||
})),
|
||||
applicationCommercialAngles: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 10).map((vector, index) => ({
|
||||
commercialReframe: vector.queryDirection,
|
||||
coreQualifier: coreDifferentiator,
|
||||
evidenceRefs: vector.evidenceRefs.slice(0, 8),
|
||||
grounding: vector.grounding,
|
||||
id: `angle.${vector.id || index + 1}`,
|
||||
priority: vector.priority,
|
||||
queryPatterns: [],
|
||||
relationshipToCore: vector.relationshipToCore,
|
||||
sourceVector: vector.title
|
||||
})),
|
||||
rejectedAngles: [],
|
||||
guardrails: [
|
||||
"Fallback не доказывает рынок и не запускает Wordstat/SERP.",
|
||||
"Model pass должен заменить архитектурные тезисы покупательскими формулировками."
|
||||
],
|
||||
summary: businessSynthesis.summary,
|
||||
nextActions: ["Запустить seo.commercial_demand через model provider перед normalization."]
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestAlignedSeoCommercialDemand(
|
||||
projectId: string,
|
||||
semanticRunId: string
|
||||
): Promise<SeoCommercialDemandRun | null> {
|
||||
const result = await pool.query<SeoCommercialDemandRunRow>(
|
||||
`
|
||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'seo_commercial_demand'
|
||||
and output->>'semanticRunId' = $2
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId, semanticRunId]
|
||||
);
|
||||
|
||||
return result.rows[0] ? mapSeoCommercialDemandRun(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getSeoCommercialDemandContract(projectId: string): Promise<SeoCommercialDemandContract | null> {
|
||||
const analysis = await getLatestSemanticAnalysis(projectId);
|
||||
|
||||
if (!analysis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aligned = await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId);
|
||||
|
||||
if (aligned) {
|
||||
return aligned;
|
||||
}
|
||||
|
||||
const businessSynthesis = await getSeoBusinessSynthesisContract(projectId);
|
||||
|
||||
return businessSynthesis ? buildFallbackCommercialDemand(projectId, businessSynthesis) : null;
|
||||
}
|
||||
|
||||
export async function buildLatestSeoCommercialDemandTask(projectId: string): Promise<SeoCommercialDemandModelTaskContract | null> {
|
||||
const analysis = await getLatestSemanticAnalysis(projectId);
|
||||
|
||||
if (!analysis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const businessSynthesis = await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId);
|
||||
|
||||
return businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null;
|
||||
}
|
||||
|
||||
export async function saveSeoCommercialDemandFromModelProvider(
|
||||
projectId: string,
|
||||
output: SeoCommercialDemandContract,
|
||||
sourceModelTaskRunId: string,
|
||||
fallback: SeoCommercialDemandModelProviderFallback
|
||||
) {
|
||||
const normalizedOutput = normalizeCommercialDemandOutput(
|
||||
projectId,
|
||||
output,
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider выполнил seo.commercial_demand; результат сохранён как buyer-intent evidence.",
|
||||
fallback
|
||||
);
|
||||
const result = await pool.query<SeoCommercialDemandRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_commercial_demand', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
schemaVersion: "seo-commercial-demand-model-provider-input.v1",
|
||||
semanticRunId: normalizedOutput.semanticRunId,
|
||||
sourceModelTaskRunId,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.commercial_demand"
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapSeoCommercialDemandRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace Commercial demand.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
export function buildSeoCommercialDemandPromotionFallback(
|
||||
modelTask: SeoCommercialDemandModelTaskContract
|
||||
): SeoCommercialDemandModelProviderFallback {
|
||||
return {
|
||||
modelTask,
|
||||
projectOntologyVersionId: modelTask.projectOntologyVersionId,
|
||||
semanticRunId: modelTask.semanticRunId
|
||||
};
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ const envSchema = z.object({
|
|||
WORDSTAT_REGION_IDS: z.string().default("225"),
|
||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(12),
|
||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||
SERP_API_BASE_URL: z.string().url().optional(),
|
||||
SERP_API_TOKEN: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@ type SeoContextReviewRunRow = {
|
|||
|
||||
type ReviewLevel = "ok" | "warning" | "problem";
|
||||
type ContextReviewConfidence = "low" | "medium" | "high";
|
||||
type SeoContextReviewProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type SeoContextReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
|
||||
type SeoContextReviewModelProviderFallback = {
|
||||
projectOntologyVersionId: string | null;
|
||||
scanVersionId: string;
|
||||
semanticRunId: string;
|
||||
sourceTaskId: string;
|
||||
};
|
||||
|
||||
export type SeoContextReviewOutput = {
|
||||
schemaVersion: "seo-context-review.v1";
|
||||
|
|
@ -166,52 +173,316 @@ function getDefaultAnalystReview(): SeoContextReviewOutput["analystReview"] {
|
|||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return asArray(value).map(asString).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeConfidence(value: unknown, fallback: ContextReviewConfidence = "medium"): ContextReviewConfidence {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
|
||||
if (normalized === "high" || normalized === "medium" || normalized === "low") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized === "moderate" ? "medium" : fallback;
|
||||
}
|
||||
|
||||
function normalizeReviewLevel(value: unknown): ReviewLevel {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
|
||||
return normalized === "problem" || normalized === "warning" || normalized === "ok" ? normalized : "warning";
|
||||
}
|
||||
|
||||
function normalizeEvidenceRefs(value: unknown): string[] {
|
||||
return asArray(value)
|
||||
.map((item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return item.trim();
|
||||
}
|
||||
|
||||
const record = asRecord(item);
|
||||
const sourcePath = asString(record.sourcePath) ?? asString(record.ref) ?? asString(record.id);
|
||||
const reason = asString(record.reason) ?? asString(record.title);
|
||||
|
||||
return [sourcePath, reason].filter(Boolean).join(" · ") || `evidence:${index + 1}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function normalizeContextReviewShape(
|
||||
projectId: string,
|
||||
output: SeoContextReviewOutput,
|
||||
fallback?: SeoContextReviewModelProviderFallback
|
||||
): SeoContextReviewOutput {
|
||||
const rawOutput = asRecord(output);
|
||||
const rawSiteIdentity = asRecord(rawOutput.siteIdentity);
|
||||
const rawOffer = asRecord(rawOutput.offer);
|
||||
const rawAudience = asRecord(rawOutput.audience);
|
||||
const summary = asString(rawOutput.summary) ?? "Context Review model output сохранен после backend normalization.";
|
||||
const siteIdentityDescription =
|
||||
asString(rawSiteIdentity.description) ?? asString(rawSiteIdentity.identitySummary) ?? summary;
|
||||
const offerSummary = asString(rawOffer.summary) ?? asString(rawOffer.primaryOffer) ?? summary;
|
||||
const audiencePrimary =
|
||||
asString(rawAudience.primary) ??
|
||||
asStringArray(rawAudience.primaryAudiences)[0] ??
|
||||
asStringArray(rawAudience.decisionMakers)[0] ??
|
||||
"Целевая аудитория требует ручной проверки.";
|
||||
const providerReadiness = asRecord(rawOutput.readiness);
|
||||
const needsHumanReview = asBoolean(
|
||||
rawOutput.needsHumanReview,
|
||||
asBoolean(providerReadiness.needsHumanReview, true)
|
||||
);
|
||||
const confirmedClaims =
|
||||
asArray(rawOffer.confirmedClaims).length > 0
|
||||
? asArray(rawOffer.confirmedClaims).map((claim, index) => {
|
||||
const record = asRecord(claim);
|
||||
|
||||
return {
|
||||
confidence: normalizeConfidence(record.confidence),
|
||||
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
|
||||
id: asString(record.id) ?? `claim.${index + 1}`,
|
||||
title: asString(record.title) ?? asString(record.claim) ?? `Подтвержденный смысл ${index + 1}`
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
confidence: normalizeConfidence(rawOffer.confidence, "medium"),
|
||||
evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs),
|
||||
id: "claim.primary_offer",
|
||||
title: offerSummary
|
||||
},
|
||||
...asStringArray(rawOffer.supportingCapabilities)
|
||||
.slice(0, 8)
|
||||
.map((title, index) => ({
|
||||
confidence: normalizeConfidence(rawOffer.confidence, "medium"),
|
||||
evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs),
|
||||
id: `claim.capability.${index + 1}`,
|
||||
title
|
||||
}))
|
||||
];
|
||||
const pageRoles = asArray(rawOutput.pageRoles).map((role, index) => {
|
||||
const record = asRecord(role);
|
||||
const rawRole = asString(record.role);
|
||||
const normalizedRole: SeoContextReviewOutput["pageRoles"][number]["role"] =
|
||||
rawRole === "content_source" || asString(record.scope) === "content_source"
|
||||
? "content_source"
|
||||
: rawRole === "template_block" || asString(record.scope) === "template_block"
|
||||
? "template_block"
|
||||
: rawRole === "admin_or_technical" || rawRole === "non_seo_admin_surface" || asString(record.scope) === "admin_page"
|
||||
? "admin_or_technical"
|
||||
: rawRole === "primary_landing" || rawRole === "primary_product_landing" || index === 0
|
||||
? "primary_landing"
|
||||
: "supporting_page";
|
||||
|
||||
return {
|
||||
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
|
||||
pageId: asString(record.pageId) ?? asString(record.id) ?? `page.${index + 1}`,
|
||||
path: asString(record.path) ?? asString(record.urlPath),
|
||||
reason: asString(record.reason) ?? asString(record.risk) ?? "Model provider page role.",
|
||||
role: normalizedRole,
|
||||
scope: asString(record.scope) ?? "unknown",
|
||||
title: asString(record.title) ?? `Страница ${index + 1}`
|
||||
};
|
||||
});
|
||||
const sectionFindings = asArray(rawOutput.sectionFindings).map((finding, index) => {
|
||||
const record = asRecord(finding);
|
||||
const status = asString(record.status);
|
||||
const evidenceLevel = asString(record.evidenceLevel);
|
||||
const normalizedEvidenceLevel: SeoContextReviewOutput["sectionFindings"][number]["evidenceLevel"] =
|
||||
evidenceLevel === "none" || evidenceLevel === "thin" || evidenceLevel === "moderate" || evidenceLevel === "strong"
|
||||
? evidenceLevel
|
||||
: normalizeConfidence(record.confidence) === "high"
|
||||
? "moderate"
|
||||
: "thin";
|
||||
const normalizedStatus: SeoContextReviewOutput["sectionFindings"][number]["status"] =
|
||||
status === "covered" || status === "weak" || status === "missing"
|
||||
? status
|
||||
: asBoolean(record.needsHumanReview)
|
||||
? "weak"
|
||||
: "covered";
|
||||
|
||||
return {
|
||||
confidence: normalizeConfidence(record.confidence),
|
||||
evidenceLevel: normalizedEvidenceLevel,
|
||||
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
|
||||
id: asString(record.id) ?? `section.${index + 1}`,
|
||||
status: normalizedStatus,
|
||||
targetIntent: asString(record.targetIntent) ?? asString(record.risk) ?? "Проверить смысл секции.",
|
||||
title: asString(record.title) ?? `Секция ${index + 1}`
|
||||
};
|
||||
});
|
||||
const semanticGaps = asArray(rawOutput.semanticGaps).map((gap, index) => {
|
||||
const record = asRecord(gap);
|
||||
const priority = asString(record.priority);
|
||||
const normalizedPriority: SeoContextReviewOutput["semanticGaps"][number]["priority"] =
|
||||
priority === "high" || priority === "medium" || priority === "low" ? priority : "medium";
|
||||
|
||||
return {
|
||||
id: asString(record.id) ?? `gap.${index + 1}`,
|
||||
priority: normalizedPriority,
|
||||
reason: asString(record.reason) ?? asString(record.description) ?? "Нужна ручная проверка.",
|
||||
suggestedReview: asString(record.suggestedReview) ?? "Проверить перед отправкой в спрос.",
|
||||
title: asString(record.title) ?? `Семантический пробел ${index + 1}`
|
||||
};
|
||||
});
|
||||
const forbiddenClaims = asArray(rawOutput.forbiddenClaims).map((claim, index) => {
|
||||
const record = asRecord(claim);
|
||||
const source = asString(record.source);
|
||||
const normalizedSource: SeoContextReviewOutput["forbiddenClaims"][number]["source"] =
|
||||
source === "semantic_gap" || source === "weak_evidence" || source === "guardrail" ? source : "guardrail";
|
||||
|
||||
return {
|
||||
claim: typeof claim === "string" ? claim : asString(record.claim) ?? asString(record.title) ?? `Запрет ${index + 1}`,
|
||||
id: asString(record.id) ?? `forbidden.${index + 1}`,
|
||||
reason: asString(record.reason) ?? "Guardrail из Context Review.",
|
||||
source: normalizedSource
|
||||
};
|
||||
});
|
||||
const normalizationHints = asArray(rawOutput.normalizationHints).map((hint, index) => {
|
||||
const record = asRecord(hint);
|
||||
|
||||
return {
|
||||
clusterId: asString(record.clusterId) ?? asString(record.id) ?? `hint.${index + 1}`,
|
||||
id: asString(record.id) ?? `hint.${index + 1}`,
|
||||
marketAngles: asStringArray(record.marketAngles),
|
||||
reason: asString(record.reason) ?? asString(record.hint) ?? "Normalization hint from model provider.",
|
||||
reviewRequired: asBoolean(record.reviewRequired, true),
|
||||
sourcePhrases: asStringArray(record.sourcePhrases),
|
||||
stopPhrases: asStringArray(record.stopPhrases),
|
||||
targetIntent: asString(record.targetIntent) ?? asString(record.title) ?? "Проверить нормализацию.",
|
||||
title: asString(record.title) ?? `Подсказка нормализации ${index + 1}`
|
||||
};
|
||||
});
|
||||
const risks = asArray(rawOutput.risks).map((risk, index) => {
|
||||
const record = asRecord(risk);
|
||||
|
||||
return {
|
||||
id: asString(record.id) ?? `risk.${index + 1}`,
|
||||
level: normalizeReviewLevel(record.level),
|
||||
message: asString(record.message) ?? asString(record.reason) ?? "Risk from model provider.",
|
||||
title: asString(record.title) ?? `Риск ${index + 1}`
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...output,
|
||||
analystReview: output.analystReview ?? getDefaultAnalystReview(),
|
||||
audience: {
|
||||
confidence: normalizeConfidence(rawAudience.confidence),
|
||||
primary: audiencePrimary,
|
||||
reason: asString(rawAudience.reason) ?? "Audience normalized from model provider output.",
|
||||
secondary: asStringArray(rawAudience.secondary)
|
||||
},
|
||||
forbiddenClaims,
|
||||
needsHumanReview,
|
||||
normalizationHints,
|
||||
offer: {
|
||||
confirmedClaims,
|
||||
summary: offerSummary,
|
||||
weakClaims: asArray(rawOffer.weakClaims).map((claim, index) => {
|
||||
const record = asRecord(claim);
|
||||
|
||||
return {
|
||||
id: asString(record.id) ?? `weak_claim.${index + 1}`,
|
||||
reason: asString(record.reason) ?? "Нужна проверка доказательности.",
|
||||
title: asString(record.title) ?? asString(record.claim) ?? `Слабое утверждение ${index + 1}`
|
||||
};
|
||||
})
|
||||
},
|
||||
pageRoles,
|
||||
projectId: asString(rawOutput.projectId) ?? projectId,
|
||||
projectOntologyVersionId: asString(rawOutput.projectOntologyVersionId) ?? fallback?.projectOntologyVersionId ?? null,
|
||||
readiness: {
|
||||
evidenceRefCount: Number(providerReadiness.evidenceRefCount) || normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs).length,
|
||||
forbiddenClaimCount: forbiddenClaims.length,
|
||||
needsHumanReview,
|
||||
normalizationHintCount: normalizationHints.length,
|
||||
pageRoleCount: pageRoles.length,
|
||||
semanticGapCount: semanticGaps.length
|
||||
},
|
||||
risks,
|
||||
scanVersionId: asString(rawOutput.scanVersionId) ?? fallback?.scanVersionId ?? "",
|
||||
schemaVersion: "seo-context-review.v1",
|
||||
sectionFindings,
|
||||
semanticGaps,
|
||||
semanticRunId: asString(rawOutput.semanticRunId) ?? fallback?.semanticRunId ?? "",
|
||||
siteIdentity: {
|
||||
brandName: asString(rawSiteIdentity.brandName) ?? "Проект",
|
||||
confidence: normalizeConfidence(rawSiteIdentity.confidence),
|
||||
description: siteIdentityDescription,
|
||||
evidenceRefs: normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs)
|
||||
},
|
||||
sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback?.sourceTaskId ?? "",
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContextReviewOutput(
|
||||
projectId: string,
|
||||
output: SeoContextReviewOutput,
|
||||
providerMode: SeoContextReviewProviderMode,
|
||||
providerMessage: string
|
||||
providerMessage: string,
|
||||
fallback?: SeoContextReviewModelProviderFallback
|
||||
): SeoContextReviewOutput {
|
||||
if (output.schemaVersion !== "seo-context-review.v1") {
|
||||
const normalizedShape = normalizeContextReviewShape(projectId, output, fallback);
|
||||
|
||||
if (normalizedShape.schemaVersion !== "seo-context-review.v1") {
|
||||
throw new Error("Context Review output schemaVersion должен быть seo-context-review.v1.");
|
||||
}
|
||||
|
||||
if (output.projectId !== projectId) {
|
||||
if (normalizedShape.projectId !== projectId) {
|
||||
throw new Error("Context Review output projectId не совпадает с проектом.");
|
||||
}
|
||||
|
||||
if (!output.semanticRunId || !output.scanVersionId || !output.sourceTaskId) {
|
||||
if (!normalizedShape.semanticRunId || !normalizedShape.scanVersionId || !normalizedShape.sourceTaskId) {
|
||||
throw new Error("Context Review output должен иметь semanticRunId, scanVersionId и sourceTaskId.");
|
||||
}
|
||||
|
||||
if (!output.siteIdentity?.description || !output.offer?.summary || !output.summary) {
|
||||
if (!normalizedShape.siteIdentity?.description || !normalizedShape.offer?.summary || !normalizedShape.summary) {
|
||||
throw new Error("Context Review output должен иметь siteIdentity.description, offer.summary и summary.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(output.pageRoles) || !Array.isArray(output.sectionFindings)) {
|
||||
if (!Array.isArray(normalizedShape.pageRoles) || !Array.isArray(normalizedShape.sectionFindings)) {
|
||||
throw new Error("Context Review output должен иметь pageRoles и sectionFindings.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(output.semanticGaps) || !Array.isArray(output.forbiddenClaims) || !Array.isArray(output.normalizationHints)) {
|
||||
if (!Array.isArray(normalizedShape.semanticGaps) || !Array.isArray(normalizedShape.forbiddenClaims) || !Array.isArray(normalizedShape.normalizationHints)) {
|
||||
throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints.");
|
||||
}
|
||||
|
||||
return {
|
||||
...output,
|
||||
...normalizedShape,
|
||||
generatedAt: new Date().toISOString(),
|
||||
provider: {
|
||||
message: providerMessage,
|
||||
mode: providerMode,
|
||||
modelRequired: true
|
||||
},
|
||||
analystReview: output.analystReview ?? getDefaultAnalystReview(),
|
||||
analystReview: normalizedShape.analystReview ?? getDefaultAnalystReview(),
|
||||
readiness: {
|
||||
...output.readiness,
|
||||
forbiddenClaimCount: output.forbiddenClaims.length,
|
||||
normalizationHintCount: output.normalizationHints.length,
|
||||
pageRoleCount: output.pageRoles.length,
|
||||
semanticGapCount: output.semanticGaps.length,
|
||||
needsHumanReview: output.needsHumanReview
|
||||
...normalizedShape.readiness,
|
||||
forbiddenClaimCount: normalizedShape.forbiddenClaims.length,
|
||||
normalizationHintCount: normalizedShape.normalizationHints.length,
|
||||
pageRoleCount: normalizedShape.pageRoles.length,
|
||||
semanticGapCount: normalizedShape.semanticGaps.length,
|
||||
needsHumanReview: normalizedShape.needsHumanReview
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -541,3 +812,45 @@ export async function saveSeoContextReviewManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveSeoContextReviewFromModelProvider(
|
||||
projectId: string,
|
||||
output: SeoContextReviewOutput,
|
||||
sourceModelTaskRunId: string,
|
||||
fallback: SeoContextReviewModelProviderFallback
|
||||
): Promise<SeoContextReviewRun> {
|
||||
const normalizedOutput = normalizeContextReviewOutput(
|
||||
projectId,
|
||||
output,
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider вернул seo.context_review task как structured JSON; backend schema validation passed.",
|
||||
fallback
|
||||
);
|
||||
const result = await pool.query<SeoContextReviewRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_context_review', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
scanVersionId: normalizedOutput.scanVersionId,
|
||||
schemaVersion: "seo-context-review-model-provider-input.v1",
|
||||
semanticRunId: normalizedOutput.semanticRunId,
|
||||
sourceModelTaskRunId,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.context_review"
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace Context Review.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
|
||||
type AnchorDecisionStatus = "approved" | "disabled" | "pending";
|
||||
type AnchorDecisionStatus = "approved" | "deleted" | "disabled" | "pending";
|
||||
type AnchorDecisionSource = "human" | "model" | "system";
|
||||
type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready";
|
||||
export type DemandCollectionProfile = "contextual" | "market_wide";
|
||||
|
||||
export const DEFAULT_DEMAND_COLLECTION_PROFILE: DemandCollectionProfile = "contextual";
|
||||
type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number];
|
||||
|
||||
export type AnchorReviewDecisionInput = {
|
||||
|
|
@ -80,6 +83,7 @@ export type AnchorReviewContract = {
|
|||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
state: AnchorReviewState;
|
||||
demandCollectionProfile: DemandCollectionProfile;
|
||||
source: {
|
||||
normalizationGeneratedAt: string | null;
|
||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||
|
|
@ -104,6 +108,13 @@ export type AnchorReviewContract = {
|
|||
manualDecisionCount: number;
|
||||
};
|
||||
anchors: AnchorReviewAnchor[];
|
||||
deletedAnchors: Array<{
|
||||
deletedAt: string | null;
|
||||
id: string;
|
||||
key: string;
|
||||
phrase: string;
|
||||
reason: string;
|
||||
}>;
|
||||
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
||||
nextActions: string[];
|
||||
};
|
||||
|
|
@ -124,6 +135,10 @@ function normalizePhrase(value: string) {
|
|||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeDemandCollectionProfile(value: unknown): DemandCollectionProfile {
|
||||
return value === "market_wide" ? "market_wide" : DEFAULT_DEMAND_COLLECTION_PROFILE;
|
||||
}
|
||||
|
||||
function getAnchorKey(seed: Pick<SeoNormalizationSeed, "clusterId" | "phrase">) {
|
||||
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
||||
}
|
||||
|
|
@ -153,6 +168,18 @@ function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision {
|
|||
}
|
||||
|
||||
function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): AnchorReviewDecision {
|
||||
if (decision.status === "deleted") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
decidedBy: decision.decidedBy,
|
||||
reason: decision.reason || "Anchor deleted by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: decision.source,
|
||||
status: "deleted"
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.status === "approved") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
|
|
@ -189,15 +216,47 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
|
|||
}
|
||||
|
||||
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
||||
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||
const anchorSnapshots = (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||
anchor,
|
||||
decision: anchor.decision,
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
}));
|
||||
const deletedSnapshots =
|
||||
savedOutput?.deletedAnchors?.map((anchor) => ({
|
||||
decision: {
|
||||
decidedAt: anchor.deletedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: anchor.reason || "Anchor deleted by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human" as const,
|
||||
status: "deleted" as const
|
||||
},
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
})) ?? [];
|
||||
|
||||
return [...anchorSnapshots, ...deletedSnapshots];
|
||||
}
|
||||
|
||||
function getSavedDemandCollectionProfile(savedOutput: AnchorReviewContract | null): DemandCollectionProfile {
|
||||
return normalizeDemandCollectionProfile(savedOutput?.demandCollectionProfile);
|
||||
}
|
||||
|
||||
function normalizeSavedManualDecision(decision: AnchorReviewDecision): AnchorReviewDecision {
|
||||
if (decision.status === "deleted") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
decidedBy: decision.decidedBy,
|
||||
reason: decision.reason || "Manual anchor deleted by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: decision.source,
|
||||
status: "deleted"
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.status === "approved") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
|
|
@ -312,6 +371,16 @@ function buildWordstatQueue(anchors: AnchorReviewAnchor[]): AnchorReviewWordstat
|
|||
}));
|
||||
}
|
||||
|
||||
function mapDeletedAnchor(anchor: AnchorReviewAnchor) {
|
||||
return {
|
||||
deletedAt: anchor.decision.decidedAt,
|
||||
id: anchor.id,
|
||||
key: anchor.key,
|
||||
phrase: anchor.phrase,
|
||||
reason: anchor.decision.reason || "Anchor deleted by review."
|
||||
};
|
||||
}
|
||||
|
||||
function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
|
||||
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
||||
return "not_ready";
|
||||
|
|
@ -324,6 +393,18 @@ function getState(normalization: SeoNormalizationContract, anchors: AnchorReview
|
|||
return wordstatQueueCount > 0 ? "approved" : "needs_review";
|
||||
}
|
||||
|
||||
function getStateFromContract(contract: AnchorReviewContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
|
||||
if (contract.source.normalizationState !== "ready" || !contract.semanticRunId) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
||||
if (anchors.length === 0) {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
return wordstatQueueCount > 0 ? "approved" : "needs_review";
|
||||
}
|
||||
|
||||
function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||
const actions: string[] = [];
|
||||
|
||||
|
|
@ -360,7 +441,8 @@ function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
|||
function buildContract(
|
||||
projectId: string,
|
||||
normalization: SeoNormalizationContract,
|
||||
decisionSnapshots: DecisionSnapshot[] = []
|
||||
decisionSnapshots: DecisionSnapshot[] = [],
|
||||
demandCollectionProfile: DemandCollectionProfile = DEFAULT_DEMAND_COLLECTION_PROFILE
|
||||
): AnchorReviewContract {
|
||||
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
||||
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot]));
|
||||
|
|
@ -369,7 +451,11 @@ function buildContract(
|
|||
);
|
||||
const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
|
||||
const allAnchors = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
|
||||
const allAnchorCandidates = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
|
||||
const allAnchors = allAnchorCandidates.filter((anchor) => anchor.decision.status !== "deleted");
|
||||
const deletedAnchors = allAnchorCandidates
|
||||
.filter((anchor) => anchor.decision.status === "deleted")
|
||||
.map(mapDeletedAnchor);
|
||||
const wordstatQueue = buildWordstatQueue(allAnchors);
|
||||
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
||||
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
||||
|
|
@ -382,6 +468,7 @@ function buildContract(
|
|||
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getState(normalization, allAnchors, wordstatQueue.length),
|
||||
demandCollectionProfile,
|
||||
source: {
|
||||
normalizationGeneratedAt: normalization.generatedAt,
|
||||
normalizationProviderMode: normalization.provider.mode,
|
||||
|
|
@ -409,6 +496,55 @@ function buildContract(
|
|||
wordstatQueueCount: wordstatQueue.length
|
||||
},
|
||||
anchors: allAnchors,
|
||||
deletedAnchors,
|
||||
wordstatQueue
|
||||
};
|
||||
|
||||
return {
|
||||
...contractWithoutActions,
|
||||
nextActions: buildNextActions(contractWithoutActions)
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildContractWithAnchors(contract: AnchorReviewContract, anchors: AnchorReviewAnchor[]): AnchorReviewContract {
|
||||
const deletedAnchorById = new Map((contract.deletedAnchors ?? []).map((anchor) => [anchor.id, anchor]));
|
||||
|
||||
anchors
|
||||
.filter((anchor) => anchor.decision.status === "deleted")
|
||||
.forEach((anchor) => deletedAnchorById.set(anchor.id, mapDeletedAnchor(anchor)));
|
||||
|
||||
const visibleAnchors = anchors.filter((anchor) => anchor.decision.status !== "deleted");
|
||||
const wordstatQueue = buildWordstatQueue(visibleAnchors);
|
||||
const pendingAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
||||
const approvedAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
||||
const disabledAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "disabled").length;
|
||||
const manualDecisionCount = visibleAnchors.filter((anchor) => anchor.decision.decidedAt).length;
|
||||
const contractWithoutActions: Omit<AnchorReviewContract, "nextActions"> = {
|
||||
...contract,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getStateFromContract(contract, visibleAnchors, wordstatQueue.length),
|
||||
analystReview: {
|
||||
notes:
|
||||
wordstatQueue.length > 0
|
||||
? ["Human-approved anchor queue exists. Market collection must use only wordstatQueue."]
|
||||
: ["Anchor queue awaits human approval before market collection."],
|
||||
reviewedAt: manualDecisionCount > 0 ? anchors.find((anchor) => anchor.decision.decidedAt)?.decision.decidedAt ?? null : null,
|
||||
reviewer: manualDecisionCount > 0 ? "human" : "system",
|
||||
status: wordstatQueue.length > 0 ? "approved" : "not_reviewed"
|
||||
},
|
||||
readiness: {
|
||||
...contract.readiness,
|
||||
approvedAnchorCount,
|
||||
disabledAnchorCount,
|
||||
manualDecisionCount,
|
||||
needsHumanReviewCount: visibleAnchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
|
||||
normalizedAnchorCount: visibleAnchors.length,
|
||||
pendingAnchorCount,
|
||||
proposedWordstatCount: visibleAnchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
|
||||
wordstatQueueCount: wordstatQueue.length
|
||||
},
|
||||
anchors: visibleAnchors,
|
||||
deletedAnchors: Array.from(deletedAnchorById.values()),
|
||||
wordstatQueue
|
||||
};
|
||||
|
||||
|
|
@ -446,7 +582,12 @@ export async function getAnchorReviewContract(projectId: string): Promise<Anchor
|
|||
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
||||
: null;
|
||||
|
||||
return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput));
|
||||
return buildContract(
|
||||
projectId,
|
||||
normalization,
|
||||
getSavedDecisionSnapshots(savedOutput),
|
||||
getSavedDemandCollectionProfile(savedOutput)
|
||||
);
|
||||
}
|
||||
|
||||
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
|
||||
|
|
@ -484,6 +625,18 @@ function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecis
|
|||
};
|
||||
}
|
||||
|
||||
if (input.status === "deleted") {
|
||||
return {
|
||||
decidedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: input.reason?.trim() || "Human deleted this anchor from semantic basis.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human",
|
||||
status: "deleted"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decidedAt,
|
||||
decidedBy: "dev-mode",
|
||||
|
|
@ -495,9 +648,36 @@ function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecis
|
|||
};
|
||||
}
|
||||
|
||||
function applyDecisionInputsToContract(
|
||||
contract: AnchorReviewContract,
|
||||
inputs: AnchorReviewDecisionInput[],
|
||||
decidedAt: string
|
||||
): AnchorReviewContract {
|
||||
if (inputs.length === 0) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
const inputsByAnchorId = new Map(inputs.map((input) => [input.anchorId, input]));
|
||||
const anchors = contract.anchors.map((anchor) => {
|
||||
const input = inputsByAnchorId.get(anchor.id);
|
||||
|
||||
if (!input) {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
return {
|
||||
...anchor,
|
||||
decision: applyDecisionInput(anchor, input, decidedAt)
|
||||
};
|
||||
});
|
||||
|
||||
return rebuildContractWithAnchors(contract, anchors);
|
||||
}
|
||||
|
||||
export async function saveAnchorReviewDecisions(
|
||||
projectId: string,
|
||||
decisions: AnchorReviewDecisionInput[] = []
|
||||
decisions?: AnchorReviewDecisionInput[],
|
||||
demandCollectionProfile?: DemandCollectionProfile
|
||||
): Promise<AnchorReviewContract> {
|
||||
const current = await getAnchorReviewContract(projectId);
|
||||
|
||||
|
|
@ -508,18 +688,25 @@ export async function saveAnchorReviewDecisions(
|
|||
const decidedAt = new Date().toISOString();
|
||||
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
||||
const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision]));
|
||||
const hasExplicitDecisions = Array.isArray(decisions);
|
||||
const explicitDecisions = decisions ?? [];
|
||||
const nextDemandCollectionProfile = normalizeDemandCollectionProfile(
|
||||
demandCollectionProfile ?? current.demandCollectionProfile
|
||||
);
|
||||
const inputs =
|
||||
decisions.length > 0
|
||||
? decisions
|
||||
: current.anchors
|
||||
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
||||
.map((anchor) => ({
|
||||
anchorId: anchor.id,
|
||||
reason: "Human approved proposed Wordstat anchor queue.",
|
||||
sendToSerp: anchor.proposal.sendToSerp,
|
||||
sendToWordstat: true,
|
||||
status: "approved" as const
|
||||
}));
|
||||
explicitDecisions.length > 0
|
||||
? explicitDecisions
|
||||
: !hasExplicitDecisions && demandCollectionProfile === undefined
|
||||
? current.anchors
|
||||
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
||||
.map((anchor) => ({
|
||||
anchorId: anchor.id,
|
||||
reason: "Human approved proposed Wordstat anchor queue.",
|
||||
sendToSerp: anchor.proposal.sendToSerp,
|
||||
sendToWordstat: true,
|
||||
status: "approved" as const
|
||||
}))
|
||||
: [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const anchor = anchorsById.get(input.anchorId);
|
||||
|
|
@ -531,14 +718,33 @@ export async function saveAnchorReviewDecisions(
|
|||
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
||||
}
|
||||
|
||||
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
||||
anchor,
|
||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
}));
|
||||
const decisionSnapshots: DecisionSnapshot[] = [
|
||||
...current.anchors.map((anchor) => ({
|
||||
anchor,
|
||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
})),
|
||||
...(current.deletedAnchors ?? []).map((anchor) => ({
|
||||
decision: {
|
||||
decidedAt: anchor.deletedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: anchor.reason || "Anchor deleted by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human" as const,
|
||||
status: "deleted" as const
|
||||
},
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
}))
|
||||
];
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const output = buildContract(projectId, normalization, decisionSnapshots);
|
||||
const output = applyDecisionInputsToContract(
|
||||
buildContract(projectId, normalization, decisionSnapshots, nextDemandCollectionProfile),
|
||||
inputs,
|
||||
decidedAt
|
||||
);
|
||||
const result = await pool.query<AnchorReviewRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
|
|
@ -549,6 +755,7 @@ export async function saveAnchorReviewDecisions(
|
|||
projectId,
|
||||
JSON.stringify({
|
||||
decisionCount: inputs.length,
|
||||
demandCollectionProfile: output.demandCollectionProfile,
|
||||
schemaVersion: "anchor-review-input.v1",
|
||||
semanticRunId: output.semanticRunId
|
||||
}),
|
||||
|
|
@ -655,6 +862,19 @@ export async function addAnchorReviewManualAnchor(
|
|||
id: anchor.id,
|
||||
key: anchor.key
|
||||
})),
|
||||
...(current.deletedAnchors ?? []).map((anchor) => ({
|
||||
decision: {
|
||||
decidedAt: anchor.deletedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: anchor.reason || "Anchor deleted by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human" as const,
|
||||
status: "deleted" as const
|
||||
},
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
})),
|
||||
{
|
||||
anchor: manualAnchor,
|
||||
decision: manualAnchor.decision,
|
||||
|
|
@ -663,7 +883,7 @@ export async function addAnchorReviewManualAnchor(
|
|||
}
|
||||
];
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const output = buildContract(projectId, normalization, decisionSnapshots);
|
||||
const output = buildContract(projectId, normalization, decisionSnapshots, current.demandCollectionProfile);
|
||||
const result = await pool.query<AnchorReviewRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,768 @@
|
|||
import crypto from "node:crypto";
|
||||
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 { getSeoModelProviderStatusContract, runSeoModelTask } from "../modelProvider/seoModelTaskRuns.js";
|
||||
import { updateProjectOntologyApproval } from "../ontology/projectOntologyRepository.js";
|
||||
|
||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
type KeywordAnalysisWorkflowStatus = "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
|
||||
type KeywordAnalysisWorkflowStepId =
|
||||
| "semantic_basis"
|
||||
| "project_context"
|
||||
| "demand_collection"
|
||||
| "keyword_cleaning"
|
||||
| "keyword_proposal";
|
||||
type KeywordAnalysisWorkflowStepStatus = "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
|
||||
|
||||
export type KeywordAnalysisWorkflowStep = {
|
||||
id: KeywordAnalysisWorkflowStepId;
|
||||
label: string;
|
||||
status: KeywordAnalysisWorkflowStepStatus;
|
||||
detail: string;
|
||||
reason: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type KeywordAnalysisWorkflowContract = {
|
||||
schemaVersion: "keyword-analysis-workflow.v1";
|
||||
runId: string;
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
generatedAt: string;
|
||||
status: KeywordAnalysisWorkflowStatus;
|
||||
statusLabel: string;
|
||||
activeStepId: KeywordAnalysisWorkflowStepId | null;
|
||||
activeStepLabel: string | null;
|
||||
summary: string;
|
||||
reason: string | null;
|
||||
steps: KeywordAnalysisWorkflowStep[];
|
||||
artifacts: {
|
||||
keywordCleaningRunId: string | null;
|
||||
marketGeneratedAt: string | null;
|
||||
modelTaskRunId: string | null;
|
||||
wordstatJobId: string | null;
|
||||
};
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
type KeywordAnalysisWorkflowRunRow = {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
output: KeywordAnalysisWorkflowContract | null;
|
||||
error_message: string | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
const WORKFLOW_RUN_TYPE = "keyword_analysis_workflow";
|
||||
const activeWorkflowExecutions = new Set<string>();
|
||||
|
||||
const WORKFLOW_STEPS: Array<Pick<KeywordAnalysisWorkflowStep, "detail" | "id" | "label">> = [
|
||||
{
|
||||
detail: "Проверяем, что пользователь выбрал смысловые якоря и разрешил очередь для анализа.",
|
||||
id: "semantic_basis",
|
||||
label: "Семантический базис"
|
||||
},
|
||||
{
|
||||
detail: "Проверяем проектный контекст и готовность онтологии перед внешними данными.",
|
||||
id: "project_context",
|
||||
label: "Проектный контекст"
|
||||
},
|
||||
{
|
||||
detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.",
|
||||
id: "demand_collection",
|
||||
label: "Рыночный спрос"
|
||||
},
|
||||
{
|
||||
detail: "Передаем собранный рынок в модельную очистку и группировку ключей.",
|
||||
id: "keyword_cleaning",
|
||||
label: "Разбор ключей"
|
||||
},
|
||||
{
|
||||
detail: "Готовим человекочитаемое предложение по ключам для следующего этапа.",
|
||||
id: "keyword_proposal",
|
||||
label: "Предложение ключей"
|
||||
}
|
||||
];
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function getDemandCollectionProfileLabel(profile: "contextual" | "market_wide") {
|
||||
return profile === "market_wide" ? "максимальный рыночный охват" : "контекстный охват";
|
||||
}
|
||||
|
||||
function isModelTaskRunning(status: string) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function getWorkflowStatusLabel(status: KeywordAnalysisWorkflowStatus) {
|
||||
const labels: Record<KeywordAnalysisWorkflowStatus, string> = {
|
||||
blocked: "Остановлено",
|
||||
completed: "Анализ готов",
|
||||
failed: "Ошибка",
|
||||
queued: "В очереди",
|
||||
running: "Идет анализ",
|
||||
waiting: "Ожидаем результат"
|
||||
};
|
||||
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function getStepLabel(stepId: KeywordAnalysisWorkflowStepId) {
|
||||
return WORKFLOW_STEPS.find((step) => step.id === stepId)?.label ?? null;
|
||||
}
|
||||
|
||||
function buildInitialSteps(): KeywordAnalysisWorkflowStep[] {
|
||||
return WORKFLOW_STEPS.map((step) => ({
|
||||
...step,
|
||||
completedAt: null,
|
||||
reason: null,
|
||||
startedAt: null,
|
||||
status: "pending"
|
||||
}));
|
||||
}
|
||||
|
||||
function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysisWorkflowContract {
|
||||
return {
|
||||
schemaVersion: "keyword-analysis-workflow.v1",
|
||||
runId,
|
||||
projectId,
|
||||
semanticRunId: null,
|
||||
generatedAt: nowIso(),
|
||||
status: "running",
|
||||
statusLabel: getWorkflowStatusLabel("running"),
|
||||
activeStepId: "semantic_basis",
|
||||
activeStepLabel: getStepLabel("semantic_basis"),
|
||||
summary: "Запускаем анализ выбранных ключей.",
|
||||
reason: null,
|
||||
steps: buildInitialSteps(),
|
||||
artifacts: {
|
||||
keywordCleaningRunId: null,
|
||||
marketGeneratedAt: null,
|
||||
modelTaskRunId: null,
|
||||
wordstatJobId: null
|
||||
},
|
||||
nextActions: []
|
||||
};
|
||||
}
|
||||
|
||||
function setWorkflowStatus(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
status: KeywordAnalysisWorkflowStatus,
|
||||
patch: Partial<Pick<KeywordAnalysisWorkflowContract, "activeStepId" | "reason" | "summary">> = {}
|
||||
): KeywordAnalysisWorkflowContract {
|
||||
const activeStepId = Object.hasOwn(patch, "activeStepId") ? (patch.activeStepId ?? null) : contract.activeStepId;
|
||||
|
||||
return {
|
||||
...contract,
|
||||
...patch,
|
||||
activeStepId,
|
||||
activeStepLabel: activeStepId ? getStepLabel(activeStepId) : null,
|
||||
generatedAt: nowIso(),
|
||||
status,
|
||||
statusLabel: getWorkflowStatusLabel(status)
|
||||
};
|
||||
}
|
||||
|
||||
function setStepStatus(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
stepId: KeywordAnalysisWorkflowStepId,
|
||||
status: KeywordAnalysisWorkflowStepStatus,
|
||||
patch: Partial<Pick<KeywordAnalysisWorkflowStep, "detail" | "reason">> = {}
|
||||
): KeywordAnalysisWorkflowContract {
|
||||
const timestamp = nowIso();
|
||||
|
||||
return {
|
||||
...contract,
|
||||
activeStepId: status === "completed" ? contract.activeStepId : stepId,
|
||||
activeStepLabel: status === "completed" ? contract.activeStepLabel : getStepLabel(stepId),
|
||||
generatedAt: timestamp,
|
||||
steps: contract.steps.map((step) => {
|
||||
if (step.id !== stepId) {
|
||||
return step;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
...patch,
|
||||
completedAt:
|
||||
status === "blocked" || status === "completed" || status === "failed" ? step.completedAt ?? timestamp : step.completedAt,
|
||||
reason: Object.hasOwn(patch, "reason") ? (patch.reason ?? null) : step.reason,
|
||||
startedAt: step.startedAt ?? timestamp,
|
||||
status
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function saveWorkflow(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
runStatus: RunStatus,
|
||||
errorMessage: string | null = null
|
||||
) {
|
||||
const completed = runStatus === "done" || runStatus === "failed" || runStatus === "cancelled";
|
||||
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
|
||||
`
|
||||
update runs
|
||||
set status = $2::run_status,
|
||||
output = $3::jsonb,
|
||||
error_message = $4,
|
||||
completed_at = case when $5 then coalesce(completed_at, now()) else completed_at end
|
||||
where id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
|
||||
returning id, status, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[contract.runId, runStatus, JSON.stringify(contract), errorMessage, completed]
|
||||
);
|
||||
|
||||
return mapWorkflowRow(result.rows[0]);
|
||||
}
|
||||
|
||||
function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) {
|
||||
return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? row.output : null;
|
||||
}
|
||||
|
||||
async function getWorkflowRun(runId: string) {
|
||||
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
|
||||
`
|
||||
select id, status, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
|
||||
limit 1;
|
||||
`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function blockWorkflow(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
stepId: KeywordAnalysisWorkflowStepId,
|
||||
reason: string,
|
||||
nextActions: string[] = []
|
||||
) {
|
||||
const blocked = setWorkflowStatus(setStepStatus(contract, stepId, "blocked", { reason }), "blocked", {
|
||||
activeStepId: stepId,
|
||||
reason,
|
||||
summary: `${getStepLabel(stepId) ?? "Шаг"} остановлен: ${reason}`
|
||||
});
|
||||
|
||||
blocked.nextActions = nextActions.length > 0 ? nextActions : [reason];
|
||||
|
||||
return saveWorkflow(blocked, "done");
|
||||
}
|
||||
|
||||
async function failWorkflow(contract: KeywordAnalysisWorkflowContract, stepId: KeywordAnalysisWorkflowStepId, reason: string) {
|
||||
const failed = setWorkflowStatus(setStepStatus(contract, stepId, "failed", { reason }), "failed", {
|
||||
activeStepId: stepId,
|
||||
reason,
|
||||
summary: `${getStepLabel(stepId) ?? "Шаг"} завершился ошибкой: ${reason}`
|
||||
});
|
||||
|
||||
failed.nextActions = ["Проверить ошибку шага и повторить анализ после исправления причины."];
|
||||
|
||||
return saveWorkflow(failed, "failed", reason);
|
||||
}
|
||||
|
||||
async function ensureProjectContextReady(projectId: string) {
|
||||
let market = await getMarketEnrichmentContract(projectId);
|
||||
const ontologyVersion = market.projectOntologyVersion;
|
||||
|
||||
if (
|
||||
ontologyVersion &&
|
||||
ontologyVersion.approvalStatus === "draft" &&
|
||||
ontologyVersion.reviewProblemCount === 0
|
||||
) {
|
||||
await updateProjectOntologyApproval({
|
||||
approvedBy: "keyword-analysis-workflow",
|
||||
projectId,
|
||||
status: "needs_review",
|
||||
versionId: ontologyVersion.id
|
||||
});
|
||||
market = await getMarketEnrichmentContract(projectId);
|
||||
}
|
||||
|
||||
return market;
|
||||
}
|
||||
|
||||
async function ensureMarketFrontierDiscoveryReady(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
projectId: string,
|
||||
demandCollectionProfile: "contextual" | "market_wide"
|
||||
): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> {
|
||||
if (demandCollectionProfile !== "market_wide") {
|
||||
return { contract, ready: true };
|
||||
}
|
||||
|
||||
if (contract.artifacts.modelTaskRunId) {
|
||||
const providerStatus = await getSeoModelProviderStatusContract(projectId);
|
||||
const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId);
|
||||
|
||||
if (existingRun && isModelTaskRunning(existingRun.runStatus)) {
|
||||
const waiting = setWorkflowStatus(
|
||||
setStepStatus(contract, "demand_collection", "waiting", {
|
||||
reason: "AI Workspace строит market frontier discovery перед Wordstat."
|
||||
}),
|
||||
"waiting",
|
||||
{
|
||||
activeStepId: "demand_collection",
|
||||
reason: "Ждём модельный план рыночных фронтиров.",
|
||||
summary: "Модель строит память рынка, языковой fingerprint и probe plan перед Wordstat."
|
||||
}
|
||||
);
|
||||
waiting.nextActions = ["Дождаться завершения seo.market_frontier_discovery; после этого workflow продолжит Wordstat-добор."];
|
||||
await saveWorkflow(waiting, "running");
|
||||
|
||||
return { contract: waiting, ready: false };
|
||||
}
|
||||
|
||||
return { contract, ready: true };
|
||||
}
|
||||
|
||||
const modelTaskRun = await runSeoModelTask(projectId, {
|
||||
providerId: "codex_workspace",
|
||||
taskType: "seo.market_frontier_discovery"
|
||||
});
|
||||
const nextContract = {
|
||||
...contract,
|
||||
artifacts: {
|
||||
...contract.artifacts,
|
||||
modelTaskRunId: modelTaskRun.runId
|
||||
}
|
||||
};
|
||||
|
||||
if (isModelTaskRunning(modelTaskRun.runStatus)) {
|
||||
const waiting = setWorkflowStatus(
|
||||
setStepStatus(nextContract, "demand_collection", "waiting", {
|
||||
reason: "AI Workspace принял market frontier discovery; ждём структурированный probe plan."
|
||||
}),
|
||||
"waiting",
|
||||
{
|
||||
activeStepId: "demand_collection",
|
||||
reason: "Ждём модельный план рыночных фронтиров.",
|
||||
summary: "Модель строит память рынка, языковой fingerprint и probe plan перед Wordstat."
|
||||
}
|
||||
);
|
||||
waiting.nextActions = ["Дождаться завершения seo.market_frontier_discovery; после этого workflow продолжит Wordstat-добор."];
|
||||
await saveWorkflow(waiting, "running");
|
||||
|
||||
return { contract: waiting, ready: false };
|
||||
}
|
||||
|
||||
return { contract: nextContract, ready: true };
|
||||
}
|
||||
|
||||
async function completeWorkflow(contract: KeywordAnalysisWorkflowContract) {
|
||||
const completed = setWorkflowStatus(contract, "completed", {
|
||||
activeStepId: null,
|
||||
reason: null,
|
||||
summary: "Анализ выбранных ключей завершен. Можно разбирать предложение по ключам."
|
||||
});
|
||||
|
||||
completed.nextActions = ["Проверить предложенные ключи и перейти к распределению по посадочным."];
|
||||
|
||||
return saveWorkflow(completed, "done");
|
||||
}
|
||||
|
||||
async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) {
|
||||
if (activeWorkflowExecutions.has(runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeWorkflowExecutions.add(runId);
|
||||
|
||||
try {
|
||||
const row = await getWorkflowRun(runId);
|
||||
let contract = mapWorkflowRow(row);
|
||||
|
||||
if (!contract) {
|
||||
return;
|
||||
}
|
||||
|
||||
contract = setWorkflowStatus(setStepStatus(contract, "semantic_basis", "running"), "running", {
|
||||
activeStepId: "semantic_basis",
|
||||
summary: "Проверяем семантический базис."
|
||||
});
|
||||
await saveWorkflow(contract, "running");
|
||||
|
||||
const anchorReview = await getAnchorReviewContract(projectId);
|
||||
contract = {
|
||||
...contract,
|
||||
semanticRunId: anchorReview.semanticRunId
|
||||
};
|
||||
|
||||
if (!anchorReview.semanticRunId) {
|
||||
await blockWorkflow(contract, "semantic_basis", "Сначала нужен semantic analysis проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchorReview.state !== "approved" || anchorReview.readiness.wordstatQueueCount <= 0) {
|
||||
await blockWorkflow(
|
||||
contract,
|
||||
"semantic_basis",
|
||||
"Нужно выбрать хотя бы один ключ в семантическом базисе и отправить его на анализ."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
contract = setStepStatus(contract, "semantic_basis", "completed", {
|
||||
detail: `Выбрано ${anchorReview.readiness.wordstatQueueCount} ключей для анализа.`
|
||||
});
|
||||
contract = setWorkflowStatus(setStepStatus(contract, "project_context", "running"), "running", {
|
||||
activeStepId: "project_context",
|
||||
summary: "Проверяем проектный контекст."
|
||||
});
|
||||
await saveWorkflow(contract, "running");
|
||||
|
||||
let market = await ensureProjectContextReady(projectId);
|
||||
contract = {
|
||||
...contract,
|
||||
artifacts: {
|
||||
...contract.artifacts,
|
||||
marketGeneratedAt: market.generatedAt,
|
||||
wordstatJobId: market.wordstat.latestJob?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
if (!market.semanticRunId) {
|
||||
await blockWorkflow(contract, "project_context", "Сначала нужен semantic analysis проекта.", market.nextActions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!market.readiness.ontologyReadyForMarket) {
|
||||
await blockWorkflow(
|
||||
contract,
|
||||
"project_context",
|
||||
"Проектный контекст не подтвержден для анализа спроса.",
|
||||
market.nextActions
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
contract = setStepStatus(contract, "project_context", "completed", {
|
||||
detail: "Проектный контекст принят для анализа спроса."
|
||||
});
|
||||
contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", {
|
||||
activeStepId: "demand_collection",
|
||||
summary: "Собираем рыночную базу Wordstat."
|
||||
});
|
||||
await saveWorkflow(contract, "running");
|
||||
|
||||
if (!market.readiness.canCollectWordstat) {
|
||||
await blockWorkflow(
|
||||
contract,
|
||||
"demand_collection",
|
||||
"Сбор спроса сейчас недоступен: провайдер или очередь не готовы.",
|
||||
market.nextActions
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const marketFrontierDiscovery = await ensureMarketFrontierDiscoveryReady(
|
||||
contract,
|
||||
projectId,
|
||||
anchorReview.demandCollectionProfile
|
||||
);
|
||||
contract = marketFrontierDiscovery.contract;
|
||||
|
||||
if (!marketFrontierDiscovery.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wordstatJobIdBeforeCollection = market.wordstat.latestJob?.id ?? null;
|
||||
|
||||
market = await runMarketEnrichment(projectId);
|
||||
contract = {
|
||||
...contract,
|
||||
artifacts: {
|
||||
...contract.artifacts,
|
||||
marketGeneratedAt: market.generatedAt,
|
||||
wordstatJobId: market.wordstat.latestJob?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
const latestWordstatJob = market.wordstat.latestJob;
|
||||
const latestWordstatJobId = latestWordstatJob?.id ?? null;
|
||||
const hasFreshWordstatFailure =
|
||||
latestWordstatJob?.status === "failed" && latestWordstatJobId !== wordstatJobIdBeforeCollection;
|
||||
const hasReusableWordstatEvidence = market.wordstat.summary.resultCount > 0;
|
||||
|
||||
if (hasFreshWordstatFailure || (latestWordstatJob?.status === "failed" && !hasReusableWordstatEvidence)) {
|
||||
await failWorkflow(
|
||||
contract,
|
||||
"demand_collection",
|
||||
latestWordstatJob.errorMessage ?? "Сбор спроса завершился ошибкой провайдера."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestWordstatJob) {
|
||||
await blockWorkflow(contract, "demand_collection", "Сбор спроса не стартовал.", market.nextActions);
|
||||
return;
|
||||
}
|
||||
|
||||
contract = setStepStatus(contract, "demand_collection", "completed", {
|
||||
detail:
|
||||
`Профиль: ${getDemandCollectionProfileLabel(anchorReview.demandCollectionProfile)}. ` +
|
||||
`Собрано ${market.wordstat.summary.collectedSeedCount} seed-фраз; ` +
|
||||
`точный спрос ${market.wordstat.summary.exactFrequencySeedCount}, ` +
|
||||
`related-сигнал ${market.wordstat.summary.relatedOnlySeedCount}.` +
|
||||
(market.wordstat.latestJob?.status === "failed"
|
||||
? " Последний failed job старый; используем ранее собранное evidence для текущего сравнения."
|
||||
: "")
|
||||
});
|
||||
contract = setWorkflowStatus(setStepStatus(contract, "keyword_cleaning", "running"), "running", {
|
||||
activeStepId: "keyword_cleaning",
|
||||
summary: "Готовим разбор ключей."
|
||||
});
|
||||
await saveWorkflow(contract, "running");
|
||||
|
||||
const modelTaskRun = await runSeoModelTask(projectId, { taskType: "seo.keyword_cleaning" });
|
||||
contract = {
|
||||
...contract,
|
||||
artifacts: {
|
||||
...contract.artifacts,
|
||||
modelTaskRunId: modelTaskRun.runId
|
||||
}
|
||||
};
|
||||
|
||||
if (modelTaskRun.runStatus === "running") {
|
||||
const waiting = setWorkflowStatus(
|
||||
setStepStatus(contract, "keyword_cleaning", "waiting", {
|
||||
reason: "AI Workspace принял задачу; ждем структурированный результат модели."
|
||||
}),
|
||||
"waiting",
|
||||
{
|
||||
activeStepId: "keyword_cleaning",
|
||||
reason: "AI Workspace обрабатывает разбор ключей.",
|
||||
summary: "Ждем модельный разбор ключей."
|
||||
}
|
||||
);
|
||||
waiting.nextActions = ["Дождаться завершения AI Workspace task; workflow обновится при следующей проверке статуса."];
|
||||
await saveWorkflow(waiting, "running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelTaskRun.runStatus === "failed") {
|
||||
const fallbackReason =
|
||||
modelTaskRun.errorMessage ??
|
||||
"Модельный разбор ключей не стартовал, используем backend fallback по собранному Wordstat evidence.";
|
||||
const fallbackContract = setWorkflowStatus(
|
||||
setStepStatus(contract, "keyword_cleaning", "running", {
|
||||
detail: `AI Workspace недоступен: ${fallbackReason}. Собираем backend fallback по exact Wordstat evidence.`,
|
||||
reason: fallbackReason
|
||||
}),
|
||||
"running",
|
||||
{
|
||||
activeStepId: "keyword_cleaning",
|
||||
reason: fallbackReason,
|
||||
summary: "Модельный разбор недоступен, собираем fallback-разбор ключей."
|
||||
}
|
||||
);
|
||||
await saveWorkflow(fallbackContract, "running");
|
||||
const completedFallback = await finishKeywordCleaningWorkflow(fallbackContract, projectId, null);
|
||||
|
||||
if (!completedFallback) {
|
||||
await failWorkflow(fallbackContract, "keyword_cleaning", fallbackReason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await finishKeywordCleaningWorkflow(contract, projectId, modelTaskRun.runId);
|
||||
} catch (error) {
|
||||
const row = await getWorkflowRun(runId);
|
||||
const contract = mapWorkflowRow(row);
|
||||
|
||||
if (contract) {
|
||||
await failWorkflow(contract, contract.activeStepId ?? "keyword_cleaning", error instanceof Error ? error.message : "workflow_failed");
|
||||
}
|
||||
} finally {
|
||||
activeWorkflowExecutions.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishKeywordCleaningWorkflow(
|
||||
contract: KeywordAnalysisWorkflowContract,
|
||||
projectId: string,
|
||||
expectedModelTaskRunId: string | null = contract.artifacts.modelTaskRunId
|
||||
) {
|
||||
const keywordCleaningMatch = expectedModelTaskRunId
|
||||
? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId)
|
||||
: null;
|
||||
const keywordCleaning = expectedModelTaskRunId
|
||||
? keywordCleaningMatch?.keywordCleaning ?? null
|
||||
: await getKeywordCleaningContract(projectId);
|
||||
|
||||
if (!keywordCleaning) {
|
||||
await blockWorkflow(
|
||||
contract,
|
||||
"keyword_cleaning",
|
||||
"Разбор ключей для текущего запуска ещё не сохранён: ждём новый model-task result, старые данные не используются.",
|
||||
[
|
||||
"Дождаться завершения AI Workspace task и повторной проверки workflow.",
|
||||
"Если task завершился без сохранения keyword cleaning, повторить отправку на анализ."
|
||||
]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keywordCleaning.state !== "ready") {
|
||||
await blockWorkflow(
|
||||
contract,
|
||||
"keyword_cleaning",
|
||||
"Разбор ключей пока не готов: модельный результат не сохранен или не прошел backend-проверку.",
|
||||
keywordCleaning.nextActions
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let nextContract: KeywordAnalysisWorkflowContract = {
|
||||
...contract,
|
||||
artifacts: {
|
||||
...contract.artifacts,
|
||||
keywordCleaningRunId: keywordCleaningMatch?.runId ?? null
|
||||
}
|
||||
};
|
||||
|
||||
nextContract = setStepStatus(nextContract, "keyword_cleaning", "completed", {
|
||||
detail: `Разобрано ${keywordCleaning.readiness.sourcePhraseCount} ключей.`
|
||||
});
|
||||
nextContract = setStepStatus(nextContract, "keyword_proposal", "running");
|
||||
nextContract = setStepStatus(nextContract, "keyword_proposal", "completed", {
|
||||
detail: `Предложение готово: ${keywordCleaning.readiness.useCount} основных, ${keywordCleaning.readiness.supportCount} поддерживающих.`
|
||||
});
|
||||
|
||||
return completeWorkflow(nextContract);
|
||||
}
|
||||
|
||||
async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) {
|
||||
if (contract.status !== "waiting" || !contract.artifacts.modelTaskRunId) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
const providerStatus = await getSeoModelProviderStatusContract(contract.projectId);
|
||||
const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId) ?? null;
|
||||
|
||||
if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
if (contract.activeStepId === "demand_collection") {
|
||||
const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled";
|
||||
const detail = taskDidNotFinish
|
||||
? `Market frontier discovery не завершился: ${modelTaskRun.errorMessage ?? "нет provider result"}. Продолжаем через deterministic fallback.`
|
||||
: "Market frontier discovery готов; продолжаем Wordstat-добор по памяти рынка, fingerprint и probe plan.";
|
||||
const resumed = setWorkflowStatus(
|
||||
setStepStatus(contract, "demand_collection", "running", {
|
||||
detail,
|
||||
reason: taskDidNotFinish ? modelTaskRun.errorMessage ?? "market_frontier_discovery_not_completed" : null
|
||||
}),
|
||||
"running",
|
||||
{
|
||||
activeStepId: "demand_collection",
|
||||
reason: taskDidNotFinish ? modelTaskRun.errorMessage ?? "market_frontier_discovery_not_completed" : null,
|
||||
summary: taskDidNotFinish
|
||||
? "Market frontier discovery недоступен, продолжаем Wordstat через fallback-план."
|
||||
: "Продолжаем Wordstat-добор по market frontier discovery."
|
||||
}
|
||||
);
|
||||
resumed.nextActions = ["Workflow продолжит Wordstat-добор; повторно нажимать кнопку не нужно."];
|
||||
const saved = await saveWorkflow(resumed, "running");
|
||||
void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId);
|
||||
|
||||
return saved ?? resumed;
|
||||
}
|
||||
|
||||
if (contract.activeStepId !== "keyword_cleaning") {
|
||||
return contract;
|
||||
}
|
||||
|
||||
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
|
||||
const failed = await failWorkflow(
|
||||
contract,
|
||||
"keyword_cleaning",
|
||||
modelTaskRun.errorMessage ?? "Модельный разбор ключей не завершился успешно."
|
||||
);
|
||||
return failed ?? contract;
|
||||
}
|
||||
|
||||
return (await finishKeywordCleaningWorkflow(contract, contract.projectId)) ?? contract;
|
||||
}
|
||||
|
||||
export async function getLatestKeywordAnalysisWorkflow(projectId: string): Promise<KeywordAnalysisWorkflowContract | null> {
|
||||
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
|
||||
`
|
||||
select id, status, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where project_id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
const row = result.rows[0] ?? null;
|
||||
const contract = mapWorkflowRow(row ?? undefined);
|
||||
|
||||
if (!row || !contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.status === "running" && contract.status === "waiting") {
|
||||
return refreshWaitingWorkflow(contract);
|
||||
}
|
||||
|
||||
if (row.status === "running" && (contract.status === "queued" || contract.status === "running") && !activeWorkflowExecutions.has(row.id)) {
|
||||
void executeKeywordAnalysisWorkflow(row.id, projectId);
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
export async function startKeywordAnalysisWorkflow(projectId: string): Promise<KeywordAnalysisWorkflowContract> {
|
||||
const existing = await pool.query<KeywordAnalysisWorkflowRunRow>(
|
||||
`
|
||||
select id, status, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = '${WORKFLOW_RUN_TYPE}'
|
||||
and status in ('queued', 'running')
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
const existingContract = mapWorkflowRow(existing.rows[0]);
|
||||
|
||||
if (existing.rows[0] && existingContract) {
|
||||
void executeKeywordAnalysisWorkflow(existing.rows[0].id, projectId);
|
||||
return existingContract;
|
||||
}
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
const output = buildInitialWorkflow(projectId, runId);
|
||||
await pool.query(
|
||||
`
|
||||
insert into runs (id, project_id, run_type, status, input, output, started_at)
|
||||
values ($1, $2, '${WORKFLOW_RUN_TYPE}', 'running', $3::jsonb, $4::jsonb, now());
|
||||
`,
|
||||
[
|
||||
runId,
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
schemaVersion: "keyword-analysis-workflow-input.v1",
|
||||
trigger: "semantic_basis_submitted"
|
||||
}),
|
||||
JSON.stringify(output)
|
||||
]
|
||||
);
|
||||
|
||||
void executeKeywordAnalysisWorkflow(runId, projectId);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
||||
|
||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
||||
|
|
@ -220,6 +221,11 @@ const CONSUMER_AI_NOISE_PATTERNS = [
|
|||
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
||||
const B2B_KEYWORD_FACET_PATTERN =
|
||||
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
||||
const KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT = 60;
|
||||
const KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT = 18;
|
||||
const KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT = 24;
|
||||
const KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT = 150;
|
||||
const KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT = 120;
|
||||
|
||||
const ARTICLE_PATTERNS = [
|
||||
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
|
||||
|
|
@ -258,6 +264,12 @@ function wordCount(value: string) {
|
|||
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function truncateTaskText(value: string | null | undefined, limit = 120) {
|
||||
const normalized = String(value ?? "").trim().replace(/\s+/g, " ");
|
||||
|
||||
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
|
||||
}
|
||||
|
||||
const KEYWORD_CLEANING_FACET_STOP_WORDS = new Set([
|
||||
"a",
|
||||
"an",
|
||||
|
|
@ -575,7 +587,7 @@ function enforceKeywordCleaningSemanticGuard(
|
|||
});
|
||||
}
|
||||
|
||||
function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
|
||||
function shouldRescueExactTrashItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
||||
return (
|
||||
item.decision === "trash" &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
|
|
@ -587,6 +599,7 @@ function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
|
|||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||
);
|
||||
|
|
@ -599,6 +612,7 @@ function isBroadHeadDemandItem(item: KeywordCleaningItem) {
|
|||
}
|
||||
|
||||
function shouldPromoteExactDemandItem(
|
||||
market: MarketEnrichmentContract,
|
||||
item: KeywordCleaningItem,
|
||||
contextTokens: Set<string>,
|
||||
guard: KeywordCleaningFacetGuard
|
||||
|
|
@ -621,14 +635,15 @@ function shouldPromoteExactDemandItem(
|
|||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||
);
|
||||
}
|
||||
|
||||
function rescueExactTrashItems(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
||||
function rescueExactTrashItems(market: MarketEnrichmentContract, items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
||||
return items.map((item) => {
|
||||
if (!shouldRescueExactTrashItem(item)) {
|
||||
if (!shouldRescueExactTrashItem(market, item)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
@ -648,7 +663,7 @@ function promoteExactDemandItems(market: MarketEnrichmentContract, items: Keywor
|
|||
const guard = buildKeywordCleaningFacetGuard(market);
|
||||
|
||||
return items.map((item) => {
|
||||
if (!shouldPromoteExactDemandItem(item, contextTokens, guard)) {
|
||||
if (!shouldPromoteExactDemandItem(market, item, contextTokens, guard)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
@ -667,20 +682,26 @@ function enforceKeywordCleaningBackendGuards(
|
|||
market: MarketEnrichmentContract,
|
||||
items: KeywordCleaningItem[]
|
||||
): KeywordCleaningItem[] {
|
||||
return promoteExactDemandItems(market, rescueExactTrashItems(enforceKeywordCleaningSemanticGuard(market, items)));
|
||||
return promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)));
|
||||
}
|
||||
|
||||
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
|
||||
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
|
||||
}
|
||||
|
||||
function shouldBackfillMissingExactItem(item: KeywordCleaningItem) {
|
||||
function shouldBackfillMissingExactItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
||||
return (
|
||||
item.decision !== "trash" &&
|
||||
(item.decision === "use" || item.decision === "support") &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
Boolean(item.wordstatResultId) &&
|
||||
wordCount(item.phrase) > 1
|
||||
wordCount(item.phrase) > 1 &&
|
||||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -690,7 +711,7 @@ function mergeMissingExactEvidenceItems(
|
|||
): KeywordCleaningItem[] {
|
||||
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
|
||||
const missingItems = classifyItems(market, getBaseItems(market))
|
||||
.filter(shouldBackfillMissingExactItem)
|
||||
.filter((item) => shouldBackfillMissingExactItem(market, item))
|
||||
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
|
||||
.sort((left, right) => {
|
||||
const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
|
||||
|
|
@ -698,11 +719,11 @@ function mergeMissingExactEvidenceItems(
|
|||
|
||||
return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
|
||||
})
|
||||
.slice(0, 80)
|
||||
.slice(0, 24)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
|
||||
reason: `Backend evidence добавил exact Wordstat item, который модель не вернула в keyword_cleaning. ${item.reason}`
|
||||
reason: `Backend audit сохранил high-fit exact Wordstat evidence, который модель не вернула в keyword_cleaning. ${item.reason}`
|
||||
}));
|
||||
|
||||
return [...modelItems, ...missingItems];
|
||||
|
|
@ -886,13 +907,13 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
|||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||
const briefClusterMap = buildBriefClusterMap(contract);
|
||||
const seedItems = contract.seedQueue
|
||||
.slice(0, 160)
|
||||
.slice(0, 260)
|
||||
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
|
||||
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
|
||||
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
|
||||
const relatedItems: KeywordCleaningItem[] = [];
|
||||
|
||||
for (const [index, result] of contract.wordstat.topResults.slice(0, 120).entries()) {
|
||||
for (const [index, result] of contract.wordstat.topResults.slice(0, 220).entries()) {
|
||||
const normalizedResult = normalizePhrase(result.phrase);
|
||||
|
||||
if (seen.has(normalizedResult)) {
|
||||
|
|
@ -965,41 +986,84 @@ function mergeModelProviderItemsWithMarketEvidence(
|
|||
});
|
||||
}
|
||||
|
||||
function getKeywordCleaningFrequencyScore(frequency: number | null) {
|
||||
if (frequency === null) return 0;
|
||||
if (frequency >= 5000) return 42;
|
||||
if (frequency >= 1000) return 34;
|
||||
if (frequency >= 100) return 26;
|
||||
if (frequency >= 20) return 18;
|
||||
if (frequency > 0) return 10;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getKeywordCleaningModelSeedScore(seed: SeedQueueItem) {
|
||||
return (
|
||||
getKeywordCleaningFrequencyScore(seed.frequency) +
|
||||
(seed.evidenceStatus === "collected" ? 36 : 0) +
|
||||
(seed.wordstatResultId ? 24 : 0) +
|
||||
(seed.normalization?.marketRole === "commercial" ? 14 : 0) +
|
||||
(seed.normalization?.marketRole === "core" ? 10 : 0) +
|
||||
(seed.priority === "high" ? 12 : seed.priority === "medium" ? 6 : 0) +
|
||||
Math.min(seed.relatedCount ?? 0, 10)
|
||||
);
|
||||
}
|
||||
|
||||
function getKeywordCleaningModelTopResultScore(result: WordstatTopResult) {
|
||||
return (
|
||||
getKeywordCleaningFrequencyScore(result.frequency) +
|
||||
(result.frequency !== null ? 30 : 0) +
|
||||
(result.sourcePhrase ? 10 : 0) +
|
||||
(result.sourceClusterId ? 6 : 0) +
|
||||
(hasB2BKeywordFacet(result.phrase) ? 8 : 0) -
|
||||
(isConsumerAiNoisePhrase(result.phrase) ? 50 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
|
||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||
const briefClusterMap = buildBriefClusterMap(contract);
|
||||
const modelSeedQueue = contract.seedQueue
|
||||
.map((seed, index) => ({ index, score: getKeywordCleaningModelSeedScore(seed), seed }))
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||
.slice(0, KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT)
|
||||
.map((item) => item.seed);
|
||||
const modelTopResults = contract.wordstat.topResults
|
||||
.map((result, index) => ({ index, result, score: getKeywordCleaningModelTopResultScore(result) }))
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||
.slice(0, KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT)
|
||||
.map((item) => item.result);
|
||||
|
||||
return {
|
||||
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({
|
||||
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT).map((anchor) => ({
|
||||
clusterId: anchor.clusterId,
|
||||
clusterTitle: anchor.clusterTitle,
|
||||
clusterTitle: truncateTaskText(anchor.clusterTitle),
|
||||
confidence: anchor.confidence,
|
||||
intentType: anchor.intentType,
|
||||
marketRole: anchor.marketRole,
|
||||
normalizedFrom: anchor.normalizedFrom,
|
||||
normalizedFrom: anchor.normalizedFrom.slice(0, 3).map((phrase) => truncateTaskText(phrase)),
|
||||
phrase: anchor.phrase,
|
||||
priority: anchor.priority
|
||||
})),
|
||||
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
|
||||
action: brief.action,
|
||||
landingBriefs: contract.briefEvidence.slice(0, KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT).map((brief) => ({
|
||||
action: truncateTaskText(brief.action, 160),
|
||||
evidenceStatus: brief.evidenceStatus,
|
||||
path: brief.path,
|
||||
priority: brief.priority,
|
||||
qualityScore: brief.qualityScore,
|
||||
seedPhrases: brief.seedPhrases.slice(0, 10)
|
||||
seedPhrases: brief.seedPhrases.slice(0, 6).map((phrase) => truncateTaskText(phrase))
|
||||
})),
|
||||
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({
|
||||
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
|
||||
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT).map((group) => ({
|
||||
excludedPhrases: group.excludedPhrases.slice(0, 6).map((item) => truncateTaskText(item.phrase)),
|
||||
id: group.id,
|
||||
intentType: group.intentType,
|
||||
marketHypothesis: group.marketHypothesis,
|
||||
marketHypothesis: truncateTaskText(group.marketHypothesis, 180),
|
||||
priority: group.priority,
|
||||
title: group.title,
|
||||
wordstatQueries: group.wordstatQueries.slice(0, 12)
|
||||
title: truncateTaskText(group.title),
|
||||
wordstatQueries: group.wordstatQueries.slice(0, 6).map((query) => truncateTaskText(query))
|
||||
})),
|
||||
seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({
|
||||
seedQueue: modelSeedQueue.map((seed) => ({
|
||||
clusterId: seed.clusterId,
|
||||
clusterTitle: seed.clusterTitle,
|
||||
clusterTitle: truncateTaskText(seed.clusterTitle),
|
||||
evidenceStatus: seed.evidenceStatus,
|
||||
frequency: seed.frequency,
|
||||
frequencyGroup: seed.frequencyGroup,
|
||||
|
|
@ -1013,14 +1077,14 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
|
|||
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
||||
wordstatResultId: seed.wordstatResultId
|
||||
})),
|
||||
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
|
||||
wordstatTopResults: modelTopResults.map((result) => ({
|
||||
frequency: result.frequency,
|
||||
frequencyGroup: result.frequencyGroup,
|
||||
id: result.id,
|
||||
phrase: result.phrase,
|
||||
region: result.region,
|
||||
sourceClusterId: result.sourceClusterId,
|
||||
sourceClusterTitle: result.sourceClusterTitle,
|
||||
sourceClusterTitle: truncateTaskText(result.sourceClusterTitle),
|
||||
sourcePhrase: result.sourcePhrase
|
||||
}))
|
||||
};
|
||||
|
|
@ -1046,7 +1110,7 @@ function isBroadAiKeywordNoise(phrase: string | null) {
|
|||
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
|
||||
}
|
||||
|
||||
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||
function getClassification(contract: MarketEnrichmentContract, item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||
const blockers: string[] = [];
|
||||
const exactEvidence = item.frequency !== null;
|
||||
const noExternalSignal =
|
||||
|
|
@ -1110,6 +1174,16 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
|||
};
|
||||
}
|
||||
|
||||
if (hasEducationIntentMismatch(contract, item.normalizedPhrase)) {
|
||||
return {
|
||||
blockers: unique([...blockers, "education_intent_not_in_product_offer"]),
|
||||
confidence: exactEvidence ? 0.72 : 0.58,
|
||||
decision: "article" as const,
|
||||
reason:
|
||||
"Фраза относится к обучению/курсам, но продуктовая нормализация сайта не содержит образовательного offer; держим как backlog/контент-гипотезу, не как продуктовую карту Stage 3."
|
||||
};
|
||||
}
|
||||
|
||||
if (isArticle && exactEvidence) {
|
||||
return {
|
||||
blockers,
|
||||
|
|
@ -1151,7 +1225,7 @@ function classifyItems(contract: MarketEnrichmentContract, items: KeywordCleanin
|
|||
const contextTokens = buildContextTokenSet(contract);
|
||||
|
||||
return items.map((item) => {
|
||||
const classification = getClassification(item, contextTokens);
|
||||
const classification = getClassification(contract, item, contextTokens);
|
||||
|
||||
return {
|
||||
...item,
|
||||
|
|
@ -1181,7 +1255,8 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
|
|||
(item) =>
|
||||
(item.decision === "use" || item.decision === "support") &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null
|
||||
item.frequency !== null &&
|
||||
Boolean(item.wordstatResultId)
|
||||
).length,
|
||||
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||
riskyCount: lanes.risky.length,
|
||||
|
|
@ -1263,9 +1338,11 @@ function buildKeywordCleaningModelTask(
|
|||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
|
||||
"Не пропускать exact Wordstat items молча: для каждой значимой exact-фразы из seedQueue/topResults вернуть item с use/support/article/risky/trash и причиной.",
|
||||
"Если модель не вернула exact-фразу, backend может сохранить её только как audit evidence; Stage 3 не должен показывать такие строки пользователю как candidates.",
|
||||
"Stage 5 собирает глобальную карту спроса, а не rewrite текущей посадочной: частотные exact B2B-смежные ветки с сохранённым source-фасетом должны попадать минимум в support/risky, а не исчезать в article только из-за соседнего рынка.",
|
||||
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
|
||||
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
||||
"Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.",
|
||||
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
|
||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||
"Не сохранять rewrite/apply decisions."
|
||||
|
|
@ -1303,7 +1380,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
|||
}
|
||||
|
||||
actions.push(
|
||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
|
||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} кандидатов в 5 колонок; exact-frequency закрепляется строже, финальные роли фиксирует пользователь.`
|
||||
);
|
||||
|
||||
return actions;
|
||||
|
|
@ -1467,6 +1544,29 @@ export async function getLatestAlignedKeywordCleaning(
|
|||
return run;
|
||||
}
|
||||
|
||||
export async function getKeywordCleaningByModelTaskRun(
|
||||
projectId: string,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<{ keywordCleaning: KeywordCleaningContract; runId: string } | null> {
|
||||
const result = await pool.query<KeywordCleaningRunRow>(
|
||||
`
|
||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'keyword_cleaning'
|
||||
and input->>'sourceModelTaskRunId' = $2
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId, sourceModelTaskRunId]
|
||||
);
|
||||
|
||||
const row = result.rows[0] ?? null;
|
||||
const keywordCleaning = row ? mapKeywordCleaningRun(row) : null;
|
||||
|
||||
return row && keywordCleaning ? { keywordCleaning, runId: row.id } : null;
|
||||
}
|
||||
|
||||
export async function getKeywordCleaningContract(projectId: string): Promise<KeywordCleaningContract> {
|
||||
const market = await getMarketEnrichmentContract(projectId);
|
||||
const fallbackContract = buildFallbackContract(projectId, market);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
||||
import { pool } from "../db/client.js";
|
||||
import { getMarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
import { getAnchorReviewContract } from "./anchorReview.js";
|
||||
import { getKeywordCleaningContract } from "./keywordCleaning.js";
|
||||
import { getKeywordMapContract } from "./keywordMap.js";
|
||||
|
||||
type KeywordContextSnapshotInput = {
|
||||
label?: string;
|
||||
note?: string;
|
||||
source?: "manual_save" | "expansion_checkpoint";
|
||||
curation?: {
|
||||
roleOverrides?: Array<{
|
||||
itemId: string;
|
||||
role: string;
|
||||
}>;
|
||||
suppressedItemIds?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>;
|
||||
|
||||
export type KeywordContextSnapshotSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
source: "manual_save" | "expansion_checkpoint";
|
||||
createdAt: string;
|
||||
semanticRunId: string | null;
|
||||
keywordMapGeneratedAt: string | null;
|
||||
keywordCount: number;
|
||||
keywordPhrases: string[];
|
||||
suppressedCount: number;
|
||||
roleOverrideCount: number;
|
||||
};
|
||||
|
||||
type KeywordContextSnapshotRow = {
|
||||
id: string;
|
||||
input: {
|
||||
label?: string;
|
||||
source?: "manual_save" | "expansion_checkpoint";
|
||||
};
|
||||
output: {
|
||||
semanticRunId?: string | null;
|
||||
keywordMap?: {
|
||||
generatedAt?: string | null;
|
||||
items?: unknown[];
|
||||
};
|
||||
curation?: {
|
||||
roleOverrides?: unknown[];
|
||||
suppressedItemIds?: unknown[];
|
||||
};
|
||||
} | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
function getSnapshotKeywordPhrases(row: KeywordContextSnapshotRow) {
|
||||
const items = row.output?.keywordMap?.items;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phrase = (item as { phrase?: unknown }).phrase;
|
||||
|
||||
return typeof phrase === "string" && phrase.trim() ? phrase : null;
|
||||
})
|
||||
.filter((phrase): phrase is string => Boolean(phrase));
|
||||
}
|
||||
|
||||
function mapSnapshotRow(row: KeywordContextSnapshotRow): KeywordContextSnapshotSummary {
|
||||
const curation = row.output?.curation;
|
||||
const keywordPhrases = getSnapshotKeywordPhrases(row);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.input.label || `Ключи и спрос · ${row.created_at.toLocaleString("ru-RU")}`,
|
||||
source: row.input.source ?? "manual_save",
|
||||
createdAt: row.created_at.toISOString(),
|
||||
semanticRunId: row.output?.semanticRunId ?? null,
|
||||
keywordMapGeneratedAt: row.output?.keywordMap?.generatedAt ?? null,
|
||||
keywordCount: keywordPhrases.length,
|
||||
keywordPhrases,
|
||||
roleOverrideCount: Array.isArray(curation?.roleOverrides) ? curation.roleOverrides.length : 0,
|
||||
suppressedCount: Array.isArray(curation?.suppressedItemIds) ? curation.suppressedItemIds.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function listKeywordContextSnapshots(projectId: string): Promise<KeywordContextSnapshotSummary[]> {
|
||||
const result = await pool.query<KeywordContextSnapshotRow>(
|
||||
`
|
||||
select id, input, output, created_at
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'keyword_context_snapshot'
|
||||
and status = 'done'
|
||||
order by created_at desc
|
||||
limit 30;
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return result.rows.map(mapSnapshotRow);
|
||||
}
|
||||
|
||||
export async function saveKeywordContextSnapshot(
|
||||
projectId: string,
|
||||
input: KeywordContextSnapshotInput
|
||||
): Promise<KeywordContextSnapshotSummary> {
|
||||
const [semanticAnalysis, anchorReview, marketEnrichment, keywordCleaning, keywordMap] = await Promise.all([
|
||||
getLatestSemanticAnalysis(projectId),
|
||||
getAnchorReviewContract(projectId),
|
||||
getMarketEnrichmentContract(projectId),
|
||||
getKeywordCleaningContract(projectId),
|
||||
getKeywordMapContract(projectId)
|
||||
]);
|
||||
const label = input.label?.trim() || `Ключи и спрос · ${new Date().toLocaleString("ru-RU")}`;
|
||||
const snapshot = {
|
||||
schemaVersion: "keyword-context-snapshot.v1",
|
||||
projectId,
|
||||
createdAt: new Date().toISOString(),
|
||||
label,
|
||||
note: input.note?.trim() || null,
|
||||
source: input.source ?? "manual_save",
|
||||
semanticRunId: keywordMap.semanticRunId ?? anchorReview.semanticRunId ?? semanticAnalysis?.runId ?? null,
|
||||
semanticAnalysis,
|
||||
anchorReview,
|
||||
marketEnrichment,
|
||||
keywordCleaning,
|
||||
keywordMap,
|
||||
curation: {
|
||||
roleOverrides: input.curation?.roleOverrides ?? [],
|
||||
suppressedItemIds: input.curation?.suppressedItemIds ?? []
|
||||
},
|
||||
replayContract: {
|
||||
purpose: "Reproduce Stage 5 context for keyword analysis, frontier expansion and model debugging.",
|
||||
modelMustKnow: [
|
||||
"Исходный контекст сайта и semantic analysis.",
|
||||
"Какие semantic anchors пользователь подтвердил, скрыл или оставил на разборе.",
|
||||
"Какой профиль сбора спроса выбран.",
|
||||
"Какие Wordstat evidence уже собраны и какие seed-фразы дали no signal.",
|
||||
"Какая текущая keyword map раскладка сохранена пользователем."
|
||||
],
|
||||
expansionRule:
|
||||
"Дополнительное расширение должно выбирать новые frontier seed-фразы из реального Wordstat evidence, не повторяя уже проверенную траекторию и не сбрасывая пользовательскую раскладку."
|
||||
}
|
||||
};
|
||||
const result = await pool.query<KeywordContextSnapshotRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'keyword_context_snapshot', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, input, output, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
label,
|
||||
schemaVersion: "keyword-context-snapshot-input.v1",
|
||||
source: input.source ?? "manual_save"
|
||||
}),
|
||||
JSON.stringify(snapshot)
|
||||
]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Не удалось сохранить snapshot ключей и спроса.");
|
||||
}
|
||||
|
||||
return mapSnapshotRow(row);
|
||||
}
|
||||
|
||||
export async function renameKeywordContextSnapshot(
|
||||
projectId: string,
|
||||
snapshotId: string,
|
||||
label: string
|
||||
): Promise<KeywordContextSnapshotSummary> {
|
||||
const normalizedLabel = label.trim();
|
||||
|
||||
if (!normalizedLabel) {
|
||||
throw new Error("Нужно указать название snapshot-профиля.");
|
||||
}
|
||||
|
||||
const result = await pool.query<KeywordContextSnapshotRow>(
|
||||
`
|
||||
update runs
|
||||
set input = jsonb_set(coalesce(input, '{}'::jsonb), '{label}', to_jsonb($3::text), true),
|
||||
output = jsonb_set(coalesce(output, '{}'::jsonb), '{label}', to_jsonb($3::text), true),
|
||||
completed_at = now()
|
||||
where id = $2
|
||||
and project_id = $1
|
||||
and run_type = 'keyword_context_snapshot'
|
||||
and status = 'done'
|
||||
returning id, input, output, created_at;
|
||||
`,
|
||||
[projectId, snapshotId, normalizedLabel]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Snapshot-профиль не найден.");
|
||||
}
|
||||
|
||||
return mapSnapshotRow(row);
|
||||
}
|
||||
|
||||
export async function updateKeywordContextSnapshotCuration(
|
||||
projectId: string,
|
||||
snapshotId: string,
|
||||
curation: KeywordContextSnapshotCurationInput
|
||||
): Promise<KeywordContextSnapshotSummary> {
|
||||
const normalizedCuration = {
|
||||
roleOverrides: curation.roleOverrides ?? [],
|
||||
savedAt: new Date().toISOString(),
|
||||
suppressedItemIds: curation.suppressedItemIds ?? []
|
||||
};
|
||||
const result = await pool.query<KeywordContextSnapshotRow>(
|
||||
`
|
||||
update runs
|
||||
set output = jsonb_set(coalesce(output, '{}'::jsonb), '{curation}', $3::jsonb, true),
|
||||
completed_at = now()
|
||||
where id = $2
|
||||
and project_id = $1
|
||||
and run_type = 'keyword_context_snapshot'
|
||||
and status = 'done'
|
||||
returning id, input, output, created_at;
|
||||
`,
|
||||
[projectId, snapshotId, JSON.stringify(normalizedCuration)]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Snapshot-профиль не найден.");
|
||||
}
|
||||
|
||||
return mapSnapshotRow(row);
|
||||
}
|
||||
|
||||
export async function deleteKeywordContextSnapshot(projectId: string, snapshotId: string) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
update runs
|
||||
set status = 'cancelled',
|
||||
completed_at = now()
|
||||
where id = $2
|
||||
and project_id = $1
|
||||
and run_type = 'keyword_context_snapshot'
|
||||
and status = 'done';
|
||||
`,
|
||||
[projectId, snapshotId]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("Snapshot-профиль не найден.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import type { MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
|
||||
const EDUCATION_SEARCH_INTENT_PATTERN =
|
||||
/(^|\s)(курс[а-яёa-z0-9-]*|обучени[еяю]|учебн[а-яёa-z0-9-]*|урок[а-яёa-z0-9-]*|тренинг[а-яёa-z0-9-]*|семинар[а-яёa-z0-9-]*|вебинар[а-яёa-z0-9-]*|сертификат[а-яёa-z0-9-]*|повышени[а-яёa-z0-9-]*\s+квалификац[а-яёa-z0-9-]*|професси[яию]|course|courses|training|academy|bootcamp|certification)(\s|$)/i;
|
||||
|
||||
const EDUCATION_OFFER_CONTEXT_PATTERN =
|
||||
/(^|\s)(курс[а-яёa-z0-9-]*|образовательн[а-яёa-z0-9-]*|учебн[а-яёa-z0-9-]*\s+(центр|программ|курс)|академи[яи]|школа|тренинг[а-яёa-z0-9-]*|семинар[а-яёa-z0-9-]*|вебинар[а-яёa-z0-9-]*|сертификат[а-яёa-z0-9-]*|повышени[а-яёa-z0-9-]*\s+квалификац[а-яёa-z0-9-]*|наставничеств[а-яёa-z0-9-]*|практикум[а-яёa-z0-9-]*|обучени[а-яёa-z0-9-]*\s+(сотрудник[а-яёa-z0-9-]*|специалист[а-яёa-z0-9-]*|пользовател[а-яёa-z0-9-]*|клиент[а-яёa-z0-9-]*|команд[а-яёa-z0-9-]*|персонал[а-яёa-z0-9-]*|работе|по)|course|courses|training|academy|bootcamp|certification)(\s|$)/i;
|
||||
|
||||
const MODEL_TRAINING_CONTEXT_PATTERN =
|
||||
/(^|\s)(машинн[а-яёa-z0-9-]*\s+обучени[а-яёa-z0-9-]*|обучени[а-яёa-z0-9-]*\s+(модел[а-яёa-z0-9-]*|нейросет[а-яёa-z0-9-]*|алгоритм[а-яёa-z0-9-]*|model|models|algorithm[а-яёa-z0-9-]*))(\s|$)/gi;
|
||||
|
||||
function normalizeIntentText(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function pushText(parts: string[], value: string | null | undefined) {
|
||||
const normalized = normalizeIntentText(String(value ?? ""));
|
||||
|
||||
if (normalized) {
|
||||
parts.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function buildProductOfferContextText(contract: MarketEnrichmentContract) {
|
||||
const parts: string[] = [];
|
||||
const modelInput = contract.normalization.modelTask?.input;
|
||||
|
||||
pushText(parts, contract.normalization.summary);
|
||||
|
||||
for (const group of contract.normalization.intentGroups) {
|
||||
pushText(parts, group.title);
|
||||
pushText(parts, group.marketHypothesis);
|
||||
|
||||
for (const phrase of group.corePhrases) {
|
||||
pushText(parts, phrase);
|
||||
}
|
||||
}
|
||||
|
||||
pushText(parts, modelInput?.contextReview.summary);
|
||||
pushText(parts, modelInput?.businessSynthesis.summary);
|
||||
pushText(parts, modelInput?.businessSynthesis.productFrame?.coreOffer);
|
||||
pushText(parts, modelInput?.businessSynthesis.productFrame?.productCategory);
|
||||
pushText(parts, modelInput?.businessSynthesis.productFrame?.centralThesis);
|
||||
pushText(parts, modelInput?.commercialDemand.summary);
|
||||
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreBuyerProblem);
|
||||
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreCommercialValue);
|
||||
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreDifferentiator);
|
||||
pushText(parts, modelInput?.commercialDemand.commercialCore?.decisionContext);
|
||||
|
||||
for (const pillar of modelInput?.businessSynthesis.corePillars ?? []) {
|
||||
pushText(parts, pillar.title);
|
||||
pushText(parts, pillar.role);
|
||||
pushText(parts, pillar.grounding);
|
||||
}
|
||||
|
||||
for (const vector of modelInput?.businessSynthesis.applicationVectors ?? []) {
|
||||
pushText(parts, vector.title);
|
||||
pushText(parts, vector.relationshipToCore);
|
||||
pushText(parts, vector.grounding);
|
||||
pushText(parts, vector.queryDirection);
|
||||
}
|
||||
|
||||
for (const family of modelInput?.businessSynthesis.demandFamilies ?? []) {
|
||||
pushText(parts, family.title);
|
||||
pushText(parts, family.role);
|
||||
pushText(parts, family.grounding);
|
||||
pushText(parts, family.rationale);
|
||||
|
||||
for (const phrase of family.phrases) {
|
||||
pushText(parts, phrase);
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of modelInput?.commercialDemand.buyerIntentFamilies ?? []) {
|
||||
pushText(parts, family.title);
|
||||
pushText(parts, family.buyerIntent);
|
||||
pushText(parts, family.commercialAngle);
|
||||
pushText(parts, family.sourceMeaning);
|
||||
pushText(parts, family.coreQualifier);
|
||||
}
|
||||
|
||||
for (const angle of modelInput?.commercialDemand.applicationCommercialAngles ?? []) {
|
||||
pushText(parts, angle.sourceVector);
|
||||
pushText(parts, angle.relationshipToCore);
|
||||
pushText(parts, angle.grounding);
|
||||
pushText(parts, angle.commercialReframe);
|
||||
pushText(parts, angle.coreQualifier);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function hasEducationSearchIntent(phrase: string) {
|
||||
const normalizedPhrase = normalizeIntentText(phrase);
|
||||
const phraseWithoutModelTraining = normalizedPhrase.replace(MODEL_TRAINING_CONTEXT_PATTERN, " ");
|
||||
|
||||
return EDUCATION_SEARCH_INTENT_PATTERN.test(phraseWithoutModelTraining);
|
||||
}
|
||||
|
||||
export function hasEducationOfferContext(contract: MarketEnrichmentContract) {
|
||||
const productContext = buildProductOfferContextText(contract).replace(MODEL_TRAINING_CONTEXT_PATTERN, " ");
|
||||
|
||||
return EDUCATION_OFFER_CONTEXT_PATTERN.test(productContext);
|
||||
}
|
||||
|
||||
export function hasEducationIntentMismatch(contract: MarketEnrichmentContract, phrase: string) {
|
||||
return hasEducationSearchIntent(phrase) && !hasEducationOfferContext(contract);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
||||
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
||||
|
||||
type KeywordMapState = "blocked" | "draft";
|
||||
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||
|
|
@ -82,6 +83,15 @@ export type KeywordMapPersistResult = {
|
|||
|
||||
type KeywordMapItem = KeywordMapContract["items"][number];
|
||||
type KeywordCleaningItem = KeywordCleaningContract["items"][number];
|
||||
type PersistedKeywordMapLayout = {
|
||||
reason: string | null;
|
||||
relatedLanding: string | null;
|
||||
role: KeywordMapRole;
|
||||
targetPath: string | null;
|
||||
};
|
||||
type KeywordRoleContext = {
|
||||
market: MarketEnrichmentContract;
|
||||
};
|
||||
|
||||
export type KeywordMapApproveInput = {
|
||||
itemIds?: string[];
|
||||
|
|
@ -89,12 +99,39 @@ export type KeywordMapApproveInput = {
|
|||
itemId: string;
|
||||
role: KeywordMapRole;
|
||||
}>;
|
||||
suppressedItemIds?: string[];
|
||||
};
|
||||
|
||||
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
||||
return keywordMapRoles.includes(value as KeywordMapRole);
|
||||
}
|
||||
|
||||
function normalizeKeywordMapDecisionPayload(payload: unknown): Record<string, unknown> {
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as unknown;
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getKeywordMapPayloadRole(payload: unknown) {
|
||||
const role = normalizeKeywordMapDecisionPayload(payload).role;
|
||||
|
||||
return typeof role === "string" && isKeywordMapRole(role) ? role : null;
|
||||
}
|
||||
|
||||
function normalizePhrase(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
|
@ -371,6 +408,10 @@ function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]
|
|||
return 0;
|
||||
}
|
||||
|
||||
function isBackendMissingExactBackfillItem(item: KeywordCleaningItem) {
|
||||
return item.evidenceRefs.some((ref) => ref === "backend:missing-exact-evidence-backfill");
|
||||
}
|
||||
|
||||
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
|
||||
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
|
||||
|
||||
|
|
@ -384,41 +425,137 @@ function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGu
|
|||
);
|
||||
}
|
||||
|
||||
function hasProjectFacetForPrimary(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
|
||||
const semanticFit = getSemanticFitAssessment(item, guard);
|
||||
|
||||
return semanticFit.projectCoverage === null || semanticFit.projectCoverage > 0;
|
||||
}
|
||||
|
||||
function isKeywordMapReviewCandidate(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
|
||||
const semanticFit = getSemanticFitAssessment(item, guard);
|
||||
|
||||
return (
|
||||
item.decision === "risky" &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
Boolean(item.projectOntologyVersionId) &&
|
||||
Boolean(item.wordstatResultId) &&
|
||||
semanticFit.label !== "lost_source_facet"
|
||||
);
|
||||
}
|
||||
|
||||
function hasExactWordstatDemandEvidence(item: KeywordCleaningItem) {
|
||||
return (
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
Boolean(item.wordstatResultId)
|
||||
);
|
||||
}
|
||||
|
||||
function isKeywordMapDisplayCandidate(item: KeywordCleaningItem, guard: SemanticFacetGuard, roleContext: KeywordRoleContext) {
|
||||
const semanticFit = getSemanticFitAssessment(item, guard);
|
||||
|
||||
if (
|
||||
item.decision === "trash" ||
|
||||
isBackendMissingExactBackfillItem(item) ||
|
||||
!hasExactWordstatDemandEvidence(item) ||
|
||||
!item.projectOntologyVersionId ||
|
||||
semanticFit.label === "lost_source_facet" ||
|
||||
hasEducationIntentMismatch(roleContext.market, item.phrase)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (canModelRouteKeyword(item, guard) || isKeywordMapReviewCandidate(item, guard)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.decision === "use" || item.decision === "support";
|
||||
}
|
||||
|
||||
function isLongTailKeywordCandidate(item: KeywordCleaningItem) {
|
||||
return item.priority === "low" || getKeywordWordCount(item.phrase) >= 5 || (item.frequency !== null && item.frequency < 50);
|
||||
}
|
||||
|
||||
function isLowFrequencyOnlyKeywordCandidate(item: KeywordCleaningItem) {
|
||||
return item.priority === "low" || (item.frequency !== null && item.frequency < 50);
|
||||
}
|
||||
|
||||
function isCommercialActionKeywordCandidate(item: KeywordCleaningItem) {
|
||||
const text = normalizePhrase(item.phrase);
|
||||
|
||||
return (
|
||||
item.marketRole === "commercial" ||
|
||||
item.intentType === "commercial" ||
|
||||
/(услуг[а-яёa-z0-9-]*|заказать|купить|стоимост[а-яёa-z0-9-]*|цен[а-яёa-z0-9-]*|под\s+ключ|компани[а-яёa-z0-9-]*\s+по|интегратор[а-яёa-z0-9-]*|внедрен[а-яёa-z0-9-]*|разработк[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)/i.test(
|
||||
text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isEditorialOrLocalTailKeywordCandidate(item: KeywordCleaningItem) {
|
||||
const text = normalizePhrase(item.phrase);
|
||||
|
||||
return /(кейс[а-яёa-z0-9-]*|пример[а-яёa-z0-9-]*|риск[а-яёa-z0-9-]*|специалист[а-яёa-z0-9-]*|компани[а-яёa-z0-9-]*|рынок\s+заказн[а-яёa-z0-9-]*|обзор[а-яёa-z0-9-]*|сравнен[а-яёa-z0-9-]*|рейтинг[а-яёa-z0-9-]*|топ\s+|екатеринбург|ростов|москва|санкт|спб|unity|игр[а-яёa-z0-9-]*)/i.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
|
||||
const text = getKeywordProfileText(item);
|
||||
const text = normalizePhrase(item.phrase);
|
||||
|
||||
return (
|
||||
item.marketRole === "integration" ||
|
||||
item.intentType === "deployment_security" ||
|
||||
item.intentType === "integration" ||
|
||||
/(^|\s)(api|bim|crm|erp|mcp|iot|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
|
||||
/(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц|цифров[а-яёa-z0-9-]*\s+двойн|инженер|тендер|закуп|юрид|договор|беспилот|телеметр|строител|эксплуатац)/i.test(
|
||||
/(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц|цифров[а-яёa-z0-9-]*\s+двойн|инженер|тендер|закуп|юрид|договор|беспилот|телеметр|строител|эксплуатац|промышлен|производств|учет|отчет|логистик|ритейл|медицин)/i.test(
|
||||
text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard: SemanticFacetGuard): KeywordMapRole {
|
||||
function getKeywordRole(
|
||||
item: KeywordCleaningItem,
|
||||
indexInTarget: number,
|
||||
guard: SemanticFacetGuard,
|
||||
roleContext: KeywordRoleContext
|
||||
): KeywordMapRole {
|
||||
if (!canModelRouteKeyword(item, guard)) {
|
||||
return "validate";
|
||||
}
|
||||
|
||||
if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
|
||||
return "primary";
|
||||
if (hasEducationIntentMismatch(roleContext.market, item.phrase)) {
|
||||
return "validate";
|
||||
}
|
||||
|
||||
if (isLongTailKeywordCandidate(item)) {
|
||||
return "support";
|
||||
if (
|
||||
indexInTarget === 0 &&
|
||||
(item.decision === "use" || item.decision === "support") &&
|
||||
(item.priority === "high" || item.priority === "medium") &&
|
||||
!isLongTailKeywordCandidate(item) &&
|
||||
!isClarifyingKeywordCandidate(item) &&
|
||||
hasProjectFacetForPrimary(item, guard)
|
||||
) {
|
||||
return "primary";
|
||||
}
|
||||
|
||||
if (isClarifyingKeywordCandidate(item)) {
|
||||
return "differentiator";
|
||||
}
|
||||
|
||||
if (isEditorialOrLocalTailKeywordCandidate(item)) {
|
||||
return "support";
|
||||
}
|
||||
|
||||
if (isCommercialActionKeywordCandidate(item)) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
if (isLowFrequencyOnlyKeywordCandidate(item)) {
|
||||
return "support";
|
||||
}
|
||||
|
||||
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
|
||||
return "secondary";
|
||||
}
|
||||
|
|
@ -463,15 +600,10 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
|||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||
const targetCounts = new Map<string, number>();
|
||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||
const roleContext: KeywordRoleContext = { market: contract };
|
||||
const seenPhrases = new Set<string>();
|
||||
const sourceItems = cleaning.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.decision !== "trash" &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
Boolean(item.wordstatResultId)
|
||||
)
|
||||
.filter((item) => isKeywordMapDisplayCandidate(item, semanticFacetGuard, roleContext))
|
||||
.sort((left, right) => {
|
||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
||||
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
||||
|
|
@ -526,7 +658,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
|||
const rawTargetPath = item.targetPath;
|
||||
const targetKey = rawTargetPath ?? item.clusterId;
|
||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
|
||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard, roleContext);
|
||||
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
||||
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
||||
|
||||
|
|
@ -831,6 +963,109 @@ async function getKeywordMapPersistenceSummary(
|
|||
};
|
||||
}
|
||||
|
||||
async function getSuppressedKeywordMapItems(
|
||||
projectId: string,
|
||||
semanticRunId: string | null,
|
||||
projectOntologyVersionId: string | null
|
||||
) {
|
||||
const emptySuppressed = {
|
||||
normalizedPhrases: new Set<string>(),
|
||||
sourceItemIds: new Set<string>()
|
||||
};
|
||||
|
||||
if (!semanticRunId || !projectOntologyVersionId) {
|
||||
return emptySuppressed;
|
||||
}
|
||||
|
||||
const result = await pool.query<{ phrase: string | null; source_item_id: string | null }>(
|
||||
`
|
||||
select source_item_id, phrase
|
||||
from keyword_decisions
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and project_ontology_version_id = $3
|
||||
and status = 'suppressed'
|
||||
and (source_item_id is not null or nullif(trim(phrase), '') is not null);
|
||||
`,
|
||||
[projectId, semanticRunId, projectOntologyVersionId]
|
||||
);
|
||||
|
||||
return {
|
||||
normalizedPhrases: new Set(
|
||||
result.rows
|
||||
.map((row) => (row.phrase ? normalizePhrase(row.phrase) : null))
|
||||
.filter((phrase): phrase is string => Boolean(phrase))
|
||||
),
|
||||
sourceItemIds: new Set(result.rows.map((row) => row.source_item_id).filter((id): id is string => Boolean(id)))
|
||||
};
|
||||
}
|
||||
|
||||
async function getPersistedKeywordMapLayouts(
|
||||
projectId: string,
|
||||
semanticRunId: string | null,
|
||||
projectOntologyVersionId: string | null
|
||||
) {
|
||||
if (!semanticRunId || !projectOntologyVersionId) {
|
||||
return new Map<string, PersistedKeywordMapLayout>();
|
||||
}
|
||||
|
||||
const result = await pool.query<{
|
||||
source_item_id: string;
|
||||
target_path: string | null;
|
||||
related_landing: string | null;
|
||||
reason: string | null;
|
||||
payload: unknown;
|
||||
}>(
|
||||
`
|
||||
select
|
||||
source_item_id,
|
||||
target_path,
|
||||
payload->>'relatedLanding' as related_landing,
|
||||
reason,
|
||||
payload
|
||||
from keyword_decisions
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and project_ontology_version_id = $3
|
||||
and source_item_id is not null
|
||||
and (
|
||||
(status = 'approved' and approved = true)
|
||||
or status = 'layout'
|
||||
);
|
||||
`,
|
||||
[projectId, semanticRunId, projectOntologyVersionId]
|
||||
);
|
||||
const layouts = new Map<string, PersistedKeywordMapLayout>();
|
||||
|
||||
for (const row of result.rows) {
|
||||
const role = getKeywordMapPayloadRole(row.payload);
|
||||
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
layouts.set(row.source_item_id, {
|
||||
reason: row.reason,
|
||||
relatedLanding: row.related_landing,
|
||||
role,
|
||||
targetPath: row.target_path
|
||||
});
|
||||
}
|
||||
|
||||
return layouts;
|
||||
}
|
||||
|
||||
function applyPersistedKeywordMapLayout(item: KeywordMapItem, layout: PersistedKeywordMapLayout): KeywordMapItem {
|
||||
const nonRewriteRole = layout.role === "validate" || layout.role === "secondary_candidate";
|
||||
|
||||
return {
|
||||
...item,
|
||||
relatedLanding: layout.relatedLanding ?? (nonRewriteRole ? (item.relatedLanding ?? item.targetPath) : item.relatedLanding),
|
||||
role: layout.role,
|
||||
targetPath: nonRewriteRole ? null : (layout.targetPath ?? item.targetPath ?? item.relatedLanding)
|
||||
};
|
||||
}
|
||||
|
||||
function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||
if (contract.blockers.length > 0) {
|
||||
return contract.blockers;
|
||||
|
|
@ -840,7 +1075,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
|||
|
||||
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
||||
actions.push(
|
||||
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||
"Keyword cleaning не дал кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||
);
|
||||
|
||||
if (contract.readiness.marketEvidenceCount === 0) {
|
||||
|
|
@ -880,12 +1115,23 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
|
|||
]);
|
||||
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
|
||||
const blockers = buildBlockers(marketContract, cleaningContract);
|
||||
const items = buildItems(marketContract, cleaningContract, reviewedOntology);
|
||||
const persisted = await getKeywordMapPersistenceSummary(
|
||||
projectId,
|
||||
marketContract.semanticRunId,
|
||||
marketContract.projectOntologyVersion?.id ?? null
|
||||
);
|
||||
const semanticRunId = marketContract.semanticRunId;
|
||||
const projectOntologyVersionId = marketContract.projectOntologyVersion?.id ?? null;
|
||||
const [suppressedItems, persistedLayouts, persisted] = await Promise.all([
|
||||
getSuppressedKeywordMapItems(projectId, semanticRunId, projectOntologyVersionId),
|
||||
getPersistedKeywordMapLayouts(projectId, semanticRunId, projectOntologyVersionId),
|
||||
getKeywordMapPersistenceSummary(projectId, semanticRunId, projectOntologyVersionId)
|
||||
]);
|
||||
const items = buildItems(marketContract, cleaningContract, reviewedOntology)
|
||||
.map((item) => {
|
||||
const persistedLayout = persistedLayouts.get(item.id);
|
||||
|
||||
return persistedLayout ? applyPersistedKeywordMapLayout(item, persistedLayout) : item;
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
!suppressedItems.sourceItemIds.has(item.id) && !suppressedItems.normalizedPhrases.has(normalizePhrase(item.phrase))
|
||||
);
|
||||
const contractWithoutActions: Omit<KeywordMapContract, "nextActions"> = {
|
||||
schemaVersion: "keyword-map.v1",
|
||||
projectId,
|
||||
|
|
@ -935,6 +1181,7 @@ export async function approveKeywordMapDecisions(
|
|||
.filter((override) => isKeywordMapRole(override.role))
|
||||
.map((override) => [override.itemId, override.role] as const)
|
||||
);
|
||||
const suppressedItemIds = new Set(input.suppressedItemIds ?? []);
|
||||
const selectedItems = keywordMap.items
|
||||
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
||||
.map((item) => {
|
||||
|
|
@ -942,9 +1189,17 @@ export async function approveKeywordMapDecisions(
|
|||
|
||||
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
|
||||
});
|
||||
const persistableItems = selectedItems.filter(isPersistableKeywordMapItem);
|
||||
const suppressedItems = selectedItems.filter((item) => suppressedItemIds.has(item.id));
|
||||
const persistableItems = selectedItems.filter((item) => !suppressedItemIds.has(item.id) && isPersistableKeywordMapItem(item));
|
||||
const canApproveMap = keywordMap.blockers.length === 0;
|
||||
const approvableItems = canApproveMap ? persistableItems : [];
|
||||
const layoutItems = selectedItems.filter(
|
||||
(item) =>
|
||||
!suppressedItemIds.has(item.id) &&
|
||||
(!isPersistableKeywordMapItem(item) || (!canApproveMap && roleOverrideByItemId.has(item.id)))
|
||||
);
|
||||
|
||||
if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
|
||||
if (!keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
|
||||
return {
|
||||
keywordMap,
|
||||
savedDecisionCount: 0,
|
||||
|
|
@ -960,9 +1215,9 @@ export async function approveKeywordMapDecisions(
|
|||
|
||||
let savedDecisionCount = 0;
|
||||
let savedMapItemCount = 0;
|
||||
const activeSourceItemIds = persistableItems.map((item) => item.id);
|
||||
const activeSourceItemIds = approvableItems.map((item) => item.id);
|
||||
|
||||
if (!itemIdFilter) {
|
||||
if (!itemIdFilter && canApproveMap) {
|
||||
if (activeSourceItemIds.length > 0) {
|
||||
await client.query(
|
||||
`
|
||||
|
|
@ -1016,7 +1271,166 @@ export async function approveKeywordMapDecisions(
|
|||
}
|
||||
}
|
||||
|
||||
for (const item of persistableItems) {
|
||||
for (const item of suppressedItems) {
|
||||
await client.query(
|
||||
`
|
||||
update keyword_map_items
|
||||
set approved = false, updated_at = now()
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and project_ontology_version_id = $3
|
||||
and source_item_id = $4
|
||||
and approved = true;
|
||||
`,
|
||||
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into keyword_decisions (
|
||||
project_id,
|
||||
semantic_run_id,
|
||||
project_ontology_version_id,
|
||||
wordstat_result_id,
|
||||
source_item_id,
|
||||
cluster_id,
|
||||
cluster_title,
|
||||
target_path,
|
||||
phrase,
|
||||
status,
|
||||
reason,
|
||||
approved,
|
||||
evidence_status,
|
||||
frequency,
|
||||
frequency_group,
|
||||
payload,
|
||||
updated_at
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, null, $8, 'suppressed', $9, false, $10, $11, $12, $13::jsonb, now())
|
||||
on conflict (project_id, project_ontology_version_id, source_item_id)
|
||||
where source_item_id is not null
|
||||
do update set
|
||||
semantic_run_id = excluded.semantic_run_id,
|
||||
wordstat_result_id = excluded.wordstat_result_id,
|
||||
cluster_id = excluded.cluster_id,
|
||||
cluster_title = excluded.cluster_title,
|
||||
target_path = null,
|
||||
phrase = excluded.phrase,
|
||||
status = excluded.status,
|
||||
reason = excluded.reason,
|
||||
approved = false,
|
||||
evidence_status = excluded.evidence_status,
|
||||
frequency = excluded.frequency,
|
||||
frequency_group = excluded.frequency_group,
|
||||
payload = excluded.payload,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
keywordMap.semanticRunId,
|
||||
keywordMap.projectOntologyVersion.id,
|
||||
item.wordstatResultId,
|
||||
item.id,
|
||||
item.clusterId,
|
||||
item.clusterTitle,
|
||||
item.phrase,
|
||||
"Пользователь удалил фразу из Stage 5 distribution board.",
|
||||
item.evidenceStatus,
|
||||
item.frequency,
|
||||
item.frequencyGroup,
|
||||
JSON.stringify({
|
||||
relatedLanding: item.relatedLanding,
|
||||
role: item.role,
|
||||
source: item.source,
|
||||
priority: item.priority,
|
||||
normalizedPhrase: normalizePhrase(item.phrase),
|
||||
suppressedAt: new Date().toISOString()
|
||||
})
|
||||
]
|
||||
);
|
||||
savedDecisionCount += 1;
|
||||
}
|
||||
|
||||
for (const item of layoutItems) {
|
||||
await client.query(
|
||||
`
|
||||
update keyword_map_items
|
||||
set approved = false, updated_at = now()
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and project_ontology_version_id = $3
|
||||
and source_item_id = $4
|
||||
and approved = true;
|
||||
`,
|
||||
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into keyword_decisions (
|
||||
project_id,
|
||||
semantic_run_id,
|
||||
project_ontology_version_id,
|
||||
wordstat_result_id,
|
||||
source_item_id,
|
||||
cluster_id,
|
||||
cluster_title,
|
||||
target_path,
|
||||
phrase,
|
||||
status,
|
||||
reason,
|
||||
approved,
|
||||
evidence_status,
|
||||
frequency,
|
||||
frequency_group,
|
||||
payload,
|
||||
updated_at
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, null, $8, 'layout', $9, false, $10, $11, $12, $13::jsonb, now())
|
||||
on conflict (project_id, project_ontology_version_id, source_item_id)
|
||||
where source_item_id is not null
|
||||
do update set
|
||||
semantic_run_id = excluded.semantic_run_id,
|
||||
wordstat_result_id = excluded.wordstat_result_id,
|
||||
cluster_id = excluded.cluster_id,
|
||||
cluster_title = excluded.cluster_title,
|
||||
target_path = null,
|
||||
phrase = excluded.phrase,
|
||||
status = excluded.status,
|
||||
reason = excluded.reason,
|
||||
approved = false,
|
||||
evidence_status = excluded.evidence_status,
|
||||
frequency = excluded.frequency,
|
||||
frequency_group = excluded.frequency_group,
|
||||
payload = excluded.payload,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
keywordMap.semanticRunId,
|
||||
keywordMap.projectOntologyVersion.id,
|
||||
item.wordstatResultId,
|
||||
item.id,
|
||||
item.clusterId,
|
||||
item.clusterTitle,
|
||||
item.phrase,
|
||||
item.reason,
|
||||
item.evidenceStatus,
|
||||
item.frequency,
|
||||
item.frequencyGroup,
|
||||
JSON.stringify({
|
||||
relatedLanding: item.relatedLanding,
|
||||
role: item.role,
|
||||
source: item.source,
|
||||
priority: item.priority,
|
||||
projectOntologyVersionId: item.projectOntologyVersionId
|
||||
})
|
||||
]
|
||||
);
|
||||
savedDecisionCount += 1;
|
||||
}
|
||||
|
||||
for (const item of approvableItems) {
|
||||
const decisionResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into keyword_decisions (
|
||||
|
|
@ -1177,7 +1591,7 @@ export async function approveKeywordMapDecisions(
|
|||
keywordMap: await getKeywordMapContract(projectId),
|
||||
savedDecisionCount,
|
||||
savedMapItemCount,
|
||||
skippedItemCount: selectedItems.length - persistableItems.length
|
||||
skippedItemCount: selectedItems.length - suppressedItems.length - layoutItems.length - approvableItems.length
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("rollback");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,20 @@ function getString(value: unknown) {
|
|||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizePhrase(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function isRealWordstatPhrase(value: string) {
|
||||
const normalizedValue = normalizePhrase(value);
|
||||
|
||||
return (
|
||||
normalizedValue.length > 1 &&
|
||||
/[a-zа-яё0-9]/i.test(normalizedValue) &&
|
||||
!["-", "—", "n/a", "na", "null", "undefined", "нет данных", "не применимо"].includes(normalizedValue)
|
||||
);
|
||||
}
|
||||
|
||||
function getNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.round(value));
|
||||
|
|
@ -124,6 +138,10 @@ function asArray(value: unknown) {
|
|||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function mergeArraySources(...values: unknown[]) {
|
||||
return values.flatMap((value) => asArray(value));
|
||||
}
|
||||
|
||||
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
||||
const record = asRecord(value);
|
||||
const phrase =
|
||||
|
|
@ -133,7 +151,7 @@ function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
|||
getString(record.text) ||
|
||||
getString(record.name);
|
||||
|
||||
if (!phrase) {
|
||||
if (!phrase || !isRealWordstatPhrase(phrase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -162,20 +180,27 @@ function normalizeSeedResult(value: unknown, seed: WordstatSeedInput): WordstatS
|
|||
getNumber(record.count) ??
|
||||
getNumber(record.total) ??
|
||||
getNumber(record.impressions);
|
||||
const relatedSource =
|
||||
record.relatedQueries ??
|
||||
record.related_queries ??
|
||||
record.queries ??
|
||||
record.items ??
|
||||
record.results ??
|
||||
record.associations;
|
||||
const relatedSource = mergeArraySources(
|
||||
record.relatedQueries,
|
||||
record.related_queries,
|
||||
record.queries,
|
||||
record.items,
|
||||
record.results,
|
||||
record.associations,
|
||||
record.topRequests,
|
||||
record.top_requests,
|
||||
record.top,
|
||||
record.popular,
|
||||
record.popularQueries,
|
||||
record.popular_queries
|
||||
);
|
||||
|
||||
return {
|
||||
seedId: seed.seedId,
|
||||
seedPhrase: phrase,
|
||||
seedPhrase: isRealWordstatPhrase(phrase) ? phrase : seed.phrase,
|
||||
frequency,
|
||||
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
|
||||
relatedQueries: asArray(relatedSource).flatMap((related) => {
|
||||
relatedQueries: relatedSource.flatMap((related) => {
|
||||
const normalized = normalizeRelatedQuery(related);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
|
|
@ -237,14 +262,22 @@ class DisabledWordstatProvider implements WordstatProvider {
|
|||
}
|
||||
|
||||
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
|
||||
throw new Error(`Wordstat provider недоступен: backend не смог подключиться к ${endpoint}.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "");
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ type WordstatResultRow = {
|
|||
|
||||
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
||||
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
||||
const WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION = 16;
|
||||
|
||||
export type WordstatQuotaSnapshot = {
|
||||
provider: string;
|
||||
|
|
@ -154,8 +155,18 @@ function normalizePhrase(value: string) {
|
|||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
const WORDSTAT_TOP_RESULTS_LIMIT = 160;
|
||||
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12;
|
||||
function isRealWordstatPhrase(value: string) {
|
||||
const normalizedValue = normalizePhrase(value);
|
||||
|
||||
return (
|
||||
normalizedValue.length > 1 &&
|
||||
/[a-zа-яё0-9]/i.test(normalizedValue) &&
|
||||
!["-", "—", "n/a", "na", "null", "undefined", "нет данных", "не применимо"].includes(normalizedValue)
|
||||
);
|
||||
}
|
||||
|
||||
const WORDSTAT_TOP_RESULTS_LIMIT = 240;
|
||||
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 16;
|
||||
|
||||
const RELATED_QUERY_STOP_WORDS = new Set([
|
||||
"a",
|
||||
|
|
@ -270,7 +281,7 @@ function shouldKeepRelatedQuery(seedPhrase: string, relatedPhrase: string) {
|
|||
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
|
||||
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
|
||||
|
||||
if (!normalizedRelatedPhrase || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
||||
if (!isRealWordstatPhrase(normalizedRelatedPhrase) || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -352,7 +363,7 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
|
|||
const topResultByPhrase = new Map<string, WordstatResultRow>();
|
||||
|
||||
for (const result of results) {
|
||||
if (result.frequency === null) {
|
||||
if (result.frequency === null || !isRealWordstatPhrase(result.phrase)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -448,10 +459,11 @@ function buildEvidence(
|
|||
results: WordstatResultRow[],
|
||||
quota: WordstatQuotaSnapshot | null
|
||||
): WordstatEvidence {
|
||||
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||
const realResults = results.filter((result) => isRealWordstatPhrase(result.phrase));
|
||||
const seedRows = realResults.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||
const relatedCounts = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
for (const result of realResults) {
|
||||
if (result.source_phrase && result.source_phrase !== result.phrase) {
|
||||
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
|
||||
}
|
||||
|
|
@ -475,7 +487,7 @@ function buildEvidence(
|
|||
seedSource: result.seed_source,
|
||||
collectedAt: result.created_at.toISOString()
|
||||
})),
|
||||
topResults: getRepresentativeTopResultRows(results)
|
||||
topResults: getRepresentativeTopResultRows(realResults)
|
||||
.map((result) => ({
|
||||
id: result.id,
|
||||
phrase: result.phrase,
|
||||
|
|
@ -497,11 +509,11 @@ function buildEvidence(
|
|||
noSignalSeedCount: seedRows.filter(
|
||||
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
|
||||
).length,
|
||||
resultCount: results.length,
|
||||
totalFrequency: results.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
||||
highFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
||||
midFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
||||
lowFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
|
||||
resultCount: realResults.length,
|
||||
totalFrequency: realResults.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
||||
highFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
||||
midFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
||||
lowFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -599,6 +611,27 @@ async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: strin
|
|||
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
|
||||
}
|
||||
|
||||
export async function getRequestedWordstatPhraseSet(projectId: string, semanticRunId: string) {
|
||||
const result = await pool.query<{ requested_phrases: string[] | null }>(
|
||||
`
|
||||
select requested_phrases
|
||||
from wordstat_jobs
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and status in ('queued', 'running', 'done', 'failed')
|
||||
and jsonb_array_length(requested_phrases) > 0;
|
||||
`,
|
||||
[projectId, semanticRunId]
|
||||
);
|
||||
|
||||
return new Set(
|
||||
result.rows
|
||||
.flatMap((row) => Array.isArray(row.requested_phrases) ? row.requested_phrases : [])
|
||||
.map(normalizePhrase)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
async function getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
|
||||
const result = await pool.query<{
|
||||
id: string;
|
||||
|
|
@ -860,7 +893,8 @@ async function updateJobFailed(jobId: string, error: unknown) {
|
|||
export async function collectWordstatEvidence(
|
||||
projectId: string,
|
||||
analysis: SemanticAnalysisRun,
|
||||
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates
|
||||
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates,
|
||||
options: { maxSeeds?: number } = {}
|
||||
) {
|
||||
const provider = await createWordstatProvider();
|
||||
const status = provider.getStatus();
|
||||
|
|
@ -871,14 +905,20 @@ export async function collectWordstatEvidence(
|
|||
|
||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
||||
const uncollectedSeedRows = seedRows.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)));
|
||||
const requestedSeedPhrases = await getRequestedWordstatPhraseSet(projectId, analysis.runId);
|
||||
const alreadyTriedSeedPhrases = new Set([...collectedSeedPhrases, ...requestedSeedPhrases]);
|
||||
const uncollectedSeedRows = seedRows.filter((row) => !alreadyTriedSeedPhrases.has(normalizePhrase(row.phrase)));
|
||||
|
||||
if (uncollectedSeedRows.length === 0) {
|
||||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||
}
|
||||
|
||||
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
|
||||
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining);
|
||||
const requestedMaxSeeds = Math.min(
|
||||
options.maxSeeds ?? status.maxSeedsPerRun,
|
||||
WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION
|
||||
);
|
||||
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining, requestedMaxSeeds);
|
||||
|
||||
if (maxSeedsForRun <= 0) {
|
||||
throw new Error(
|
||||
|
|
@ -943,9 +983,7 @@ export async function getLatestWordstatEvidence(
|
|||
updated_at
|
||||
from wordstat_jobs
|
||||
where project_id = $1 and semantic_run_id = $2
|
||||
order by
|
||||
case when status = 'done' then 0 else 1 end,
|
||||
created_at desc
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId, semanticRunId]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
||||
import {
|
||||
buildSeoBusinessSynthesisTask,
|
||||
getLatestAlignedSeoBusinessSynthesis,
|
||||
type SeoBusinessSynthesisModelTaskContract
|
||||
} from "../business/seoBusinessSynthesis.js";
|
||||
import {
|
||||
buildSeoCommercialDemandTask,
|
||||
getLatestAlignedSeoCommercialDemand,
|
||||
type SeoCommercialDemandModelTaskContract
|
||||
} from "../commercial/seoCommercialDemand.js";
|
||||
import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
|
||||
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
||||
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
||||
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
||||
import {
|
||||
getMarketFrontierDiscoveryContract,
|
||||
type MarketFrontierDiscoveryModelTaskContract
|
||||
} from "../market/marketFrontierDiscovery.js";
|
||||
import {
|
||||
getSeoModelProviderCatalog,
|
||||
type SeoModelProviderCatalog
|
||||
|
|
@ -26,7 +40,10 @@ import {
|
|||
|
||||
export type SeoModelTaskContract =
|
||||
| KeywordCleaningModelTaskContract
|
||||
| SeoBusinessSynthesisModelTaskContract
|
||||
| SeoCommercialDemandModelTaskContract
|
||||
| SeoContextReviewModelTaskContract
|
||||
| MarketFrontierDiscoveryModelTaskContract
|
||||
| SeoNormalizationModelTaskContract
|
||||
| SerpInterpretationModelTaskContract
|
||||
| StrategyQualityReviewModelTaskContract
|
||||
|
|
@ -64,8 +81,13 @@ export type SeoModelContextContract = {
|
|||
} | null;
|
||||
stageReadiness: {
|
||||
semanticContextReady: boolean;
|
||||
businessSynthesisTaskReady: boolean;
|
||||
businessSynthesisResultReady: boolean;
|
||||
commercialDemandTaskReady: boolean;
|
||||
commercialDemandResultReady: boolean;
|
||||
contextReviewTaskReady: boolean;
|
||||
contextReviewResultReady: boolean;
|
||||
marketFrontierDiscoveryTaskReady: boolean;
|
||||
normalizationState: "not_ready" | "ready";
|
||||
normalizationTaskReady: boolean;
|
||||
keywordCleaningState: "not_ready" | "ready";
|
||||
|
|
@ -80,7 +102,7 @@ export type SeoModelContextContract = {
|
|||
modelProvider: SeoModelProviderCatalog;
|
||||
contextReview: {
|
||||
runId: string;
|
||||
providerMode: "codex_manual" | "deterministic_fallback";
|
||||
providerMode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
generatedAt: string;
|
||||
needsHumanReview: boolean;
|
||||
summary: string;
|
||||
|
|
@ -167,7 +189,12 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
const skills = getSeoSkillPackContract();
|
||||
const modelProvider = getSeoModelProviderCatalog();
|
||||
const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null;
|
||||
const businessSynthesis = analysis ? await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId) : null;
|
||||
const commercialDemand = analysis ? await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId) : null;
|
||||
const marketFrontierDiscovery = analysis ? await getMarketFrontierDiscoveryContract(projectId) : null;
|
||||
const contextReviewTask = analysis ? buildSeoContextReviewTask(projectId, analysis) : null;
|
||||
const businessSynthesisTask = analysis ? buildSeoBusinessSynthesisTask(projectId, analysis, contextReview) : null;
|
||||
const commercialDemandTask = businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null;
|
||||
const serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence);
|
||||
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
|
||||
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
|
||||
|
|
@ -178,6 +205,9 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
);
|
||||
const modelTasks = [
|
||||
contextReviewTask,
|
||||
businessSynthesisTask,
|
||||
commercialDemandTask,
|
||||
marketFrontierDiscovery?.modelTask,
|
||||
normalization.modelTask,
|
||||
keywordCleaning.modelTask,
|
||||
serpInterpretationTask,
|
||||
|
|
@ -225,8 +255,14 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
allowedEvidence: [
|
||||
"latest scan metadata",
|
||||
"project ontology summary",
|
||||
"clean selected site text chunks",
|
||||
"active SEO skill pack contracts",
|
||||
"context review model task",
|
||||
"business synthesis model task",
|
||||
"business synthesis product-frame evidence",
|
||||
"commercial demand model task",
|
||||
"commercial demand buyer-intent evidence",
|
||||
"market frontier discovery memory and query-language fingerprint",
|
||||
"semantic clusters and source refs",
|
||||
"normalization model task",
|
||||
"keyword cleaning model task",
|
||||
|
|
@ -262,8 +298,13 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
: null,
|
||||
stageReadiness: {
|
||||
semanticContextReady: Boolean(analysis),
|
||||
businessSynthesisTaskReady: Boolean(businessSynthesisTask),
|
||||
businessSynthesisResultReady: Boolean(businessSynthesis),
|
||||
commercialDemandTaskReady: Boolean(commercialDemandTask),
|
||||
commercialDemandResultReady: Boolean(commercialDemand),
|
||||
contextReviewTaskReady: Boolean(contextReviewTask),
|
||||
contextReviewResultReady: Boolean(contextReview),
|
||||
marketFrontierDiscoveryTaskReady: Boolean(marketFrontierDiscovery?.modelTask),
|
||||
normalizationState: normalization.state,
|
||||
normalizationTaskReady: Boolean(normalization.modelTask),
|
||||
keywordCleaningState: keywordCleaning.state,
|
||||
|
|
@ -348,6 +389,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
||||
"Stage 3 не должен превращаться в мусорный буфер: backend-only missing exact backfill хранится как evidence/audit, но не как user-facing candidate без model-returned semantic fit.",
|
||||
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||
],
|
||||
nextActions: !analysis
|
||||
|
|
@ -360,7 +402,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать широкие кандидаты в пять колонок для ручной правки; exact-frequency нужен для строгой фиксации.",
|
||||
"Не запускать external market collection без anchor review approval."
|
||||
]
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ export type SeoAiWorkspacePersistedResult = {
|
|||
schemaVersion: "seo-model-persisted-result.v1";
|
||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||
runType:
|
||||
| "seo_business_synthesis"
|
||||
| "seo_commercial_demand"
|
||||
| "seo_context_review"
|
||||
| "keyword_cleaning"
|
||||
| "market_frontier_discovery"
|
||||
| "seo_model_task"
|
||||
| "seo_normalization"
|
||||
| "seo_strategy_quality_review"
|
||||
|
|
@ -150,6 +154,10 @@ export async function assistantRequest<T>(
|
|||
throw new Error("ai_workspace_assistant_timeout");
|
||||
}
|
||||
|
||||
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
|
||||
throw new Error("AI Workspace bridge недоступен: backend не смог подключиться к Assistant/Hub endpoint.");
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
|
@ -169,6 +177,14 @@ function buildSeoModelTaskPrompt(input: {
|
|||
`Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`,
|
||||
"If the output contract has bookkeeping keys such as modelTask, sourceTaskId, source, semanticRunId, projectId, or provider, populate them from the task contract and the provided evidence.",
|
||||
"For keyword tasks, classify only phrases present in task.input.seedQueue or task.input.wordstatTopResults; keep unresolved or weak rows out of approved rewrite lanes.",
|
||||
"For business synthesis tasks, behave like a senior product SEO strategist: read the site evidence, identify the central product offer, platform layers, application vectors, and demand families, while clearly separating explicit claims, conservative inferences, and risky expansion.",
|
||||
"For commercial demand tasks, behave like a buyer-intent SEO strategist: convert product architecture into purchasing, evaluation, implementation, automation, integration, replacement, pilot, or vendor-selection demand. Preserve the product's core differentiator from productFrame/corePillars in every vertical/application query pattern; do not output pure informational or architecture-inventory phrases as demand.",
|
||||
"For market frontier discovery tasks, behave like a market-research SEO strategist for a non-SEO user: read productUnderstanding, approvedSemanticBasis, marketMemory, and queryLanguageFingerprint; identify demand frontiers that current coveredPhrases/requestedSeeds do not cover; propose a short, high-leverage Wordstat probeSeeds list that can harvest top/related/popular rows. Do not return repeated, suppressed, n/a, or final Stage 3 keyword clutter.",
|
||||
"For context review and normalization tasks, treat task.input.siteTextEvidence.chunks as the primary cleaned text corpus of the selected site scope when it is present.",
|
||||
"For normalization tasks, use task.input.siteTextEvidence.highSignalLines as a checklist of short visible meanings: represent non-technical product, module, use-case, integration, audience, workflow, or vertical lines as needs-human query/corePhrase candidates, or put them in excludedPhrases with an explicit reason.",
|
||||
"For normalization tasks, use task.input.businessSynthesis as the product hierarchy: keep productFrame/corePillars as the semantic center and keep applicationVectors as use cases or vertical branches unless the synthesis explicitly marks them core.",
|
||||
"For normalization tasks, use task.input.commercialDemand as the buyer-intent frame: seedStrategy and wordstatQueries should be commercial/problem-solution/evaluation/implementation phrases, not raw architecture theses. seedStrategy must include the actual semantic-basis query candidates across buyer-intent groups, not only 3-4 core phrases; target 24-60 needs-human seeds and keep them concise enough for market review. Do not shorten by deleting the product core differentiator; a longer qualified buyer query is better than a short generic phrase for another market. If a phrase is only informational, put it in excludedPhrases or rejectedSeeds with a reason.",
|
||||
"Decompose meanings from that corpus first, then use semanticSignals/pageSignals as backend hints and cross-checks.",
|
||||
"",
|
||||
"Hard boundaries:",
|
||||
"- Work only from the task contract below.",
|
||||
|
|
@ -381,11 +397,52 @@ function parseJsonFromText(rawText: string): JsonRecord | null {
|
|||
}
|
||||
|
||||
const firstBrace = text.indexOf("{");
|
||||
const lastBrace = text.lastIndexOf("}");
|
||||
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
if (firstBrace >= 0) {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = firstBrace; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
escaped = inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"") {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
|
||||
if (depth === 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(firstBrace, index + 1));
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
|
||||
const parsed = JSON.parse(text.slice(firstBrace));
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
|||
|
||||
export const SEO_MODEL_TASK_TYPES = [
|
||||
"seo.context_review",
|
||||
"seo.business_synthesis",
|
||||
"seo.commercial_demand",
|
||||
"seo.market_frontier_discovery",
|
||||
"seo.normalization",
|
||||
"seo.keyword_cleaning",
|
||||
"seo.serp_interpretation",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import {
|
||||
buildSeoBusinessSynthesisPromotionFallback,
|
||||
saveSeoBusinessSynthesisFromModelProvider,
|
||||
type SeoBusinessSynthesisContract,
|
||||
type SeoBusinessSynthesisModelTaskContract
|
||||
} from "../business/seoBusinessSynthesis.js";
|
||||
import {
|
||||
buildSeoCommercialDemandPromotionFallback,
|
||||
saveSeoCommercialDemandFromModelProvider,
|
||||
type SeoCommercialDemandContract,
|
||||
type SeoCommercialDemandModelTaskContract
|
||||
} from "../commercial/seoCommercialDemand.js";
|
||||
import { saveSeoContextReviewFromModelProvider, type SeoContextReviewOutput } from "../contextReview/seoContextReview.js";
|
||||
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
import {
|
||||
saveMarketFrontierDiscoveryFromModelProvider,
|
||||
type MarketFrontierDiscoveryContract
|
||||
} from "../market/marketFrontierDiscovery.js";
|
||||
import {
|
||||
saveSeoNormalizationFromModelProvider,
|
||||
type SeoNormalizationContract,
|
||||
type SeoNormalizationModelTaskContract
|
||||
} from "../normalization/seoNormalization.js";
|
||||
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
||||
import {
|
||||
|
|
@ -157,6 +178,22 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
|||
return 2600;
|
||||
}
|
||||
|
||||
if (task.taskType === "seo.business_synthesis") {
|
||||
return 4800;
|
||||
}
|
||||
|
||||
if (task.taskType === "seo.commercial_demand") {
|
||||
return 5200;
|
||||
}
|
||||
|
||||
if (task.taskType === "seo.market_frontier_discovery") {
|
||||
return 6200;
|
||||
}
|
||||
|
||||
if (task.taskType === "seo.normalization") {
|
||||
return 4200;
|
||||
}
|
||||
|
||||
if (task.taskType === "seo.keyword_cleaning") {
|
||||
return 9000;
|
||||
}
|
||||
|
|
@ -417,6 +454,68 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function getRecordString(record: Record<string, unknown>, key: string) {
|
||||
const value = record[key];
|
||||
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function getContextReviewPromotionFallback(projectId: string, run: SeoModelTaskRun) {
|
||||
const inputTask = isRecord(run.input.task) ? run.input.task : {};
|
||||
const semanticRunId = getRecordString(inputTask, "semanticRunId") ?? run.task.semanticRunId;
|
||||
const sourceTaskId = getRecordString(inputTask, "taskId") ?? run.task.taskId;
|
||||
|
||||
if (!semanticRunId || !sourceTaskId) {
|
||||
throw new Error("Context Review promotion fallback не может определить semanticRunId/sourceTaskId.");
|
||||
}
|
||||
|
||||
const analysisResult = await pool.query<{ output: { scanVersionId?: unknown; projectOntology?: { versionId?: unknown } } | null }>(
|
||||
`
|
||||
select output
|
||||
from runs
|
||||
where id = $1
|
||||
and project_id = $2
|
||||
and run_type = 'semantic_analysis'
|
||||
limit 1;
|
||||
`,
|
||||
[semanticRunId, projectId]
|
||||
);
|
||||
const analysisOutput = analysisResult.rows[0]?.output ?? null;
|
||||
const scanVersionId = getRecordString(inputTask, "scanVersionId") ?? (
|
||||
typeof analysisOutput?.scanVersionId === "string" ? analysisOutput.scanVersionId : null
|
||||
);
|
||||
const projectOntologyVersionId = getRecordString(inputTask, "projectOntologyVersionId") ?? (
|
||||
typeof analysisOutput?.projectOntology?.versionId === "string" ? analysisOutput.projectOntology.versionId : null
|
||||
);
|
||||
|
||||
if (!scanVersionId) {
|
||||
throw new Error("Context Review promotion fallback не может определить scanVersionId.");
|
||||
}
|
||||
|
||||
return {
|
||||
projectOntologyVersionId,
|
||||
scanVersionId,
|
||||
semanticRunId,
|
||||
sourceTaskId
|
||||
};
|
||||
}
|
||||
|
||||
function getNormalizationPromotionFallback(run: SeoModelTaskRun) {
|
||||
const inputTask = isRecord(run.input.task) ? run.input.task : null;
|
||||
const semanticRunId = inputTask ? getRecordString(inputTask, "semanticRunId") : run.task.semanticRunId;
|
||||
const taskId = inputTask ? getRecordString(inputTask, "taskId") : run.task.taskId;
|
||||
|
||||
if (!inputTask || !semanticRunId || !taskId) {
|
||||
throw new Error("SEO normalization promotion fallback не может определить semanticRunId/modelTask.taskId.");
|
||||
}
|
||||
|
||||
return {
|
||||
modelTask: inputTask as unknown as SeoNormalizationModelTaskContract,
|
||||
projectOntologyVersionId: getRecordString(inputTask, "projectOntologyVersionId"),
|
||||
semanticRunId
|
||||
};
|
||||
}
|
||||
|
||||
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
|
||||
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
||||
? run.modelOutput.aiWorkspace
|
||||
|
|
@ -465,11 +564,91 @@ async function promoteAiWorkspaceModelOutput(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.context_review") {
|
||||
const fallback = await getContextReviewPromotionFallback(projectId, run);
|
||||
|
||||
await saveSeoContextReviewFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as SeoContextReviewOutput,
|
||||
run.runId,
|
||||
fallback
|
||||
);
|
||||
return {
|
||||
nextAction: "Context Review обновлён из AI Workspace output; normalization/anchor review должны читать model-backed semantic basis.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_context_review",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.business_synthesis") {
|
||||
const inputTask = isRecord(run.input.task) ? run.input.task as unknown as SeoBusinessSynthesisModelTaskContract : null;
|
||||
|
||||
if (!inputTask) {
|
||||
throw new Error("Business synthesis promotion fallback не может определить modelTask.");
|
||||
}
|
||||
|
||||
await saveSeoBusinessSynthesisFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as SeoBusinessSynthesisContract,
|
||||
run.runId,
|
||||
buildSeoBusinessSynthesisPromotionFallback(inputTask)
|
||||
);
|
||||
return {
|
||||
nextAction: "Business synthesis обновлен из AI Workspace output; normalization должна читать product-frame перед semantic basis.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_business_synthesis",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.commercial_demand") {
|
||||
const inputTask = isRecord(run.input.task) ? run.input.task as unknown as SeoCommercialDemandModelTaskContract : null;
|
||||
|
||||
if (!inputTask) {
|
||||
throw new Error("Commercial demand promotion fallback не может определить modelTask.");
|
||||
}
|
||||
|
||||
await saveSeoCommercialDemandFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as SeoCommercialDemandContract,
|
||||
run.runId,
|
||||
buildSeoCommercialDemandPromotionFallback(inputTask)
|
||||
);
|
||||
return {
|
||||
nextAction: "Commercial demand обновлен из AI Workspace output; normalization должна читать buyer-intent framing перед semantic basis.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_commercial_demand",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.market_frontier_discovery") {
|
||||
await saveMarketFrontierDiscoveryFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as MarketFrontierDiscoveryContract,
|
||||
run.runId
|
||||
);
|
||||
return {
|
||||
nextAction: "Market frontier discovery обновлён из AI Workspace output; market-wide Wordstat должен читать новую память, fingerprint и probe plan.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "market_frontier_discovery",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.normalization") {
|
||||
const fallback = getNormalizationPromotionFallback(run);
|
||||
|
||||
await saveSeoNormalizationFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
||||
run.runId
|
||||
run.runId,
|
||||
fallback
|
||||
);
|
||||
return {
|
||||
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
||||
|
|
@ -538,6 +717,14 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
|||
return run;
|
||||
}
|
||||
|
||||
if (await hasNewerDoneSeoModelTaskRun(run)) {
|
||||
return updateSeoModelTaskRun(run.runId, {
|
||||
completed: true,
|
||||
errorMessage: "Cancelled stale workspace task: a newer successful run for the same task type already exists.",
|
||||
status: "cancelled"
|
||||
});
|
||||
}
|
||||
|
||||
const dispatch = getRunAiWorkspaceDispatch(run);
|
||||
|
||||
if (!dispatch) {
|
||||
|
|
@ -615,6 +802,30 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
|||
});
|
||||
}
|
||||
|
||||
async function hasNewerDoneSeoModelTaskRun(run: SeoModelTaskRun) {
|
||||
if (!run.task.taskType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await pool.query<{ exists: boolean }>(
|
||||
`
|
||||
select exists(
|
||||
select 1
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'seo_model_task'
|
||||
and status = 'done'
|
||||
and created_at > $2::timestamptz
|
||||
and output->'task'->>'taskType' = $3
|
||||
limit 1
|
||||
) as exists
|
||||
`,
|
||||
[run.projectId, run.createdAt, run.task.taskType]
|
||||
);
|
||||
|
||||
return result.rows[0]?.exists === true;
|
||||
}
|
||||
|
||||
async function refreshRunningSeoModelTaskRuns(projectId: string) {
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
|
|
@ -708,7 +919,8 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
|
|||
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
||||
input: {
|
||||
...baseInput,
|
||||
aiWorkspace: dispatch
|
||||
aiWorkspace: dispatch,
|
||||
task
|
||||
},
|
||||
output: dispatchedOutput,
|
||||
status: "running"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -253,20 +253,67 @@ const INTENT_SIGNAL_GROUP_META = {
|
|||
const ONTOLOGY_STOP_WORDS = new Set([
|
||||
"and",
|
||||
"app",
|
||||
"assets",
|
||||
"attr",
|
||||
"bodyhtml",
|
||||
"bottominfo",
|
||||
"card",
|
||||
"collection",
|
||||
"com",
|
||||
"content",
|
||||
"cta",
|
||||
"cviz",
|
||||
"form",
|
||||
"для",
|
||||
"enabled",
|
||||
"features",
|
||||
"footer",
|
||||
"heading",
|
||||
"html",
|
||||
"image",
|
||||
"images",
|
||||
"introhtml",
|
||||
"json",
|
||||
"или",
|
||||
"как",
|
||||
"компания",
|
||||
"main",
|
||||
"media",
|
||||
"muted",
|
||||
"на",
|
||||
"об",
|
||||
"от",
|
||||
"по",
|
||||
"под",
|
||||
"при",
|
||||
"scope",
|
||||
"src",
|
||||
"static",
|
||||
"staticelements",
|
||||
"target",
|
||||
"text",
|
||||
"txt",
|
||||
"txt-файл",
|
||||
"txt-файла",
|
||||
"txt-файлы",
|
||||
"сайт",
|
||||
"сервис",
|
||||
"страница",
|
||||
"webp",
|
||||
"аватар",
|
||||
"блок",
|
||||
"заголовок",
|
||||
"иконка",
|
||||
"информационный",
|
||||
"карточка",
|
||||
"основная",
|
||||
"приглушенная",
|
||||
"пункт",
|
||||
"пункты",
|
||||
"строка",
|
||||
"текст",
|
||||
"форма",
|
||||
"ячейка",
|
||||
"это"
|
||||
]);
|
||||
|
||||
|
|
@ -337,11 +384,12 @@ function getTextWords(value: string) {
|
|||
return normalizeOntologyText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? [];
|
||||
}
|
||||
|
||||
function getTopTerms(value: string, limit: number) {
|
||||
function getTopTerms(value: string, limit: number, extraStopTerms: string[] = []) {
|
||||
const counts = new Map<string, number>();
|
||||
const stopTerms = new Set([...extraStopTerms.map(normalizeOntologyText), ...ONTOLOGY_STOP_WORDS]);
|
||||
|
||||
for (const word of getTextWords(value)) {
|
||||
if (ONTOLOGY_STOP_WORDS.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
|
||||
if (stopTerms.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +574,7 @@ function buildGenericLandingBrief(brandName: string, documents: ProjectOntologyE
|
|||
|
||||
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
||||
const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5);
|
||||
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${term} ${brandName}`);
|
||||
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${brandName} ${term}`);
|
||||
const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null;
|
||||
|
||||
return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4);
|
||||
|
|
@ -534,7 +582,8 @@ function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
|||
|
||||
function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) {
|
||||
const brandKey = normalizeOntologyText(brandName);
|
||||
const terms = dedupeStrings([...matchedTerms, ...topTerms])
|
||||
const evidenceTerms = matchedTerms.length > 0 ? matchedTerms : topTerms;
|
||||
const terms = dedupeStrings(evidenceTerms)
|
||||
.filter((term) => {
|
||||
const normalizedTerm = normalizeOntologyText(term);
|
||||
|
||||
|
|
@ -557,7 +606,8 @@ function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], top
|
|||
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
|
||||
const documents = getCoverageDocuments(evidence);
|
||||
const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n"));
|
||||
const topTerms = getTopTerms(allText, 10);
|
||||
const brandTerms = buildBrandTerms(brandName, evidence);
|
||||
const topTerms = getTopTerms(allText, 10, brandTerms);
|
||||
const homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null;
|
||||
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
|
||||
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
|
||||
|
|
@ -598,7 +648,7 @@ function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: st
|
|||
priority,
|
||||
targetIntent: meta.targetIntent,
|
||||
intentGroups: [groupId],
|
||||
keywords: dedupeStrings([...matchedTerms, ...topTerms.slice(0, 4)]).slice(0, 12),
|
||||
keywords: dedupeStrings([...matchedTerms, ...(matchedTerms.length >= 4 ? [] : topTerms.slice(0, 4))]).slice(0, 12),
|
||||
queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms),
|
||||
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ import {
|
|||
saveSeoContextReviewManual,
|
||||
type SeoContextReviewOutput
|
||||
} from "../contextReview/seoContextReview.js";
|
||||
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js";
|
||||
import {
|
||||
getMarketEnrichmentContract,
|
||||
getWordstatProbePlanPreview,
|
||||
runMarketEnrichment
|
||||
} from "../market/marketEnrichment.js";
|
||||
import { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
|
||||
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
||||
import {
|
||||
|
|
@ -90,6 +94,17 @@ import {
|
|||
saveKeywordCleaningManual,
|
||||
type KeywordCleaningContract
|
||||
} from "../keywords/keywordCleaning.js";
|
||||
import {
|
||||
getLatestKeywordAnalysisWorkflow,
|
||||
startKeywordAnalysisWorkflow
|
||||
} from "../keywords/keywordAnalysisWorkflow.js";
|
||||
import {
|
||||
deleteKeywordContextSnapshot,
|
||||
listKeywordContextSnapshots,
|
||||
renameKeywordContextSnapshot,
|
||||
saveKeywordContextSnapshot,
|
||||
updateKeywordContextSnapshotCuration
|
||||
} from "../keywords/keywordContextSnapshots.js";
|
||||
import {
|
||||
addAnchorReviewManualAnchor,
|
||||
getAnchorReviewContract,
|
||||
|
|
@ -235,8 +250,43 @@ const keywordMapApproveSchema = z.object({
|
|||
role: z.enum(["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"])
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
suppressedItemIds: z.array(z.string().min(1)).max(300).optional()
|
||||
});
|
||||
const keywordContextSnapshotSchema = z.object({
|
||||
label: z.string().trim().max(180).optional(),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
source: z.enum(["manual_save", "expansion_checkpoint"]).optional(),
|
||||
curation: z
|
||||
.object({
|
||||
roleOverrides: z
|
||||
.array(
|
||||
z.object({
|
||||
itemId: z.string().min(1),
|
||||
role: z.string().min(1)
|
||||
})
|
||||
)
|
||||
.max(1000)
|
||||
.optional(),
|
||||
suppressedItemIds: z.array(z.string().min(1)).max(1000).optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
const keywordContextSnapshotRenameSchema = z.object({
|
||||
label: z.string().trim().min(1, "Нужно указать название snapshot-профиля.").max(180)
|
||||
});
|
||||
const keywordContextSnapshotCurationSchema = z.object({
|
||||
roleOverrides: z
|
||||
.array(
|
||||
z.object({
|
||||
itemId: z.string().min(1),
|
||||
role: z.string().min(1)
|
||||
})
|
||||
)
|
||||
.max(1000)
|
||||
.optional(),
|
||||
suppressedItemIds: z.array(z.string().min(1)).max(1000).optional()
|
||||
});
|
||||
const contextReviewManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
|
|
@ -251,10 +301,12 @@ const anchorReviewDecisionSchema = z.object({
|
|||
reason: z.string().trim().max(1000).optional(),
|
||||
sendToSerp: z.boolean().optional(),
|
||||
sendToWordstat: z.boolean().optional(),
|
||||
status: z.enum(["approved", "disabled", "pending"])
|
||||
status: z.enum(["approved", "deleted", "disabled", "pending"])
|
||||
});
|
||||
const demandCollectionProfileSchema = z.enum(["contextual", "market_wide"]);
|
||||
const anchorReviewDecisionsSchema = z.object({
|
||||
decisions: z.array(anchorReviewDecisionSchema).max(200).optional()
|
||||
decisions: z.array(anchorReviewDecisionSchema).max(200).optional(),
|
||||
demandCollectionProfile: demandCollectionProfileSchema.optional()
|
||||
});
|
||||
const anchorReviewManualAnchorSchema = z.object({
|
||||
phrase: z.string().trim().min(1).max(180),
|
||||
|
|
@ -768,6 +820,24 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/market-enrichment/probe-preview", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
probePreview: await getWordstatProbePlanPreview(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось построить preview Wordstat probe plan.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
@ -829,7 +899,7 @@ projectsRouter.post("/:projectId/anchor-review/decisions", async (request, respo
|
|||
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? [])
|
||||
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions, input.demandCollectionProfile)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
|
@ -879,6 +949,42 @@ projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, respon
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/keyword-analysis-workflow/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
workflow: await getLatestKeywordAnalysisWorkflow(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить workflow анализа ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/keyword-analysis-workflow", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.status(202).json({
|
||||
workflow: await startKeywordAnalysisWorkflow(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось запустить workflow анализа ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
@ -980,6 +1086,101 @@ projectsRouter.get("/:projectId/keyword-map/latest", async (request, response) =
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/keyword-context-snapshots", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
snapshots: await listKeywordContextSnapshots(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить snapshot-профили ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/keyword-context-snapshots", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = keywordContextSnapshotSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
snapshot: await saveKeywordContextSnapshot(projectId, input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить snapshot ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.patch("/:projectId/keyword-context-snapshots/:snapshotId", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
|
||||
const input = keywordContextSnapshotRenameSchema.parse(request.body ?? {});
|
||||
|
||||
response.json({
|
||||
snapshot: await renameKeywordContextSnapshot(projectId, snapshotId, input.label)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось переименовать snapshot ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.patch("/:projectId/keyword-context-snapshots/:snapshotId/curation", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
|
||||
const input = keywordContextSnapshotCurationSchema.parse(request.body ?? {});
|
||||
|
||||
response.json({
|
||||
snapshot: await updateKeywordContextSnapshotCuration(projectId, snapshotId, input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь layout snapshot payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить раскладку ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.delete("/:projectId/keyword-context-snapshots/:snapshotId", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
|
||||
|
||||
await deleteKeywordContextSnapshot(projectId, snapshotId);
|
||||
response.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id snapshot-профиля.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось удалить snapshot ключей.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/strategy/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,15 @@ function getNumericConstant(source: string, name: string) {
|
|||
|
||||
const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8");
|
||||
const marketEnrichmentSource = await readFile(new URL("../market/marketEnrichment.ts", import.meta.url), "utf8");
|
||||
const marketFrontierDiscoverySource = await readFile(new URL("../market/marketFrontierDiscovery.ts", import.meta.url), "utf8");
|
||||
const wordstatProviderSource = await readFile(new URL("../market/wordstatProvider.ts", import.meta.url), "utf8");
|
||||
const anchorReviewSource = await readFile(new URL("../keywords/anchorReview.ts", import.meta.url), "utf8");
|
||||
const keywordAnalysisWorkflowSource = await readFile(new URL("../keywords/keywordAnalysisWorkflow.ts", import.meta.url), "utf8");
|
||||
const keywordCleaningSource = await readFile(new URL("../keywords/keywordCleaning.ts", import.meta.url), "utf8");
|
||||
const keywordContextSnapshotsSource = await readFile(new URL("../keywords/keywordContextSnapshots.ts", import.meta.url), "utf8");
|
||||
const keywordMapSource = await readFile(new URL("../keywords/keywordMap.ts", import.meta.url), "utf8");
|
||||
const yandexEvidenceSource = await readFile(new URL("../evidence/yandexEvidence.ts", import.meta.url), "utf8");
|
||||
const modelProviderRegistrySource = await readFile(new URL("../modelProvider/modelProviderRegistry.ts", import.meta.url), "utf8");
|
||||
const projectRoutesSource = await readFile(new URL("../projects/routes.ts", import.meta.url), "utf8");
|
||||
const materializationSource = await readFile(new URL("../rewrite/materialization.ts", import.meta.url), "utf8");
|
||||
const rewritePlanSource = await readFile(new URL("../rewrite/rewritePlan.ts", import.meta.url), "utf8");
|
||||
|
|
@ -24,6 +31,108 @@ assert.ok(
|
|||
wordstatRepositorySource.includes("WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT"),
|
||||
"Wordstat top results must keep per-source balancing, not only global frequency sorting."
|
||||
);
|
||||
assert.ok(
|
||||
(getNumericConstant(wordstatRepositorySource, "WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION") ?? 99) <= 16,
|
||||
"Wordstat collection must keep a hard backend safety cap below the old expensive 40-query pass."
|
||||
);
|
||||
assert.ok(
|
||||
wordstatRepositorySource.includes("getRequestedWordstatPhraseSet") &&
|
||||
wordstatRepositorySource.includes("alreadyTriedSeedPhrases") &&
|
||||
wordstatRepositorySource.includes("requested_phrases"),
|
||||
"Wordstat collection must remember already requested phrases, including zero-result probes, before spending quota again."
|
||||
);
|
||||
assert.ok(
|
||||
(getNumericConstant(marketEnrichmentSource, "DEMAND_COLLECTION_CONTEXTUAL_PROBE_BUDGET") ?? 99) <= 8 &&
|
||||
(getNumericConstant(marketEnrichmentSource, "DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET") ?? 99) <= 12 &&
|
||||
marketEnrichmentSource.includes("selectMarketWideWordstatProbeSeeds") &&
|
||||
marketEnrichmentSource.includes("getReusableWordstatEvidencePhraseSet") &&
|
||||
marketEnrichmentSource.includes("getRequestedWordstatPhraseSet"),
|
||||
"Market enrichment must use small ranked Wordstat probe batches and reuse requested/collected evidence instead of fan-out seed generation."
|
||||
);
|
||||
assert.ok(
|
||||
wordstatProviderSource.includes("topRequests") &&
|
||||
wordstatProviderSource.includes("popularQueries") &&
|
||||
wordstatProviderSource.includes("isRealWordstatPhrase"),
|
||||
"Wordstat provider normalization must preserve top/related/popular rows and reject n/a-like garbage phrases."
|
||||
);
|
||||
assert.ok(
|
||||
marketFrontierDiscoverySource.includes('schemaVersion: "market-research-memory.v1"') &&
|
||||
marketFrontierDiscoverySource.includes('schemaVersion: "query-language-fingerprint.v1"') &&
|
||||
marketFrontierDiscoverySource.includes('taskType: "seo.market_frontier_discovery"') &&
|
||||
marketFrontierDiscoverySource.includes("getMarketFrontierMemoryHash") &&
|
||||
marketFrontierDiscoverySource.includes("requestedSeeds") &&
|
||||
marketFrontierDiscoverySource.includes("suppressedPhrases") &&
|
||||
marketFrontierDiscoverySource.includes("mapMarketFrontierProbeSeedsToWordstatCandidates"),
|
||||
"Market-wide demand collection must keep the frontier discovery layer: market memory, query-language fingerprint, model task, and Wordstat probe mapping."
|
||||
);
|
||||
assert.ok(
|
||||
marketEnrichmentSource.includes("getMarketFrontierDiscoveryContract") &&
|
||||
marketEnrichmentSource.includes('"market_frontier_discovery"') &&
|
||||
marketEnrichmentSource.includes("mapMarketFrontierProbeSeedsToWordstatCandidates"),
|
||||
"Market enrichment must route market frontier probe seeds into the ranked Wordstat seed queue."
|
||||
);
|
||||
assert.ok(
|
||||
marketEnrichmentSource.includes('schemaVersion: "wordstat-probe-plan-preview.v1"') &&
|
||||
marketEnrichmentSource.includes("getWordstatProbePlanPreview") &&
|
||||
marketEnrichmentSource.includes("preview_only") &&
|
||||
marketEnrichmentSource.includes("modelCanCallWordstat: false") &&
|
||||
marketEnrichmentSource.includes("buildWordstatProbePlanWarnings") &&
|
||||
marketEnrichmentSource.includes("getProbePatternFamily") &&
|
||||
marketEnrichmentSource.includes("weak_generic_probes") &&
|
||||
marketEnrichmentSource.includes("low_diversity"),
|
||||
"Market enrichment must expose a read-only Wordstat probe preview and critic before spending quota."
|
||||
);
|
||||
assert.ok(
|
||||
projectRoutesSource.includes("/market-enrichment/probe-preview") &&
|
||||
appApiSource.includes("fetchWordstatProbePlanPreview") &&
|
||||
appApiSource.includes("WordstatProbePlanWarning") &&
|
||||
!appSource.includes("wordstat-probe-preview"),
|
||||
"Backend/API must keep read-only Wordstat probe preview available, but Stage 3 UI must not render the debug preview panel."
|
||||
);
|
||||
assert.ok(
|
||||
keywordContextSnapshotsSource.includes("keywordPhrases") &&
|
||||
appApiSource.includes("keywordPhrases: string[]") &&
|
||||
appSource.includes("getKeywordCurationFreshness") &&
|
||||
appSource.includes("keyword-curation-freshness") &&
|
||||
appSource.includes("расширено:"),
|
||||
"Stage 3 must mark old/new keyword cards from expansion checkpoints and show total/updated/expanded counts in the header."
|
||||
);
|
||||
assert.ok(
|
||||
projectRoutesSource.includes("/keyword-context-snapshots/:snapshotId/curation") &&
|
||||
appApiSource.includes("updateKeywordContextSnapshotCuration") &&
|
||||
appSource.includes("saveKeywordCurationLayoutToActiveSnapshot") &&
|
||||
appSource.includes("savingKeywordCurationLayoutProjectId") &&
|
||||
appSource.includes("itemIds: changedItemIds") &&
|
||||
appSource.includes("Сохранить как новый профиль контекста ключей") &&
|
||||
appSource.includes("Сохранить текущую раскладку ключей в выбранный профиль"),
|
||||
"Keyword context Save and Save As must stay separate: topbar creates a snapshot profile, Stage 3 save persists active curation layout and keyword decisions."
|
||||
);
|
||||
assert.ok(
|
||||
keywordCleaningSource.includes("hasEducationIntentMismatch") &&
|
||||
keywordCleaningSource.includes("education_intent_not_in_product_offer") &&
|
||||
keywordMapSource.includes("hasEducationIntentMismatch") &&
|
||||
keywordMapSource.includes("isEditorialOrLocalTailKeywordCandidate"),
|
||||
"Keyword cleaning/map must keep education-intent mismatch out of product lanes and preserve real editorial/local long-tail roles."
|
||||
);
|
||||
assert.ok(
|
||||
keywordAnalysisWorkflowSource.includes("ensureMarketFrontierDiscoveryReady") &&
|
||||
keywordAnalysisWorkflowSource.includes('providerId: "codex_workspace"') &&
|
||||
keywordAnalysisWorkflowSource.includes('taskType: "seo.market_frontier_discovery"') &&
|
||||
keywordAnalysisWorkflowSource.includes('contract.activeStepId === "demand_collection"'),
|
||||
"Market-wide workflow must run/await frontier discovery before spending Wordstat requests."
|
||||
);
|
||||
assert.ok(
|
||||
(getNumericConstant(keywordCleaningSource, "KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT") ?? 999) <= 160 &&
|
||||
(getNumericConstant(keywordCleaningSource, "KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT") ?? 999) <= 140 &&
|
||||
keywordCleaningSource.includes("truncateTaskText") &&
|
||||
keywordCleaningSource.includes("getKeywordCleaningModelSeedScore") &&
|
||||
keywordCleaningSource.includes("getKeywordCleaningModelTopResultScore"),
|
||||
"Keyword cleaning model task must keep a slim ranked payload so AI Workspace does not fail with request entity too large."
|
||||
);
|
||||
assert.ok(
|
||||
modelProviderRegistrySource.includes('"seo.market_frontier_discovery"'),
|
||||
"Model provider registry must expose seo.market_frontier_discovery as a runnable task type."
|
||||
);
|
||||
assert.ok(
|
||||
(getNumericConstant(yandexEvidenceSource, "DEFAULT_PHRASE_LIMIT") ?? 0) >= 18,
|
||||
"Yandex Evidence default phrase limit must not fall back to toy 5-phrase coverage."
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
label: "Max seeds per run",
|
||||
type: "number",
|
||||
required: false,
|
||||
placeholder: "40",
|
||||
placeholder: "12",
|
||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
||||
},
|
||||
{
|
||||
|
|
@ -422,7 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
|||
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||
devices: "DEVICE_ALL",
|
||||
numPhrases: "50",
|
||||
maxSeedsPerRun: "40",
|
||||
maxSeedsPerRun: "12",
|
||||
regionIds: "225"
|
||||
};
|
||||
|
||||
|
|
@ -772,7 +772,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
mcpEndpoint: endpoint,
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -793,7 +793,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,46 @@ export type SeoContextReviewModelTaskContract = {
|
|||
wordCount: number;
|
||||
matchedClusterIds: string[];
|
||||
}>;
|
||||
siteTextEvidence: {
|
||||
schemaVersion: "seo-site-text-evidence.v1";
|
||||
extraction: {
|
||||
chunkCount: number;
|
||||
documentCount: number;
|
||||
mode: "clean_visible_text";
|
||||
sourceDocumentCount: number;
|
||||
totalChars: number;
|
||||
totalWords: number;
|
||||
};
|
||||
documents: Array<{
|
||||
chunkIds: string[];
|
||||
id: string;
|
||||
kind: "page" | "content_json";
|
||||
scope: string;
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
textCharCount: number;
|
||||
title: string;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
wordCount: number;
|
||||
}>;
|
||||
chunks: Array<{
|
||||
charEnd: number;
|
||||
charStart: number;
|
||||
chunkIndex: number;
|
||||
documentId: string;
|
||||
id: string;
|
||||
kind: "page" | "content_json";
|
||||
scope: string;
|
||||
selected: boolean;
|
||||
sourcePath: string;
|
||||
text: string;
|
||||
title: string;
|
||||
totalChunks: number;
|
||||
urlPath: string | null;
|
||||
usedForCoverage: boolean;
|
||||
}>;
|
||||
};
|
||||
semanticSignals: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -224,12 +264,13 @@ const SEO_SKILL_MANIFESTS: SeoSkillManifest[] = [
|
|||
summary:
|
||||
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
|
||||
owner: "seo_mode",
|
||||
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence"],
|
||||
requiredEvidence: ["semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
||||
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence", "site_text_evidence"],
|
||||
requiredEvidence: ["clean_visible_text_chunks", "semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
||||
deterministicChecks: [
|
||||
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
|
||||
{ id: "scope_ready", title: "Scope summary exists", source: "backend", required: true },
|
||||
{ id: "evidence_refs", title: "Evidence refs are attached to semantic claims", source: "backend", required: true }
|
||||
{ id: "evidence_refs", title: "Evidence refs are attached to semantic claims", source: "backend", required: true },
|
||||
{ id: "site_text", title: "Clean selected site text chunks are attached", source: "backend", required: true }
|
||||
],
|
||||
modelTasks: [
|
||||
{
|
||||
|
|
@ -548,10 +589,37 @@ function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTa
|
|||
.slice(0, 60);
|
||||
}
|
||||
|
||||
function getTextCorpusEvidence(analysis: SemanticAnalysisRun): SeoContextReviewModelTaskContract["input"]["siteTextEvidence"] {
|
||||
if (analysis.textCorpus) {
|
||||
return {
|
||||
schemaVersion: "seo-site-text-evidence.v1",
|
||||
extraction: analysis.textCorpus.extraction,
|
||||
documents: analysis.textCorpus.documents,
|
||||
chunks: analysis.textCorpus.chunks
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-site-text-evidence.v1",
|
||||
extraction: {
|
||||
chunkCount: 0,
|
||||
documentCount: 0,
|
||||
mode: "clean_visible_text",
|
||||
sourceDocumentCount: 0,
|
||||
totalChars: 0,
|
||||
totalWords: 0
|
||||
},
|
||||
documents: [],
|
||||
chunks: []
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoContextReviewTask(
|
||||
projectId: string,
|
||||
analysis: SemanticAnalysisRun
|
||||
): SeoContextReviewModelTaskContract {
|
||||
const siteTextEvidence = getTextCorpusEvidence(analysis);
|
||||
|
||||
return {
|
||||
taskType: "seo.context_review",
|
||||
schemaVersion: "seo-context-review-task.v1",
|
||||
|
|
@ -597,6 +665,7 @@ export function buildSeoContextReviewTask(
|
|||
wordCount: source.wordCount,
|
||||
matchedClusterIds: source.matchedClusters
|
||||
})),
|
||||
siteTextEvidence,
|
||||
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
|
||||
evidenceLevel: cluster.evidence.level,
|
||||
id: cluster.id,
|
||||
|
|
@ -657,7 +726,7 @@ export function buildSeoContextReviewTask(
|
|||
requireHumanReviewWhenAmbiguous: true
|
||||
},
|
||||
stopConditions: [
|
||||
"Недостаточно evidence refs для определения реального оффера сайта.",
|
||||
"Недостаточно clean text evidence или evidence refs для определения реального оффера сайта.",
|
||||
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
|
||||
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue