Compare commits
No commits in common. "c3a946879b0267c7a6c420b8ea10520ce6e23515" and "284c72d75c661d0b938c14a81a1e33455332a458" have entirely different histories.
c3a946879b
...
284c72d75c
|
|
@ -19,7 +19,7 @@ WORDSTAT_PROVIDER=disabled
|
||||||
# WORDSTAT_REGION_IDS=225
|
# WORDSTAT_REGION_IDS=225
|
||||||
# WORDSTAT_DEVICES=DEVICE_ALL
|
# WORDSTAT_DEVICES=DEVICE_ALL
|
||||||
# WORDSTAT_NUM_PHRASES=50
|
# WORDSTAT_NUM_PHRASES=50
|
||||||
# WORDSTAT_MAX_SEEDS_PER_RUN=12
|
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
||||||
|
|
||||||
SERP_PROVIDER=disabled
|
SERP_PROVIDER=disabled
|
||||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,246 +0,0 @@
|
||||||
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,46 +336,6 @@ export type SemanticAnalysisRun = {
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
matchedClusters: string[];
|
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<{
|
legacyFindings: Array<{
|
||||||
markerId: string;
|
markerId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -596,7 +556,7 @@ export type SeoContextReviewRun = {
|
||||||
runId: string;
|
runId: string;
|
||||||
status: string;
|
status: string;
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: true;
|
modelRequired: true;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -656,7 +616,7 @@ export type SeoNormalizationContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "not_ready" | "ready";
|
state: "not_ready" | "ready";
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: boolean;
|
modelRequired: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -777,11 +737,9 @@ export type AnchorReviewDecision = {
|
||||||
sendToSerp: boolean;
|
sendToSerp: boolean;
|
||||||
sendToWordstat: boolean;
|
sendToWordstat: boolean;
|
||||||
source: "human" | "model" | "system";
|
source: "human" | "model" | "system";
|
||||||
status: "approved" | "deleted" | "disabled" | "pending";
|
status: "approved" | "disabled" | "pending";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DemandCollectionProfile = "contextual" | "market_wide";
|
|
||||||
|
|
||||||
export type AnchorReviewContract = {
|
export type AnchorReviewContract = {
|
||||||
schemaVersion: "anchor-review.v1";
|
schemaVersion: "anchor-review.v1";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -789,7 +747,6 @@ export type AnchorReviewContract = {
|
||||||
projectOntologyVersionId: string | null;
|
projectOntologyVersionId: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "approved" | "empty" | "needs_review" | "not_ready";
|
state: "approved" | "empty" | "needs_review" | "not_ready";
|
||||||
demandCollectionProfile: DemandCollectionProfile;
|
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: string | null;
|
normalizationGeneratedAt: string | null;
|
||||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||||
|
|
@ -837,13 +794,6 @@ export type AnchorReviewContract = {
|
||||||
};
|
};
|
||||||
decision: AnchorReviewDecision;
|
decision: AnchorReviewDecision;
|
||||||
}>;
|
}>;
|
||||||
deletedAnchors: Array<{
|
|
||||||
deletedAt: string | null;
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
phrase: string;
|
|
||||||
reason: string;
|
|
||||||
}>;
|
|
||||||
wordstatQueue: Array<{
|
wordstatQueue: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
anchorId: string;
|
anchorId: string;
|
||||||
|
|
@ -895,7 +845,6 @@ export type MarketEnrichmentContract = {
|
||||||
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
||||||
normalization: SeoNormalizationContract;
|
normalization: SeoNormalizationContract;
|
||||||
anchorReview: AnchorReviewContract;
|
anchorReview: AnchorReviewContract;
|
||||||
demandCollectionProfile: DemandCollectionProfile;
|
|
||||||
readiness: {
|
readiness: {
|
||||||
anchorApprovedCount: number;
|
anchorApprovedCount: number;
|
||||||
anchorPendingCount: number;
|
anchorPendingCount: number;
|
||||||
|
|
@ -1046,115 +995,6 @@ export type MarketEnrichmentContract = {
|
||||||
nextActions: string[];
|
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 = {
|
export type KeywordCleaningItem = {
|
||||||
id: string;
|
id: string;
|
||||||
phrase: string;
|
phrase: string;
|
||||||
|
|
@ -1189,7 +1029,7 @@ export type KeywordCleaningContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "not_ready" | "ready";
|
state: "not_ready" | "ready";
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: boolean;
|
modelRequired: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -1253,11 +1093,8 @@ export type KeywordCleaningContract = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeoModelTaskType =
|
export type SeoModelTaskType =
|
||||||
| "seo.business_synthesis"
|
|
||||||
| "seo.commercial_demand"
|
|
||||||
| "seo.context_review"
|
| "seo.context_review"
|
||||||
| "seo.keyword_cleaning"
|
| "seo.keyword_cleaning"
|
||||||
| "seo.market_frontier_discovery"
|
|
||||||
| "seo.normalization"
|
| "seo.normalization"
|
||||||
| "seo.serp_interpretation"
|
| "seo.serp_interpretation"
|
||||||
| "seo.strategy_quality_review"
|
| "seo.strategy_quality_review"
|
||||||
|
|
@ -1330,10 +1167,7 @@ export type SeoModelTaskPersistedResult = {
|
||||||
schemaVersion: "seo-model-persisted-result.v1";
|
schemaVersion: "seo-model-persisted-result.v1";
|
||||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||||
runType:
|
runType:
|
||||||
| "seo_business_synthesis"
|
|
||||||
| "seo_commercial_demand"
|
|
||||||
| "keyword_cleaning"
|
| "keyword_cleaning"
|
||||||
| "seo_context_review"
|
|
||||||
| "seo_model_task"
|
| "seo_model_task"
|
||||||
| "seo_normalization"
|
| "seo_normalization"
|
||||||
| "seo_strategy_quality_review"
|
| "seo_strategy_quality_review"
|
||||||
|
|
@ -1488,7 +1322,7 @@ export type SerpInterpretationContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "not_ready" | "ready";
|
state: "not_ready" | "ready";
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: true;
|
modelRequired: true;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -1632,31 +1466,6 @@ export type KeywordMapRoleOverrideInput = {
|
||||||
role: KeywordMapRole;
|
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 = {
|
export type SeoStrategyContract = {
|
||||||
schemaVersion: "seo-strategy.v1";
|
schemaVersion: "seo-strategy.v1";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -1841,16 +1650,6 @@ export type SeoStrategyContract = {
|
||||||
nextActions: string[];
|
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 = {
|
export type StrategySynthesisContract = {
|
||||||
schemaVersion: "seo-strategy-synthesis.v1";
|
schemaVersion: "seo-strategy-synthesis.v1";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -1859,7 +1658,7 @@ export type StrategySynthesisContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "not_ready" | "ready";
|
state: "not_ready" | "ready";
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: true;
|
modelRequired: true;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -1898,13 +1697,7 @@ export type StrategySynthesisContract = {
|
||||||
promotionSignals: string[];
|
promotionSignals: string[];
|
||||||
phaseRoadmap: SeoStrategyContract["phasePlan"];
|
phaseRoadmap: SeoStrategyContract["phasePlan"];
|
||||||
};
|
};
|
||||||
phaseStrategies: Array<
|
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||||
Omit<SeoStrategyContract["phaseStrategies"][number], "doNow" | "hold" | "prepareNext"> & {
|
|
||||||
doNow: Array<string | StrategyPhaseAction>;
|
|
||||||
hold: Array<string | StrategyPhaseAction>;
|
|
||||||
prepareNext: Array<string | StrategyPhaseAction>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
recommendedPath: {
|
recommendedPath: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -1966,7 +1759,7 @@ export type StrategyQualityReviewContract = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: "not_ready" | "ready";
|
state: "not_ready" | "ready";
|
||||||
provider: {
|
provider: {
|
||||||
mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
mode: "codex_manual" | "deterministic_fallback";
|
||||||
modelRequired: true;
|
modelRequired: true;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
@ -3081,16 +2874,6 @@ export async function fetchLatestMarketEnrichment(projectId: string) {
|
||||||
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
|
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) {
|
export async function fetchLatestSeoContextReview(projectId: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`);
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`);
|
||||||
|
|
||||||
|
|
@ -3198,26 +2981,9 @@ export async function fetchLatestAnchorReview(projectId: string) {
|
||||||
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveAnchorReviewDecisions(
|
export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) {
|
||||||
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`, {
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, {
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ decisions }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
|
|
@ -3285,28 +3051,6 @@ export async function runMarketEnrichment(projectId: string) {
|
||||||
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
|
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) {
|
export async function fetchLatestYandexEvidence(projectId: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`);
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`);
|
||||||
|
|
||||||
|
|
@ -3424,7 +3168,6 @@ export async function approveKeywordMapDecisions(
|
||||||
input?: {
|
input?: {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
roleOverrides?: KeywordMapRoleOverrideInput[];
|
roleOverrides?: KeywordMapRoleOverrideInput[];
|
||||||
suppressedItemIds?: string[];
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
|
||||||
|
|
@ -3447,78 +3190,6 @@ 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) {
|
export async function fetchLatestRewritePlan(projectId: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`);
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,9 @@
|
||||||
import { Component, StrictMode, type ErrorInfo, type ReactNode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppErrorBoundary>
|
<App />
|
||||||
<App />
|
|
||||||
</AppErrorBoundary>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2748,11 +2748,6 @@ input {
|
||||||
line-height: 1.38;
|
line-height: 1.38;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-6 .market-heading .strategy-stage-run-date {
|
|
||||||
color: var(--ink);
|
|
||||||
font-weight: 850;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-6 .market-heading > svg {
|
.stage-6 .market-heading > svg {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
|
@ -2765,51 +2760,6 @@ input {
|
||||||
box-sizing: content-box;
|
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 {
|
.market-state-card {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
@ -2932,18 +2882,6 @@ input {
|
||||||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
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 {
|
.keyword-stage-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -3011,20 +2949,6 @@ input {
|
||||||
color: #fff;
|
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 {
|
.keyword-stage-icon-action:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.52;
|
opacity: 0.52;
|
||||||
|
|
@ -3046,145 +2970,6 @@ input {
|
||||||
flex: 1 1 auto;
|
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 {
|
.anchor-review-row > div:first-child span {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
|
|
@ -3244,11 +3029,11 @@ input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-card-meta > span:last-child {
|
.keyword-curation-card > span {
|
||||||
|
justify-self: start;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
color: rgba(8, 8, 10, 0.78);
|
color: rgba(8, 8, 10, 0.78);
|
||||||
background: rgba(8, 8, 10, 0.07);
|
background: rgba(8, 8, 10, 0.07);
|
||||||
|
|
@ -3256,7 +3041,6 @@ input {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-column-head strong,
|
.keyword-curation-column-head strong,
|
||||||
|
|
@ -3362,11 +3146,10 @@ input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-card {
|
.keyword-curation-card {
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px 42px 10px 10px;
|
padding: 10px;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||||
border-radius: var(--keyword-curation-radius);
|
border-radius: var(--keyword-curation-radius);
|
||||||
|
|
@ -3374,40 +3157,6 @@ input {
|
||||||
user-select: none;
|
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 {
|
.keyword-curation-drag-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: var(--keyword-drag-x);
|
left: var(--keyword-drag-x);
|
||||||
|
|
@ -3424,29 +3173,6 @@ input {
|
||||||
box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22);
|
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 {
|
.keyword-curation-empty {
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -3461,115 +3187,6 @@ input {
|
||||||
text-align: center;
|
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 {
|
.keyword-curation-footer {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
|
|
@ -3619,228 +3236,6 @@ input {
|
||||||
font-weight: 900;
|
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 {
|
.anchor-review-workspace-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -12863,8 +12258,6 @@ textarea:focus {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-stage-head,
|
|
||||||
.keyword-anchor-stage-head,
|
|
||||||
.keyword-curation-board-head,
|
.keyword-curation-board-head,
|
||||||
.keyword-curation-footer {
|
.keyword-curation-footer {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -12878,10 +12271,6 @@ textarea:focus {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.anchor-demand-profile-card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anchor-review-row {
|
.anchor-review-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|
@ -13686,9 +13075,9 @@ textarea:focus {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(10, 12, 14, 0.14);
|
background: rgba(10, 12, 14, 0.28);
|
||||||
backdrop-filter: blur(18px) saturate(1.04);
|
backdrop-filter: var(--nodedc-glass-panel-blur);
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(1.04);
|
-webkit-backdrop-filter: var(--nodedc-glass-panel-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seo-project-name-modal {
|
.seo-project-name-modal {
|
||||||
|
|
@ -13759,62 +13148,6 @@ textarea:focus {
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.seo-project-name-modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
@ -14964,7 +14297,7 @@ body:has(.seo-launcher-shell) {
|
||||||
color: rgba(8, 8, 10, 0.72);
|
color: rgba(8, 8, 10, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seo-stage-5 .seo-work-panel-toolbar > .wordstat-quota-strip {
|
.seo-stage-5 .wordstat-quota-strip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
@ -14972,76 +14305,6 @@ body:has(.seo-launcher-shell) {
|
||||||
transform: translate(-50%, -50%);
|
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-head,
|
||||||
.wordstat-quota-meta {
|
.wordstat-quota-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -15156,30 +14419,8 @@ body:has(.seo-launcher-shell) {
|
||||||
color: #fff;
|
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) {
|
@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;
|
width: 3rem;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -15187,8 +14428,7 @@ body:has(.seo-launcher-shell) {
|
||||||
font-size: 0;
|
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;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -16241,7 +15481,6 @@ body:has(.seo-launcher-shell) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.seo-launcher-shell.project-open .seo-stage-rail {
|
.seo-launcher-shell.project-open .seo-stage-rail {
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
border: 1px solid var(--nodedc-glass-outline) !important;
|
border: 1px solid var(--nodedc-glass-outline) !important;
|
||||||
background: var(--nodedc-glass-panel-surface-strong) !important;
|
background: var(--nodedc-glass-panel-surface-strong) !important;
|
||||||
|
|
@ -16272,43 +15511,6 @@ body:has(.seo-launcher-shell) {
|
||||||
z-index: 3;
|
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 {
|
.seo-stage-rail-head {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1fr) 2.38rem;
|
grid-template-columns: minmax(0, 1fr) 2.38rem;
|
||||||
|
|
|
||||||
|
|
@ -68,37 +68,6 @@ type ContentDocument = {
|
||||||
|
|
||||||
type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
|
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 = {
|
type AnalysisSignal = {
|
||||||
id: string;
|
id: string;
|
||||||
level: "ok" | "warning" | "problem";
|
level: "ok" | "warning" | "problem";
|
||||||
|
|
@ -303,19 +272,6 @@ export type SemanticAnalysisOutput = {
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
matchedClusters: string[];
|
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<{
|
legacyFindings: Array<{
|
||||||
markerId: string;
|
markerId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -528,11 +484,6 @@ const RUSSIAN_STOP_WORDS = new Set([
|
||||||
"assets",
|
"assets",
|
||||||
"attr",
|
"attr",
|
||||||
"avif",
|
"avif",
|
||||||
"accesscard",
|
|
||||||
"bodyhtml",
|
|
||||||
"bottominfo",
|
|
||||||
"card",
|
|
||||||
"cards",
|
|
||||||
"class",
|
"class",
|
||||||
"collection",
|
"collection",
|
||||||
"content",
|
"content",
|
||||||
|
|
@ -541,22 +492,16 @@ const RUSSIAN_STOP_WORDS = new Set([
|
||||||
"data",
|
"data",
|
||||||
"features",
|
"features",
|
||||||
"footer",
|
"footer",
|
||||||
"form",
|
|
||||||
"html",
|
"html",
|
||||||
"image",
|
"image",
|
||||||
"images",
|
"images",
|
||||||
"introhtml",
|
|
||||||
"json",
|
"json",
|
||||||
"heading",
|
"heading",
|
||||||
"main",
|
"main",
|
||||||
"media",
|
"media",
|
||||||
"muted",
|
"muted",
|
||||||
"node",
|
|
||||||
"site",
|
"site",
|
||||||
"src",
|
"src",
|
||||||
"static",
|
|
||||||
"staticelements",
|
|
||||||
"target",
|
|
||||||
"text",
|
"text",
|
||||||
"txt",
|
"txt",
|
||||||
"webp",
|
"webp",
|
||||||
|
|
@ -592,8 +537,6 @@ const CTA_PATTERNS = [
|
||||||
"заказать"
|
"заказать"
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_CORPUS_CHUNK_CHARS = 6000;
|
|
||||||
|
|
||||||
function toIsoDate(value: Date | null) {
|
function toIsoDate(value: Date | null) {
|
||||||
return value ? value.toISOString() : null;
|
return value ? value.toISOString() : null;
|
||||||
}
|
}
|
||||||
|
|
@ -683,113 +626,12 @@ function visibleTextFromHtml(html: string) {
|
||||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||||
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
||||||
.replace(/<svg\b[\s\S]*?<\/svg>/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(/<[^>]+>/g, " ")
|
||||||
)
|
)
|
||||||
.split(/\n+/)
|
.replace(/\s+/g, " ")
|
||||||
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
.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) {
|
function normalizeSearchText(value: string) {
|
||||||
return value
|
return value
|
||||||
.toLocaleLowerCase("ru-RU")
|
.toLocaleLowerCase("ru-RU")
|
||||||
|
|
@ -1474,7 +1316,7 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
|
||||||
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
|
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
|
||||||
: "Кластер уже найден в контенте и подходит для проверки частотности.";
|
: "Кластер уже найден в контенте и подходит для проверки частотности.";
|
||||||
|
|
||||||
return buildClusterSeedPhrases(cluster, ontology.brandName).slice(0, cluster.priority === "high" ? 4 : 2).map((phrase) => ({
|
return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({
|
||||||
phrase,
|
phrase,
|
||||||
clusterId: cluster.id,
|
clusterId: cluster.id,
|
||||||
clusterTitle: cluster.title,
|
clusterTitle: cluster.title,
|
||||||
|
|
@ -1485,122 +1327,6 @@ 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(
|
function getLandingOpportunityPageType(
|
||||||
pagePath: string
|
pagePath: string
|
||||||
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
|
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
|
||||||
|
|
@ -2797,7 +2523,6 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
||||||
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
||||||
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
||||||
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
||||||
const textCorpus = buildTextCorpus(coverageDocuments.length > 0 ? coverageDocuments : documents);
|
|
||||||
const clusters = analyzeClusters(coverageDocuments, ontology);
|
const clusters = analyzeClusters(coverageDocuments, ontology);
|
||||||
const coverageScore = getCoverageScore(clusters);
|
const coverageScore = getCoverageScore(clusters);
|
||||||
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
||||||
|
|
@ -2915,7 +2640,6 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
||||||
wordCount: document.wordCount,
|
wordCount: document.wordCount,
|
||||||
matchedClusters: contentClusterMap.get(document.id) ?? []
|
matchedClusters: contentClusterMap.get(document.id) ?? []
|
||||||
})),
|
})),
|
||||||
textCorpus,
|
|
||||||
legacyFindings,
|
legacyFindings,
|
||||||
gaps,
|
gaps,
|
||||||
landingOpportunities,
|
landingOpportunities,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,732 +0,0 @@
|
||||||
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_REGION_IDS: z.string().default("225"),
|
||||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(12),
|
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
||||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||||
SERP_API_BASE_URL: z.string().url().optional(),
|
SERP_API_BASE_URL: z.string().url().optional(),
|
||||||
SERP_API_TOKEN: z.string().optional(),
|
SERP_API_TOKEN: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,7 @@ type SeoContextReviewRunRow = {
|
||||||
|
|
||||||
type ReviewLevel = "ok" | "warning" | "problem";
|
type ReviewLevel = "ok" | "warning" | "problem";
|
||||||
type ContextReviewConfidence = "low" | "medium" | "high";
|
type ContextReviewConfidence = "low" | "medium" | "high";
|
||||||
type SeoContextReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type SeoContextReviewProviderMode = "codex_manual" | "deterministic_fallback";
|
||||||
|
|
||||||
type SeoContextReviewModelProviderFallback = {
|
|
||||||
projectOntologyVersionId: string | null;
|
|
||||||
scanVersionId: string;
|
|
||||||
semanticRunId: string;
|
|
||||||
sourceTaskId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoContextReviewOutput = {
|
export type SeoContextReviewOutput = {
|
||||||
schemaVersion: "seo-context-review.v1";
|
schemaVersion: "seo-context-review.v1";
|
||||||
|
|
@ -173,316 +166,52 @@ 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(
|
function normalizeContextReviewOutput(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
output: SeoContextReviewOutput,
|
output: SeoContextReviewOutput,
|
||||||
providerMode: SeoContextReviewProviderMode,
|
providerMode: SeoContextReviewProviderMode,
|
||||||
providerMessage: string,
|
providerMessage: string
|
||||||
fallback?: SeoContextReviewModelProviderFallback
|
|
||||||
): SeoContextReviewOutput {
|
): SeoContextReviewOutput {
|
||||||
const normalizedShape = normalizeContextReviewShape(projectId, output, fallback);
|
if (output.schemaVersion !== "seo-context-review.v1") {
|
||||||
|
|
||||||
if (normalizedShape.schemaVersion !== "seo-context-review.v1") {
|
|
||||||
throw new Error("Context Review output schemaVersion должен быть seo-context-review.v1.");
|
throw new Error("Context Review output schemaVersion должен быть seo-context-review.v1.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedShape.projectId !== projectId) {
|
if (output.projectId !== projectId) {
|
||||||
throw new Error("Context Review output projectId не совпадает с проектом.");
|
throw new Error("Context Review output projectId не совпадает с проектом.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedShape.semanticRunId || !normalizedShape.scanVersionId || !normalizedShape.sourceTaskId) {
|
if (!output.semanticRunId || !output.scanVersionId || !output.sourceTaskId) {
|
||||||
throw new Error("Context Review output должен иметь semanticRunId, scanVersionId и sourceTaskId.");
|
throw new Error("Context Review output должен иметь semanticRunId, scanVersionId и sourceTaskId.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedShape.siteIdentity?.description || !normalizedShape.offer?.summary || !normalizedShape.summary) {
|
if (!output.siteIdentity?.description || !output.offer?.summary || !output.summary) {
|
||||||
throw new Error("Context Review output должен иметь siteIdentity.description, offer.summary и summary.");
|
throw new Error("Context Review output должен иметь siteIdentity.description, offer.summary и summary.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(normalizedShape.pageRoles) || !Array.isArray(normalizedShape.sectionFindings)) {
|
if (!Array.isArray(output.pageRoles) || !Array.isArray(output.sectionFindings)) {
|
||||||
throw new Error("Context Review output должен иметь pageRoles и sectionFindings.");
|
throw new Error("Context Review output должен иметь pageRoles и sectionFindings.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(normalizedShape.semanticGaps) || !Array.isArray(normalizedShape.forbiddenClaims) || !Array.isArray(normalizedShape.normalizationHints)) {
|
if (!Array.isArray(output.semanticGaps) || !Array.isArray(output.forbiddenClaims) || !Array.isArray(output.normalizationHints)) {
|
||||||
throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints.");
|
throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalizedShape,
|
...output,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
provider: {
|
provider: {
|
||||||
message: providerMessage,
|
message: providerMessage,
|
||||||
mode: providerMode,
|
mode: providerMode,
|
||||||
modelRequired: true
|
modelRequired: true
|
||||||
},
|
},
|
||||||
analystReview: normalizedShape.analystReview ?? getDefaultAnalystReview(),
|
analystReview: output.analystReview ?? getDefaultAnalystReview(),
|
||||||
readiness: {
|
readiness: {
|
||||||
...normalizedShape.readiness,
|
...output.readiness,
|
||||||
forbiddenClaimCount: normalizedShape.forbiddenClaims.length,
|
forbiddenClaimCount: output.forbiddenClaims.length,
|
||||||
normalizationHintCount: normalizedShape.normalizationHints.length,
|
normalizationHintCount: output.normalizationHints.length,
|
||||||
pageRoleCount: normalizedShape.pageRoles.length,
|
pageRoleCount: output.pageRoles.length,
|
||||||
semanticGapCount: normalizedShape.semanticGaps.length,
|
semanticGapCount: output.semanticGaps.length,
|
||||||
needsHumanReview: normalizedShape.needsHumanReview
|
needsHumanReview: output.needsHumanReview
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -812,45 +541,3 @@ export async function saveSeoContextReviewManual(
|
||||||
|
|
||||||
return run;
|
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,12 +1,9 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||||
|
|
||||||
type AnchorDecisionStatus = "approved" | "deleted" | "disabled" | "pending";
|
type AnchorDecisionStatus = "approved" | "disabled" | "pending";
|
||||||
type AnchorDecisionSource = "human" | "model" | "system";
|
type AnchorDecisionSource = "human" | "model" | "system";
|
||||||
type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready";
|
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];
|
type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number];
|
||||||
|
|
||||||
export type AnchorReviewDecisionInput = {
|
export type AnchorReviewDecisionInput = {
|
||||||
|
|
@ -83,7 +80,6 @@ export type AnchorReviewContract = {
|
||||||
projectOntologyVersionId: string | null;
|
projectOntologyVersionId: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: AnchorReviewState;
|
state: AnchorReviewState;
|
||||||
demandCollectionProfile: DemandCollectionProfile;
|
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: string | null;
|
normalizationGeneratedAt: string | null;
|
||||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||||
|
|
@ -108,13 +104,6 @@ export type AnchorReviewContract = {
|
||||||
manualDecisionCount: number;
|
manualDecisionCount: number;
|
||||||
};
|
};
|
||||||
anchors: AnchorReviewAnchor[];
|
anchors: AnchorReviewAnchor[];
|
||||||
deletedAnchors: Array<{
|
|
||||||
deletedAt: string | null;
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
phrase: string;
|
|
||||||
reason: string;
|
|
||||||
}>;
|
|
||||||
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
||||||
nextActions: string[];
|
nextActions: string[];
|
||||||
};
|
};
|
||||||
|
|
@ -135,10 +124,6 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
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">) {
|
function getAnchorKey(seed: Pick<SeoNormalizationSeed, "clusterId" | "phrase">) {
|
||||||
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -168,18 +153,6 @@ function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): 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") {
|
if (decision.status === "approved") {
|
||||||
return {
|
return {
|
||||||
decidedAt: decision.decidedAt,
|
decidedAt: decision.decidedAt,
|
||||||
|
|
@ -216,47 +189,15 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
||||||
const anchorSnapshots = (savedOutput?.anchors ?? []).map((anchor) => ({
|
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||||
anchor,
|
anchor,
|
||||||
decision: anchor.decision,
|
decision: anchor.decision,
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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 {
|
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") {
|
if (decision.status === "approved") {
|
||||||
return {
|
return {
|
||||||
decidedAt: decision.decidedAt,
|
decidedAt: decision.decidedAt,
|
||||||
|
|
@ -371,16 +312,6 @@ 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 {
|
function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
|
||||||
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
||||||
return "not_ready";
|
return "not_ready";
|
||||||
|
|
@ -393,18 +324,6 @@ function getState(normalization: SeoNormalizationContract, anchors: AnchorReview
|
||||||
return wordstatQueueCount > 0 ? "approved" : "needs_review";
|
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">) {
|
function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
|
|
||||||
|
|
@ -441,8 +360,7 @@ function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||||
function buildContract(
|
function buildContract(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
normalization: SeoNormalizationContract,
|
normalization: SeoNormalizationContract,
|
||||||
decisionSnapshots: DecisionSnapshot[] = [],
|
decisionSnapshots: DecisionSnapshot[] = []
|
||||||
demandCollectionProfile: DemandCollectionProfile = DEFAULT_DEMAND_COLLECTION_PROFILE
|
|
||||||
): AnchorReviewContract {
|
): AnchorReviewContract {
|
||||||
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
||||||
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot]));
|
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot]));
|
||||||
|
|
@ -451,11 +369,7 @@ function buildContract(
|
||||||
);
|
);
|
||||||
const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
|
const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||||
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
|
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
|
||||||
const allAnchorCandidates = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
|
const allAnchors = [...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 wordstatQueue = buildWordstatQueue(allAnchors);
|
||||||
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
||||||
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
||||||
|
|
@ -468,7 +382,6 @@ function buildContract(
|
||||||
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
state: getState(normalization, allAnchors, wordstatQueue.length),
|
state: getState(normalization, allAnchors, wordstatQueue.length),
|
||||||
demandCollectionProfile,
|
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: normalization.generatedAt,
|
normalizationGeneratedAt: normalization.generatedAt,
|
||||||
normalizationProviderMode: normalization.provider.mode,
|
normalizationProviderMode: normalization.provider.mode,
|
||||||
|
|
@ -496,55 +409,6 @@ function buildContract(
|
||||||
wordstatQueueCount: wordstatQueue.length
|
wordstatQueueCount: wordstatQueue.length
|
||||||
},
|
},
|
||||||
anchors: allAnchors,
|
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
|
wordstatQueue
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -582,12 +446,7 @@ export async function getAnchorReviewContract(projectId: string): Promise<Anchor
|
||||||
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return buildContract(
|
return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput));
|
||||||
projectId,
|
|
||||||
normalization,
|
|
||||||
getSavedDecisionSnapshots(savedOutput),
|
|
||||||
getSavedDemandCollectionProfile(savedOutput)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
|
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
|
||||||
|
|
@ -625,18 +484,6 @@ 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 {
|
return {
|
||||||
decidedAt,
|
decidedAt,
|
||||||
decidedBy: "dev-mode",
|
decidedBy: "dev-mode",
|
||||||
|
|
@ -648,36 +495,9 @@ 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(
|
export async function saveAnchorReviewDecisions(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
decisions?: AnchorReviewDecisionInput[],
|
decisions: AnchorReviewDecisionInput[] = []
|
||||||
demandCollectionProfile?: DemandCollectionProfile
|
|
||||||
): Promise<AnchorReviewContract> {
|
): Promise<AnchorReviewContract> {
|
||||||
const current = await getAnchorReviewContract(projectId);
|
const current = await getAnchorReviewContract(projectId);
|
||||||
|
|
||||||
|
|
@ -688,25 +508,18 @@ export async function saveAnchorReviewDecisions(
|
||||||
const decidedAt = new Date().toISOString();
|
const decidedAt = new Date().toISOString();
|
||||||
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
||||||
const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision]));
|
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 =
|
const inputs =
|
||||||
explicitDecisions.length > 0
|
decisions.length > 0
|
||||||
? explicitDecisions
|
? decisions
|
||||||
: !hasExplicitDecisions && demandCollectionProfile === undefined
|
: current.anchors
|
||||||
? current.anchors
|
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
||||||
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
.map((anchor) => ({
|
||||||
.map((anchor) => ({
|
anchorId: anchor.id,
|
||||||
anchorId: anchor.id,
|
reason: "Human approved proposed Wordstat anchor queue.",
|
||||||
reason: "Human approved proposed Wordstat anchor queue.",
|
sendToSerp: anchor.proposal.sendToSerp,
|
||||||
sendToSerp: anchor.proposal.sendToSerp,
|
sendToWordstat: true,
|
||||||
sendToWordstat: true,
|
status: "approved" as const
|
||||||
status: "approved" as const
|
}));
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const anchor = anchorsById.get(input.anchorId);
|
const anchor = anchorsById.get(input.anchorId);
|
||||||
|
|
@ -718,33 +531,14 @@ export async function saveAnchorReviewDecisions(
|
||||||
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
const decisionSnapshots: DecisionSnapshot[] = [
|
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
||||||
...current.anchors.map((anchor) => ({
|
anchor,
|
||||||
anchor,
|
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
id: anchor.id,
|
||||||
id: anchor.id,
|
key: anchor.key
|
||||||
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 normalization = await getSeoNormalizationContract(projectId);
|
||||||
const output = applyDecisionInputsToContract(
|
const output = buildContract(projectId, normalization, decisionSnapshots);
|
||||||
buildContract(projectId, normalization, decisionSnapshots, nextDemandCollectionProfile),
|
|
||||||
inputs,
|
|
||||||
decidedAt
|
|
||||||
);
|
|
||||||
const result = await pool.query<AnchorReviewRunRow>(
|
const result = await pool.query<AnchorReviewRunRow>(
|
||||||
`
|
`
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||||
|
|
@ -755,7 +549,6 @@ export async function saveAnchorReviewDecisions(
|
||||||
projectId,
|
projectId,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
decisionCount: inputs.length,
|
decisionCount: inputs.length,
|
||||||
demandCollectionProfile: output.demandCollectionProfile,
|
|
||||||
schemaVersion: "anchor-review-input.v1",
|
schemaVersion: "anchor-review-input.v1",
|
||||||
semanticRunId: output.semanticRunId
|
semanticRunId: output.semanticRunId
|
||||||
}),
|
}),
|
||||||
|
|
@ -862,19 +655,6 @@ export async function addAnchorReviewManualAnchor(
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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,
|
anchor: manualAnchor,
|
||||||
decision: manualAnchor.decision,
|
decision: manualAnchor.decision,
|
||||||
|
|
@ -883,7 +663,7 @@ export async function addAnchorReviewManualAnchor(
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const normalization = await getSeoNormalizationContract(projectId);
|
const normalization = await getSeoNormalizationContract(projectId);
|
||||||
const output = buildContract(projectId, normalization, decisionSnapshots, current.demandCollectionProfile);
|
const output = buildContract(projectId, normalization, decisionSnapshots);
|
||||||
const result = await pool.query<AnchorReviewRunRow>(
|
const result = await pool.query<AnchorReviewRunRow>(
|
||||||
`
|
`
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||||
|
|
|
||||||
|
|
@ -1,768 +0,0 @@
|
||||||
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,6 +1,5 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||||
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
|
||||||
|
|
||||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
||||||
|
|
@ -221,11 +220,6 @@ const CONSUMER_AI_NOISE_PATTERNS = [
|
||||||
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
||||||
const B2B_KEYWORD_FACET_PATTERN =
|
const B2B_KEYWORD_FACET_PATTERN =
|
||||||
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|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 = [
|
const ARTICLE_PATTERNS = [
|
||||||
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
|
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
|
||||||
|
|
@ -264,12 +258,6 @@ function wordCount(value: string) {
|
||||||
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
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([
|
const KEYWORD_CLEANING_FACET_STOP_WORDS = new Set([
|
||||||
"a",
|
"a",
|
||||||
"an",
|
"an",
|
||||||
|
|
@ -587,7 +575,7 @@ function enforceKeywordCleaningSemanticGuard(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRescueExactTrashItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
|
||||||
return (
|
return (
|
||||||
item.decision === "trash" &&
|
item.decision === "trash" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
|
|
@ -599,7 +587,6 @@ function shouldRescueExactTrashItem(market: MarketEnrichmentContract, item: Keyw
|
||||||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||||
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
|
||||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||||
);
|
);
|
||||||
|
|
@ -612,7 +599,6 @@ function isBroadHeadDemandItem(item: KeywordCleaningItem) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldPromoteExactDemandItem(
|
function shouldPromoteExactDemandItem(
|
||||||
market: MarketEnrichmentContract,
|
|
||||||
item: KeywordCleaningItem,
|
item: KeywordCleaningItem,
|
||||||
contextTokens: Set<string>,
|
contextTokens: Set<string>,
|
||||||
guard: KeywordCleaningFacetGuard
|
guard: KeywordCleaningFacetGuard
|
||||||
|
|
@ -635,15 +621,14 @@ function shouldPromoteExactDemandItem(
|
||||||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||||
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
|
||||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rescueExactTrashItems(market: MarketEnrichmentContract, items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
function rescueExactTrashItems(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (!shouldRescueExactTrashItem(market, item)) {
|
if (!shouldRescueExactTrashItem(item)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -663,7 +648,7 @@ function promoteExactDemandItems(market: MarketEnrichmentContract, items: Keywor
|
||||||
const guard = buildKeywordCleaningFacetGuard(market);
|
const guard = buildKeywordCleaningFacetGuard(market);
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (!shouldPromoteExactDemandItem(market, item, contextTokens, guard)) {
|
if (!shouldPromoteExactDemandItem(item, contextTokens, guard)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -682,26 +667,20 @@ function enforceKeywordCleaningBackendGuards(
|
||||||
market: MarketEnrichmentContract,
|
market: MarketEnrichmentContract,
|
||||||
items: KeywordCleaningItem[]
|
items: KeywordCleaningItem[]
|
||||||
): KeywordCleaningItem[] {
|
): KeywordCleaningItem[] {
|
||||||
return promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)));
|
return promoteExactDemandItems(market, rescueExactTrashItems(enforceKeywordCleaningSemanticGuard(market, items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
|
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
|
||||||
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
|
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldBackfillMissingExactItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
function shouldBackfillMissingExactItem(item: KeywordCleaningItem) {
|
||||||
return (
|
return (
|
||||||
(item.decision === "use" || item.decision === "support") &&
|
item.decision !== "trash" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.wordstatResultId) &&
|
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)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,7 +690,7 @@ function mergeMissingExactEvidenceItems(
|
||||||
): KeywordCleaningItem[] {
|
): KeywordCleaningItem[] {
|
||||||
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
|
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
|
||||||
const missingItems = classifyItems(market, getBaseItems(market))
|
const missingItems = classifyItems(market, getBaseItems(market))
|
||||||
.filter((item) => shouldBackfillMissingExactItem(market, item))
|
.filter(shouldBackfillMissingExactItem)
|
||||||
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
|
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
|
const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
|
||||||
|
|
@ -719,11 +698,11 @@ function mergeMissingExactEvidenceItems(
|
||||||
|
|
||||||
return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
|
return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
|
||||||
})
|
})
|
||||||
.slice(0, 24)
|
.slice(0, 80)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
|
evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
|
||||||
reason: `Backend audit сохранил high-fit exact Wordstat evidence, который модель не вернула в keyword_cleaning. ${item.reason}`
|
reason: `Backend evidence добавил exact Wordstat item, который модель не вернула в keyword_cleaning. ${item.reason}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...modelItems, ...missingItems];
|
return [...modelItems, ...missingItems];
|
||||||
|
|
@ -907,13 +886,13 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(contract);
|
const briefClusterMap = buildBriefClusterMap(contract);
|
||||||
const seedItems = contract.seedQueue
|
const seedItems = contract.seedQueue
|
||||||
.slice(0, 260)
|
.slice(0, 160)
|
||||||
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
|
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
|
||||||
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
|
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
|
||||||
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
|
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
|
||||||
const relatedItems: KeywordCleaningItem[] = [];
|
const relatedItems: KeywordCleaningItem[] = [];
|
||||||
|
|
||||||
for (const [index, result] of contract.wordstat.topResults.slice(0, 220).entries()) {
|
for (const [index, result] of contract.wordstat.topResults.slice(0, 120).entries()) {
|
||||||
const normalizedResult = normalizePhrase(result.phrase);
|
const normalizedResult = normalizePhrase(result.phrase);
|
||||||
|
|
||||||
if (seen.has(normalizedResult)) {
|
if (seen.has(normalizedResult)) {
|
||||||
|
|
@ -986,84 +965,41 @@ 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"] {
|
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(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 {
|
return {
|
||||||
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT).map((anchor) => ({
|
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({
|
||||||
clusterId: anchor.clusterId,
|
clusterId: anchor.clusterId,
|
||||||
clusterTitle: truncateTaskText(anchor.clusterTitle),
|
clusterTitle: anchor.clusterTitle,
|
||||||
confidence: anchor.confidence,
|
confidence: anchor.confidence,
|
||||||
intentType: anchor.intentType,
|
intentType: anchor.intentType,
|
||||||
marketRole: anchor.marketRole,
|
marketRole: anchor.marketRole,
|
||||||
normalizedFrom: anchor.normalizedFrom.slice(0, 3).map((phrase) => truncateTaskText(phrase)),
|
normalizedFrom: anchor.normalizedFrom,
|
||||||
phrase: anchor.phrase,
|
phrase: anchor.phrase,
|
||||||
priority: anchor.priority
|
priority: anchor.priority
|
||||||
})),
|
})),
|
||||||
landingBriefs: contract.briefEvidence.slice(0, KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT).map((brief) => ({
|
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
|
||||||
action: truncateTaskText(brief.action, 160),
|
action: brief.action,
|
||||||
evidenceStatus: brief.evidenceStatus,
|
evidenceStatus: brief.evidenceStatus,
|
||||||
path: brief.path,
|
path: brief.path,
|
||||||
priority: brief.priority,
|
priority: brief.priority,
|
||||||
qualityScore: brief.qualityScore,
|
qualityScore: brief.qualityScore,
|
||||||
seedPhrases: brief.seedPhrases.slice(0, 6).map((phrase) => truncateTaskText(phrase))
|
seedPhrases: brief.seedPhrases.slice(0, 10)
|
||||||
})),
|
})),
|
||||||
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT).map((group) => ({
|
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({
|
||||||
excludedPhrases: group.excludedPhrases.slice(0, 6).map((item) => truncateTaskText(item.phrase)),
|
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
|
||||||
id: group.id,
|
id: group.id,
|
||||||
intentType: group.intentType,
|
intentType: group.intentType,
|
||||||
marketHypothesis: truncateTaskText(group.marketHypothesis, 180),
|
marketHypothesis: group.marketHypothesis,
|
||||||
priority: group.priority,
|
priority: group.priority,
|
||||||
title: truncateTaskText(group.title),
|
title: group.title,
|
||||||
wordstatQueries: group.wordstatQueries.slice(0, 6).map((query) => truncateTaskText(query))
|
wordstatQueries: group.wordstatQueries.slice(0, 12)
|
||||||
})),
|
})),
|
||||||
seedQueue: modelSeedQueue.map((seed) => ({
|
seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({
|
||||||
clusterId: seed.clusterId,
|
clusterId: seed.clusterId,
|
||||||
clusterTitle: truncateTaskText(seed.clusterTitle),
|
clusterTitle: seed.clusterTitle,
|
||||||
evidenceStatus: seed.evidenceStatus,
|
evidenceStatus: seed.evidenceStatus,
|
||||||
frequency: seed.frequency,
|
frequency: seed.frequency,
|
||||||
frequencyGroup: seed.frequencyGroup,
|
frequencyGroup: seed.frequencyGroup,
|
||||||
|
|
@ -1077,14 +1013,14 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
|
||||||
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
||||||
wordstatResultId: seed.wordstatResultId
|
wordstatResultId: seed.wordstatResultId
|
||||||
})),
|
})),
|
||||||
wordstatTopResults: modelTopResults.map((result) => ({
|
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
|
||||||
frequency: result.frequency,
|
frequency: result.frequency,
|
||||||
frequencyGroup: result.frequencyGroup,
|
frequencyGroup: result.frequencyGroup,
|
||||||
id: result.id,
|
id: result.id,
|
||||||
phrase: result.phrase,
|
phrase: result.phrase,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
sourceClusterId: result.sourceClusterId,
|
sourceClusterId: result.sourceClusterId,
|
||||||
sourceClusterTitle: truncateTaskText(result.sourceClusterTitle),
|
sourceClusterTitle: result.sourceClusterTitle,
|
||||||
sourcePhrase: result.sourcePhrase
|
sourcePhrase: result.sourcePhrase
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
@ -1110,7 +1046,7 @@ function isBroadAiKeywordNoise(phrase: string | null) {
|
||||||
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
|
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClassification(contract: MarketEnrichmentContract, item: KeywordCleaningItem, contextTokens: Set<string>) {
|
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||||
const blockers: string[] = [];
|
const blockers: string[] = [];
|
||||||
const exactEvidence = item.frequency !== null;
|
const exactEvidence = item.frequency !== null;
|
||||||
const noExternalSignal =
|
const noExternalSignal =
|
||||||
|
|
@ -1174,16 +1110,6 @@ function getClassification(contract: MarketEnrichmentContract, item: KeywordClea
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (isArticle && exactEvidence) {
|
||||||
return {
|
return {
|
||||||
blockers,
|
blockers,
|
||||||
|
|
@ -1225,7 +1151,7 @@ function classifyItems(contract: MarketEnrichmentContract, items: KeywordCleanin
|
||||||
const contextTokens = buildContextTokenSet(contract);
|
const contextTokens = buildContextTokenSet(contract);
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const classification = getClassification(contract, item, contextTokens);
|
const classification = getClassification(item, contextTokens);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
|
@ -1255,8 +1181,7 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.decision === "use" || item.decision === "support") &&
|
(item.decision === "use" || item.decision === "support") &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null
|
||||||
Boolean(item.wordstatResultId)
|
|
||||||
).length,
|
).length,
|
||||||
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||||
riskyCount: lanes.risky.length,
|
riskyCount: lanes.risky.length,
|
||||||
|
|
@ -1338,11 +1263,9 @@ function buildKeywordCleaningModelTask(
|
||||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||||
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
|
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
|
||||||
"Не пропускать exact Wordstat items молча: для каждой значимой exact-фразы из seedQueue/topResults вернуть item с use/support/article/risky/trash и причиной.",
|
"Не пропускать 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 только из-за соседнего рынка.",
|
"Stage 5 собирает глобальную карту спроса, а не rewrite текущей посадочной: частотные exact B2B-смежные ветки с сохранённым source-фасетом должны попадать минимум в support/risky, а не исчезать в article только из-за соседнего рынка.",
|
||||||
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
|
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
|
||||||
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
||||||
"Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.",
|
|
||||||
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
|
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
|
||||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||||
"Не сохранять rewrite/apply decisions."
|
"Не сохранять rewrite/apply decisions."
|
||||||
|
|
@ -1380,7 +1303,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(
|
actions.push(
|
||||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} кандидатов в 5 колонок; exact-frequency закрепляется строже, финальные роли фиксирует пользователь.`
|
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
|
||||||
);
|
);
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
|
|
@ -1544,29 +1467,6 @@ export async function getLatestAlignedKeywordCleaning(
|
||||||
return run;
|
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> {
|
export async function getKeywordCleaningContract(projectId: string): Promise<KeywordCleaningContract> {
|
||||||
const market = await getMarketEnrichmentContract(projectId);
|
const market = await getMarketEnrichmentContract(projectId);
|
||||||
const fallbackContract = buildFallbackContract(projectId, market);
|
const fallbackContract = buildFallbackContract(projectId, market);
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
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-профиль не найден.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||||
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
||||||
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
|
||||||
|
|
||||||
type KeywordMapState = "blocked" | "draft";
|
type KeywordMapState = "blocked" | "draft";
|
||||||
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||||
|
|
@ -83,15 +82,6 @@ export type KeywordMapPersistResult = {
|
||||||
|
|
||||||
type KeywordMapItem = KeywordMapContract["items"][number];
|
type KeywordMapItem = KeywordMapContract["items"][number];
|
||||||
type KeywordCleaningItem = KeywordCleaningContract["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 = {
|
export type KeywordMapApproveInput = {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
|
|
@ -99,39 +89,12 @@ export type KeywordMapApproveInput = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
role: KeywordMapRole;
|
role: KeywordMapRole;
|
||||||
}>;
|
}>;
|
||||||
suppressedItemIds?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
||||||
return keywordMapRoles.includes(value as 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) {
|
function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
@ -408,10 +371,6 @@ function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBackendMissingExactBackfillItem(item: KeywordCleaningItem) {
|
|
||||||
return item.evidenceRefs.some((ref) => ref === "backend:missing-exact-evidence-backfill");
|
|
||||||
}
|
|
||||||
|
|
||||||
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
|
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
|
||||||
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
|
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
|
||||||
|
|
||||||
|
|
@ -425,137 +384,41 @@ 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) {
|
function isLongTailKeywordCandidate(item: KeywordCleaningItem) {
|
||||||
return item.priority === "low" || getKeywordWordCount(item.phrase) >= 5 || (item.frequency !== null && item.frequency < 50);
|
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) {
|
function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
|
||||||
const text = normalizePhrase(item.phrase);
|
const text = getKeywordProfileText(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
item.marketRole === "integration" ||
|
item.marketRole === "integration" ||
|
||||||
item.intentType === "deployment_security" ||
|
item.intentType === "deployment_security" ||
|
||||||
item.intentType === "integration" ||
|
item.intentType === "integration" ||
|
||||||
/(^|\s)(api|bim|crm|erp|mcp|iot|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
|
/(^|\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
|
text
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeywordRole(
|
function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard: SemanticFacetGuard): KeywordMapRole {
|
||||||
item: KeywordCleaningItem,
|
|
||||||
indexInTarget: number,
|
|
||||||
guard: SemanticFacetGuard,
|
|
||||||
roleContext: KeywordRoleContext
|
|
||||||
): KeywordMapRole {
|
|
||||||
if (!canModelRouteKeyword(item, guard)) {
|
if (!canModelRouteKeyword(item, guard)) {
|
||||||
return "validate";
|
return "validate";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasEducationIntentMismatch(roleContext.market, item.phrase)) {
|
if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
|
||||||
return "validate";
|
return "primary";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isLongTailKeywordCandidate(item)) {
|
||||||
indexInTarget === 0 &&
|
return "support";
|
||||||
(item.decision === "use" || item.decision === "support") &&
|
|
||||||
(item.priority === "high" || item.priority === "medium") &&
|
|
||||||
!isLongTailKeywordCandidate(item) &&
|
|
||||||
!isClarifyingKeywordCandidate(item) &&
|
|
||||||
hasProjectFacetForPrimary(item, guard)
|
|
||||||
) {
|
|
||||||
return "primary";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isClarifyingKeywordCandidate(item)) {
|
if (isClarifyingKeywordCandidate(item)) {
|
||||||
return "differentiator";
|
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") {
|
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
|
||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
@ -600,10 +463,15 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
||||||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||||
const roleContext: KeywordRoleContext = { market: contract };
|
|
||||||
const seenPhrases = new Set<string>();
|
const seenPhrases = new Set<string>();
|
||||||
const sourceItems = cleaning.items
|
const sourceItems = cleaning.items
|
||||||
.filter((item) => isKeywordMapDisplayCandidate(item, semanticFacetGuard, roleContext))
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.decision !== "trash" &&
|
||||||
|
item.evidenceStatus === "collected" &&
|
||||||
|
item.frequency !== null &&
|
||||||
|
Boolean(item.wordstatResultId)
|
||||||
|
)
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
||||||
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
||||||
|
|
@ -658,7 +526,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
||||||
const rawTargetPath = item.targetPath;
|
const rawTargetPath = item.targetPath;
|
||||||
const targetKey = rawTargetPath ?? item.clusterId;
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard, roleContext);
|
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
|
||||||
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
||||||
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
||||||
|
|
||||||
|
|
@ -963,109 +831,6 @@ 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">) {
|
function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||||
if (contract.blockers.length > 0) {
|
if (contract.blockers.length > 0) {
|
||||||
return contract.blockers;
|
return contract.blockers;
|
||||||
|
|
@ -1075,7 +840,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||||
|
|
||||||
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
||||||
actions.push(
|
actions.push(
|
||||||
"Keyword cleaning не дал кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (contract.readiness.marketEvidenceCount === 0) {
|
if (contract.readiness.marketEvidenceCount === 0) {
|
||||||
|
|
@ -1115,23 +880,12 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
|
||||||
]);
|
]);
|
||||||
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
|
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
|
||||||
const blockers = buildBlockers(marketContract, cleaningContract);
|
const blockers = buildBlockers(marketContract, cleaningContract);
|
||||||
const semanticRunId = marketContract.semanticRunId;
|
const items = buildItems(marketContract, cleaningContract, reviewedOntology);
|
||||||
const projectOntologyVersionId = marketContract.projectOntologyVersion?.id ?? null;
|
const persisted = await getKeywordMapPersistenceSummary(
|
||||||
const [suppressedItems, persistedLayouts, persisted] = await Promise.all([
|
projectId,
|
||||||
getSuppressedKeywordMapItems(projectId, semanticRunId, projectOntologyVersionId),
|
marketContract.semanticRunId,
|
||||||
getPersistedKeywordMapLayouts(projectId, semanticRunId, projectOntologyVersionId),
|
marketContract.projectOntologyVersion?.id ?? null
|
||||||
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"> = {
|
const contractWithoutActions: Omit<KeywordMapContract, "nextActions"> = {
|
||||||
schemaVersion: "keyword-map.v1",
|
schemaVersion: "keyword-map.v1",
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -1181,7 +935,6 @@ export async function approveKeywordMapDecisions(
|
||||||
.filter((override) => isKeywordMapRole(override.role))
|
.filter((override) => isKeywordMapRole(override.role))
|
||||||
.map((override) => [override.itemId, override.role] as const)
|
.map((override) => [override.itemId, override.role] as const)
|
||||||
);
|
);
|
||||||
const suppressedItemIds = new Set(input.suppressedItemIds ?? []);
|
|
||||||
const selectedItems = keywordMap.items
|
const selectedItems = keywordMap.items
|
||||||
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
|
|
@ -1189,17 +942,9 @@ export async function approveKeywordMapDecisions(
|
||||||
|
|
||||||
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
|
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
|
||||||
});
|
});
|
||||||
const suppressedItems = selectedItems.filter((item) => suppressedItemIds.has(item.id));
|
const persistableItems = selectedItems.filter(isPersistableKeywordMapItem);
|
||||||
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.semanticRunId || !keywordMap.projectOntologyVersion) {
|
if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
|
||||||
return {
|
return {
|
||||||
keywordMap,
|
keywordMap,
|
||||||
savedDecisionCount: 0,
|
savedDecisionCount: 0,
|
||||||
|
|
@ -1215,9 +960,9 @@ export async function approveKeywordMapDecisions(
|
||||||
|
|
||||||
let savedDecisionCount = 0;
|
let savedDecisionCount = 0;
|
||||||
let savedMapItemCount = 0;
|
let savedMapItemCount = 0;
|
||||||
const activeSourceItemIds = approvableItems.map((item) => item.id);
|
const activeSourceItemIds = persistableItems.map((item) => item.id);
|
||||||
|
|
||||||
if (!itemIdFilter && canApproveMap) {
|
if (!itemIdFilter) {
|
||||||
if (activeSourceItemIds.length > 0) {
|
if (activeSourceItemIds.length > 0) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
|
|
@ -1271,166 +1016,7 @@ export async function approveKeywordMapDecisions(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of suppressedItems) {
|
for (const item of persistableItems) {
|
||||||
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 }>(
|
const decisionResult = await client.query<{ id: string }>(
|
||||||
`
|
`
|
||||||
insert into keyword_decisions (
|
insert into keyword_decisions (
|
||||||
|
|
@ -1591,7 +1177,7 @@ export async function approveKeywordMapDecisions(
|
||||||
keywordMap: await getKeywordMapContract(projectId),
|
keywordMap: await getKeywordMapContract(projectId),
|
||||||
savedDecisionCount,
|
savedDecisionCount,
|
||||||
savedMapItemCount,
|
savedMapItemCount,
|
||||||
skippedItemCount: selectedItems.length - suppressedItems.length - layoutItems.length - approvableItems.length
|
skippedItemCount: selectedItems.length - persistableItems.length
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("rollback");
|
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,20 +100,6 @@ function getString(value: unknown) {
|
||||||
return typeof value === "string" ? value.trim() : "";
|
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) {
|
function getNumber(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return Math.max(0, Math.round(value));
|
return Math.max(0, Math.round(value));
|
||||||
|
|
@ -138,10 +124,6 @@ function asArray(value: unknown) {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeArraySources(...values: unknown[]) {
|
|
||||||
return values.flatMap((value) => asArray(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
const phrase =
|
const phrase =
|
||||||
|
|
@ -151,7 +133,7 @@ function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
||||||
getString(record.text) ||
|
getString(record.text) ||
|
||||||
getString(record.name);
|
getString(record.name);
|
||||||
|
|
||||||
if (!phrase || !isRealWordstatPhrase(phrase)) {
|
if (!phrase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,27 +162,20 @@ function normalizeSeedResult(value: unknown, seed: WordstatSeedInput): WordstatS
|
||||||
getNumber(record.count) ??
|
getNumber(record.count) ??
|
||||||
getNumber(record.total) ??
|
getNumber(record.total) ??
|
||||||
getNumber(record.impressions);
|
getNumber(record.impressions);
|
||||||
const relatedSource = mergeArraySources(
|
const relatedSource =
|
||||||
record.relatedQueries,
|
record.relatedQueries ??
|
||||||
record.related_queries,
|
record.related_queries ??
|
||||||
record.queries,
|
record.queries ??
|
||||||
record.items,
|
record.items ??
|
||||||
record.results,
|
record.results ??
|
||||||
record.associations,
|
record.associations;
|
||||||
record.topRequests,
|
|
||||||
record.top_requests,
|
|
||||||
record.top,
|
|
||||||
record.popular,
|
|
||||||
record.popularQueries,
|
|
||||||
record.popular_queries
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seedId: seed.seedId,
|
seedId: seed.seedId,
|
||||||
seedPhrase: isRealWordstatPhrase(phrase) ? phrase : seed.phrase,
|
seedPhrase: phrase,
|
||||||
frequency,
|
frequency,
|
||||||
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
|
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
|
||||||
relatedQueries: relatedSource.flatMap((related) => {
|
relatedQueries: asArray(relatedSource).flatMap((related) => {
|
||||||
const normalized = normalizeRelatedQuery(related);
|
const normalized = normalizeRelatedQuery(related);
|
||||||
return normalized ? [normalized] : [];
|
return normalized ? [normalized] : [];
|
||||||
}),
|
}),
|
||||||
|
|
@ -262,22 +237,14 @@ class DisabledWordstatProvider implements WordstatProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
|
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
|
||||||
let response: Response;
|
const response = await fetch(endpoint, {
|
||||||
try {
|
method: "POST",
|
||||||
response = await fetch(endpoint, {
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
...headers
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
...headers
|
body: JSON.stringify(body)
|
||||||
},
|
});
|
||||||
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) {
|
if (!response.ok) {
|
||||||
const message = await response.text().catch(() => "");
|
const message = await response.text().catch(() => "");
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ type WordstatResultRow = {
|
||||||
|
|
||||||
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
||||||
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
||||||
const WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION = 16;
|
|
||||||
|
|
||||||
export type WordstatQuotaSnapshot = {
|
export type WordstatQuotaSnapshot = {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
@ -155,18 +154,8 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRealWordstatPhrase(value: string) {
|
const WORDSTAT_TOP_RESULTS_LIMIT = 160;
|
||||||
const normalizedValue = normalizePhrase(value);
|
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12;
|
||||||
|
|
||||||
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([
|
const RELATED_QUERY_STOP_WORDS = new Set([
|
||||||
"a",
|
"a",
|
||||||
|
|
@ -281,7 +270,7 @@ function shouldKeepRelatedQuery(seedPhrase: string, relatedPhrase: string) {
|
||||||
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
|
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
|
||||||
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
|
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
|
||||||
|
|
||||||
if (!isRealWordstatPhrase(normalizedRelatedPhrase) || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
if (!normalizedRelatedPhrase || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,7 +352,7 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
|
||||||
const topResultByPhrase = new Map<string, WordstatResultRow>();
|
const topResultByPhrase = new Map<string, WordstatResultRow>();
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.frequency === null || !isRealWordstatPhrase(result.phrase)) {
|
if (result.frequency === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,11 +448,10 @@ function buildEvidence(
|
||||||
results: WordstatResultRow[],
|
results: WordstatResultRow[],
|
||||||
quota: WordstatQuotaSnapshot | null
|
quota: WordstatQuotaSnapshot | null
|
||||||
): WordstatEvidence {
|
): WordstatEvidence {
|
||||||
const realResults = results.filter((result) => isRealWordstatPhrase(result.phrase));
|
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||||
const seedRows = realResults.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
|
||||||
const relatedCounts = new Map<string, number>();
|
const relatedCounts = new Map<string, number>();
|
||||||
|
|
||||||
for (const result of realResults) {
|
for (const result of results) {
|
||||||
if (result.source_phrase && result.source_phrase !== result.phrase) {
|
if (result.source_phrase && result.source_phrase !== result.phrase) {
|
||||||
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
|
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -487,7 +475,7 @@ function buildEvidence(
|
||||||
seedSource: result.seed_source,
|
seedSource: result.seed_source,
|
||||||
collectedAt: result.created_at.toISOString()
|
collectedAt: result.created_at.toISOString()
|
||||||
})),
|
})),
|
||||||
topResults: getRepresentativeTopResultRows(realResults)
|
topResults: getRepresentativeTopResultRows(results)
|
||||||
.map((result) => ({
|
.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
phrase: result.phrase,
|
phrase: result.phrase,
|
||||||
|
|
@ -509,11 +497,11 @@ function buildEvidence(
|
||||||
noSignalSeedCount: seedRows.filter(
|
noSignalSeedCount: seedRows.filter(
|
||||||
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
|
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
|
||||||
).length,
|
).length,
|
||||||
resultCount: realResults.length,
|
resultCount: results.length,
|
||||||
totalFrequency: realResults.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
totalFrequency: results.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
||||||
highFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
highFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
||||||
midFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
midFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
||||||
lowFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
|
lowFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -611,27 +599,6 @@ async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: strin
|
||||||
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
|
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> {
|
async function getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
|
||||||
const result = await pool.query<{
|
const result = await pool.query<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -893,8 +860,7 @@ async function updateJobFailed(jobId: string, error: unknown) {
|
||||||
export async function collectWordstatEvidence(
|
export async function collectWordstatEvidence(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
analysis: SemanticAnalysisRun,
|
analysis: SemanticAnalysisRun,
|
||||||
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates,
|
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates
|
||||||
options: { maxSeeds?: number } = {}
|
|
||||||
) {
|
) {
|
||||||
const provider = await createWordstatProvider();
|
const provider = await createWordstatProvider();
|
||||||
const status = provider.getStatus();
|
const status = provider.getStatus();
|
||||||
|
|
@ -905,20 +871,14 @@ export async function collectWordstatEvidence(
|
||||||
|
|
||||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
||||||
const requestedSeedPhrases = await getRequestedWordstatPhraseSet(projectId, analysis.runId);
|
const uncollectedSeedRows = seedRows.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)));
|
||||||
const alreadyTriedSeedPhrases = new Set([...collectedSeedPhrases, ...requestedSeedPhrases]);
|
|
||||||
const uncollectedSeedRows = seedRows.filter((row) => !alreadyTriedSeedPhrases.has(normalizePhrase(row.phrase)));
|
|
||||||
|
|
||||||
if (uncollectedSeedRows.length === 0) {
|
if (uncollectedSeedRows.length === 0) {
|
||||||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
|
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
|
||||||
const requestedMaxSeeds = Math.min(
|
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining);
|
||||||
options.maxSeeds ?? status.maxSeedsPerRun,
|
|
||||||
WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION
|
|
||||||
);
|
|
||||||
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining, requestedMaxSeeds);
|
|
||||||
|
|
||||||
if (maxSeedsForRun <= 0) {
|
if (maxSeedsForRun <= 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -983,7 +943,9 @@ export async function getLatestWordstatEvidence(
|
||||||
updated_at
|
updated_at
|
||||||
from wordstat_jobs
|
from wordstat_jobs
|
||||||
where project_id = $1 and semantic_run_id = $2
|
where project_id = $1 and semantic_run_id = $2
|
||||||
order by created_at desc
|
order by
|
||||||
|
case when status = 'done' then 0 else 1 end,
|
||||||
|
created_at desc
|
||||||
limit 1;
|
limit 1;
|
||||||
`,
|
`,
|
||||||
[projectId, semanticRunId]
|
[projectId, semanticRunId]
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,8 @@
|
||||||
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
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 { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
|
||||||
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
||||||
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
||||||
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
||||||
import {
|
|
||||||
getMarketFrontierDiscoveryContract,
|
|
||||||
type MarketFrontierDiscoveryModelTaskContract
|
|
||||||
} from "../market/marketFrontierDiscovery.js";
|
|
||||||
import {
|
import {
|
||||||
getSeoModelProviderCatalog,
|
getSeoModelProviderCatalog,
|
||||||
type SeoModelProviderCatalog
|
type SeoModelProviderCatalog
|
||||||
|
|
@ -40,10 +26,7 @@ import {
|
||||||
|
|
||||||
export type SeoModelTaskContract =
|
export type SeoModelTaskContract =
|
||||||
| KeywordCleaningModelTaskContract
|
| KeywordCleaningModelTaskContract
|
||||||
| SeoBusinessSynthesisModelTaskContract
|
|
||||||
| SeoCommercialDemandModelTaskContract
|
|
||||||
| SeoContextReviewModelTaskContract
|
| SeoContextReviewModelTaskContract
|
||||||
| MarketFrontierDiscoveryModelTaskContract
|
|
||||||
| SeoNormalizationModelTaskContract
|
| SeoNormalizationModelTaskContract
|
||||||
| SerpInterpretationModelTaskContract
|
| SerpInterpretationModelTaskContract
|
||||||
| StrategyQualityReviewModelTaskContract
|
| StrategyQualityReviewModelTaskContract
|
||||||
|
|
@ -81,13 +64,8 @@ export type SeoModelContextContract = {
|
||||||
} | null;
|
} | null;
|
||||||
stageReadiness: {
|
stageReadiness: {
|
||||||
semanticContextReady: boolean;
|
semanticContextReady: boolean;
|
||||||
businessSynthesisTaskReady: boolean;
|
|
||||||
businessSynthesisResultReady: boolean;
|
|
||||||
commercialDemandTaskReady: boolean;
|
|
||||||
commercialDemandResultReady: boolean;
|
|
||||||
contextReviewTaskReady: boolean;
|
contextReviewTaskReady: boolean;
|
||||||
contextReviewResultReady: boolean;
|
contextReviewResultReady: boolean;
|
||||||
marketFrontierDiscoveryTaskReady: boolean;
|
|
||||||
normalizationState: "not_ready" | "ready";
|
normalizationState: "not_ready" | "ready";
|
||||||
normalizationTaskReady: boolean;
|
normalizationTaskReady: boolean;
|
||||||
keywordCleaningState: "not_ready" | "ready";
|
keywordCleaningState: "not_ready" | "ready";
|
||||||
|
|
@ -102,7 +80,7 @@ export type SeoModelContextContract = {
|
||||||
modelProvider: SeoModelProviderCatalog;
|
modelProvider: SeoModelProviderCatalog;
|
||||||
contextReview: {
|
contextReview: {
|
||||||
runId: string;
|
runId: string;
|
||||||
providerMode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
providerMode: "codex_manual" | "deterministic_fallback";
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
needsHumanReview: boolean;
|
needsHumanReview: boolean;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
|
@ -189,12 +167,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
const skills = getSeoSkillPackContract();
|
const skills = getSeoSkillPackContract();
|
||||||
const modelProvider = getSeoModelProviderCatalog();
|
const modelProvider = getSeoModelProviderCatalog();
|
||||||
const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null;
|
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 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 serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence);
|
||||||
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
|
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
|
||||||
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
|
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
|
||||||
|
|
@ -205,9 +178,6 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
);
|
);
|
||||||
const modelTasks = [
|
const modelTasks = [
|
||||||
contextReviewTask,
|
contextReviewTask,
|
||||||
businessSynthesisTask,
|
|
||||||
commercialDemandTask,
|
|
||||||
marketFrontierDiscovery?.modelTask,
|
|
||||||
normalization.modelTask,
|
normalization.modelTask,
|
||||||
keywordCleaning.modelTask,
|
keywordCleaning.modelTask,
|
||||||
serpInterpretationTask,
|
serpInterpretationTask,
|
||||||
|
|
@ -255,14 +225,8 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
allowedEvidence: [
|
allowedEvidence: [
|
||||||
"latest scan metadata",
|
"latest scan metadata",
|
||||||
"project ontology summary",
|
"project ontology summary",
|
||||||
"clean selected site text chunks",
|
|
||||||
"active SEO skill pack contracts",
|
"active SEO skill pack contracts",
|
||||||
"context review model task",
|
"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",
|
"semantic clusters and source refs",
|
||||||
"normalization model task",
|
"normalization model task",
|
||||||
"keyword cleaning model task",
|
"keyword cleaning model task",
|
||||||
|
|
@ -298,13 +262,8 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
: null,
|
: null,
|
||||||
stageReadiness: {
|
stageReadiness: {
|
||||||
semanticContextReady: Boolean(analysis),
|
semanticContextReady: Boolean(analysis),
|
||||||
businessSynthesisTaskReady: Boolean(businessSynthesisTask),
|
|
||||||
businessSynthesisResultReady: Boolean(businessSynthesis),
|
|
||||||
commercialDemandTaskReady: Boolean(commercialDemandTask),
|
|
||||||
commercialDemandResultReady: Boolean(commercialDemand),
|
|
||||||
contextReviewTaskReady: Boolean(contextReviewTask),
|
contextReviewTaskReady: Boolean(contextReviewTask),
|
||||||
contextReviewResultReady: Boolean(contextReview),
|
contextReviewResultReady: Boolean(contextReview),
|
||||||
marketFrontierDiscoveryTaskReady: Boolean(marketFrontierDiscovery?.modelTask),
|
|
||||||
normalizationState: normalization.state,
|
normalizationState: normalization.state,
|
||||||
normalizationTaskReady: Boolean(normalization.modelTask),
|
normalizationTaskReady: Boolean(normalization.modelTask),
|
||||||
keywordCleaningState: keywordCleaning.state,
|
keywordCleaningState: keywordCleaning.state,
|
||||||
|
|
@ -389,7 +348,6 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
"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."
|
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||||
],
|
],
|
||||||
nextActions: !analysis
|
nextActions: !analysis
|
||||||
|
|
@ -402,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать широкие кандидаты в пять колонок для ручной правки; exact-frequency нужен для строгой фиксации.",
|
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
||||||
"Не запускать external market collection без anchor review approval."
|
"Не запускать external market collection без anchor review approval."
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,7 @@ export type SeoAiWorkspacePersistedResult = {
|
||||||
schemaVersion: "seo-model-persisted-result.v1";
|
schemaVersion: "seo-model-persisted-result.v1";
|
||||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||||
runType:
|
runType:
|
||||||
| "seo_business_synthesis"
|
|
||||||
| "seo_commercial_demand"
|
|
||||||
| "seo_context_review"
|
|
||||||
| "keyword_cleaning"
|
| "keyword_cleaning"
|
||||||
| "market_frontier_discovery"
|
|
||||||
| "seo_model_task"
|
| "seo_model_task"
|
||||||
| "seo_normalization"
|
| "seo_normalization"
|
||||||
| "seo_strategy_quality_review"
|
| "seo_strategy_quality_review"
|
||||||
|
|
@ -154,10 +150,6 @@ export async function assistantRequest<T>(
|
||||||
throw new Error("ai_workspace_assistant_timeout");
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -177,14 +169,6 @@ function buildSeoModelTaskPrompt(input: {
|
||||||
`Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`,
|
`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.",
|
"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 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:",
|
"Hard boundaries:",
|
||||||
"- Work only from the task contract below.",
|
"- Work only from the task contract below.",
|
||||||
|
|
@ -397,52 +381,11 @@ function parseJsonFromText(rawText: string): JsonRecord | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstBrace = text.indexOf("{");
|
const firstBrace = text.indexOf("{");
|
||||||
|
const lastBrace = text.lastIndexOf("}");
|
||||||
|
|
||||||
if (firstBrace >= 0) {
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||||
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 {
|
try {
|
||||||
const parsed = JSON.parse(text.slice(firstBrace));
|
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
|
||||||
return isJsonRecord(parsed) ? parsed : null;
|
return isJsonRecord(parsed) ? parsed : null;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
||||||
|
|
||||||
export const SEO_MODEL_TASK_TYPES = [
|
export const SEO_MODEL_TASK_TYPES = [
|
||||||
"seo.context_review",
|
"seo.context_review",
|
||||||
"seo.business_synthesis",
|
|
||||||
"seo.commercial_demand",
|
|
||||||
"seo.market_frontier_discovery",
|
|
||||||
"seo.normalization",
|
"seo.normalization",
|
||||||
"seo.keyword_cleaning",
|
"seo.keyword_cleaning",
|
||||||
"seo.serp_interpretation",
|
"seo.serp_interpretation",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,7 @@
|
||||||
import { pool } from "../db/client.js";
|
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 { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
||||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||||
import {
|
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||||
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 { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
||||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -178,22 +157,6 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
||||||
return 2600;
|
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") {
|
if (task.taskType === "seo.keyword_cleaning") {
|
||||||
return 9000;
|
return 9000;
|
||||||
}
|
}
|
||||||
|
|
@ -454,68 +417,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
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 {
|
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
|
||||||
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
||||||
? run.modelOutput.aiWorkspace
|
? run.modelOutput.aiWorkspace
|
||||||
|
|
@ -564,91 +465,11 @@ async function promoteAiWorkspaceModelOutput(
|
||||||
return null;
|
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") {
|
if (run.task.taskType === "seo.normalization") {
|
||||||
const fallback = getNormalizationPromotionFallback(run);
|
|
||||||
|
|
||||||
await saveSeoNormalizationFromModelProvider(
|
await saveSeoNormalizationFromModelProvider(
|
||||||
projectId,
|
projectId,
|
||||||
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
||||||
run.runId,
|
run.runId
|
||||||
fallback
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
||||||
|
|
@ -717,14 +538,6 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
||||||
return run;
|
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);
|
const dispatch = getRunAiWorkspaceDispatch(run);
|
||||||
|
|
||||||
if (!dispatch) {
|
if (!dispatch) {
|
||||||
|
|
@ -802,30 +615,6 @@ 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) {
|
async function refreshRunningSeoModelTaskRuns(projectId: string) {
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
const result = await pool.query<SeoModelTaskRunRow>(
|
||||||
`
|
`
|
||||||
|
|
@ -919,8 +708,7 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
|
||||||
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
||||||
input: {
|
input: {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
aiWorkspace: dispatch,
|
aiWorkspace: dispatch
|
||||||
task
|
|
||||||
},
|
},
|
||||||
output: dispatchedOutput,
|
output: dispatchedOutput,
|
||||||
status: "running"
|
status: "running"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -253,67 +253,20 @@ const INTENT_SIGNAL_GROUP_META = {
|
||||||
const ONTOLOGY_STOP_WORDS = new Set([
|
const ONTOLOGY_STOP_WORDS = new Set([
|
||||||
"and",
|
"and",
|
||||||
"app",
|
"app",
|
||||||
"assets",
|
|
||||||
"attr",
|
|
||||||
"bodyhtml",
|
|
||||||
"bottominfo",
|
|
||||||
"card",
|
|
||||||
"collection",
|
|
||||||
"com",
|
"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",
|
|
||||||
"аватар",
|
|
||||||
"блок",
|
|
||||||
"заголовок",
|
|
||||||
"иконка",
|
|
||||||
"информационный",
|
|
||||||
"карточка",
|
|
||||||
"основная",
|
|
||||||
"приглушенная",
|
|
||||||
"пункт",
|
|
||||||
"пункты",
|
|
||||||
"строка",
|
|
||||||
"текст",
|
|
||||||
"форма",
|
|
||||||
"ячейка",
|
|
||||||
"это"
|
"это"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -384,12 +337,11 @@ function getTextWords(value: string) {
|
||||||
return normalizeOntologyText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? [];
|
return normalizeOntologyText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopTerms(value: string, limit: number, extraStopTerms: string[] = []) {
|
function getTopTerms(value: string, limit: number) {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
const stopTerms = new Set([...extraStopTerms.map(normalizeOntologyText), ...ONTOLOGY_STOP_WORDS]);
|
|
||||||
|
|
||||||
for (const word of getTextWords(value)) {
|
for (const word of getTextWords(value)) {
|
||||||
if (stopTerms.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
|
if (ONTOLOGY_STOP_WORDS.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,7 +526,7 @@ function buildGenericLandingBrief(brandName: string, documents: ProjectOntologyE
|
||||||
|
|
||||||
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
||||||
const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5);
|
const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5);
|
||||||
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${brandName} ${term}`);
|
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${term} ${brandName}`);
|
||||||
const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null;
|
const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null;
|
||||||
|
|
||||||
return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4);
|
return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4);
|
||||||
|
|
@ -582,8 +534,7 @@ function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
||||||
|
|
||||||
function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) {
|
function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) {
|
||||||
const brandKey = normalizeOntologyText(brandName);
|
const brandKey = normalizeOntologyText(brandName);
|
||||||
const evidenceTerms = matchedTerms.length > 0 ? matchedTerms : topTerms;
|
const terms = dedupeStrings([...matchedTerms, ...topTerms])
|
||||||
const terms = dedupeStrings(evidenceTerms)
|
|
||||||
.filter((term) => {
|
.filter((term) => {
|
||||||
const normalizedTerm = normalizeOntologyText(term);
|
const normalizedTerm = normalizeOntologyText(term);
|
||||||
|
|
||||||
|
|
@ -606,8 +557,7 @@ function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], top
|
||||||
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
|
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
|
||||||
const documents = getCoverageDocuments(evidence);
|
const documents = getCoverageDocuments(evidence);
|
||||||
const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n"));
|
const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n"));
|
||||||
const brandTerms = buildBrandTerms(brandName, evidence);
|
const topTerms = getTopTerms(allText, 10);
|
||||||
const topTerms = getTopTerms(allText, 10, brandTerms);
|
|
||||||
const homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null;
|
const homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null;
|
||||||
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
|
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
|
||||||
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
|
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
|
||||||
|
|
@ -648,7 +598,7 @@ function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: st
|
||||||
priority,
|
priority,
|
||||||
targetIntent: meta.targetIntent,
|
targetIntent: meta.targetIntent,
|
||||||
intentGroups: [groupId],
|
intentGroups: [groupId],
|
||||||
keywords: dedupeStrings([...matchedTerms, ...(matchedTerms.length >= 4 ? [] : topTerms.slice(0, 4))]).slice(0, 12),
|
keywords: dedupeStrings([...matchedTerms, ...topTerms.slice(0, 4)]).slice(0, 12),
|
||||||
queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms),
|
queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms),
|
||||||
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
|
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,7 @@ import {
|
||||||
saveSeoContextReviewManual,
|
saveSeoContextReviewManual,
|
||||||
type SeoContextReviewOutput
|
type SeoContextReviewOutput
|
||||||
} from "../contextReview/seoContextReview.js";
|
} from "../contextReview/seoContextReview.js";
|
||||||
import {
|
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js";
|
||||||
getMarketEnrichmentContract,
|
|
||||||
getWordstatProbePlanPreview,
|
|
||||||
runMarketEnrichment
|
|
||||||
} from "../market/marketEnrichment.js";
|
|
||||||
import { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
|
import { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
|
||||||
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -94,17 +90,6 @@ import {
|
||||||
saveKeywordCleaningManual,
|
saveKeywordCleaningManual,
|
||||||
type KeywordCleaningContract
|
type KeywordCleaningContract
|
||||||
} from "../keywords/keywordCleaning.js";
|
} from "../keywords/keywordCleaning.js";
|
||||||
import {
|
|
||||||
getLatestKeywordAnalysisWorkflow,
|
|
||||||
startKeywordAnalysisWorkflow
|
|
||||||
} from "../keywords/keywordAnalysisWorkflow.js";
|
|
||||||
import {
|
|
||||||
deleteKeywordContextSnapshot,
|
|
||||||
listKeywordContextSnapshots,
|
|
||||||
renameKeywordContextSnapshot,
|
|
||||||
saveKeywordContextSnapshot,
|
|
||||||
updateKeywordContextSnapshotCuration
|
|
||||||
} from "../keywords/keywordContextSnapshots.js";
|
|
||||||
import {
|
import {
|
||||||
addAnchorReviewManualAnchor,
|
addAnchorReviewManualAnchor,
|
||||||
getAnchorReviewContract,
|
getAnchorReviewContract,
|
||||||
|
|
@ -250,43 +235,8 @@ const keywordMapApproveSchema = z.object({
|
||||||
role: z.enum(["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"])
|
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()
|
.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({
|
const contextReviewManualSchema = z.object({
|
||||||
output: z.record(z.string(), z.unknown())
|
output: z.record(z.string(), z.unknown())
|
||||||
});
|
});
|
||||||
|
|
@ -301,12 +251,10 @@ const anchorReviewDecisionSchema = z.object({
|
||||||
reason: z.string().trim().max(1000).optional(),
|
reason: z.string().trim().max(1000).optional(),
|
||||||
sendToSerp: z.boolean().optional(),
|
sendToSerp: z.boolean().optional(),
|
||||||
sendToWordstat: z.boolean().optional(),
|
sendToWordstat: z.boolean().optional(),
|
||||||
status: z.enum(["approved", "deleted", "disabled", "pending"])
|
status: z.enum(["approved", "disabled", "pending"])
|
||||||
});
|
});
|
||||||
const demandCollectionProfileSchema = z.enum(["contextual", "market_wide"]);
|
|
||||||
const anchorReviewDecisionsSchema = z.object({
|
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({
|
const anchorReviewManualAnchorSchema = z.object({
|
||||||
phrase: z.string().trim().min(1).max(180),
|
phrase: z.string().trim().min(1).max(180),
|
||||||
|
|
@ -820,24 +768,6 @@ 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) => {
|
projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||||
|
|
@ -899,7 +829,7 @@ projectsRouter.post("/:projectId/anchor-review/decisions", async (request, respo
|
||||||
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
|
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
|
||||||
|
|
||||||
response.status(201).json({
|
response.status(201).json({
|
||||||
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions, input.demandCollectionProfile)
|
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? [])
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|
@ -949,42 +879,6 @@ 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) => {
|
projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||||
|
|
@ -1086,101 +980,6 @@ 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) => {
|
projectsRouter.get("/:projectId/strategy/latest", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,8 @@ function getNumericConstant(source: string, name: string) {
|
||||||
|
|
||||||
const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8");
|
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 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 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 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 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 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");
|
const rewritePlanSource = await readFile(new URL("../rewrite/rewritePlan.ts", import.meta.url), "utf8");
|
||||||
|
|
@ -31,108 +24,6 @@ assert.ok(
|
||||||
wordstatRepositorySource.includes("WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT"),
|
wordstatRepositorySource.includes("WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT"),
|
||||||
"Wordstat top results must keep per-source balancing, not only global frequency sorting."
|
"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(
|
assert.ok(
|
||||||
(getNumericConstant(yandexEvidenceSource, "DEFAULT_PHRASE_LIMIT") ?? 0) >= 18,
|
(getNumericConstant(yandexEvidenceSource, "DEFAULT_PHRASE_LIMIT") ?? 0) >= 18,
|
||||||
"Yandex Evidence default phrase limit must not fall back to toy 5-phrase coverage."
|
"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",
|
label: "Max seeds per run",
|
||||||
type: "number",
|
type: "number",
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: "12",
|
placeholder: "40",
|
||||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -422,7 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
||||||
apiBaseUrl: WORDSTAT_BASE_URL,
|
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||||
devices: "DEVICE_ALL",
|
devices: "DEVICE_ALL",
|
||||||
numPhrases: "50",
|
numPhrases: "50",
|
||||||
maxSeedsPerRun: "12",
|
maxSeedsPerRun: "40",
|
||||||
regionIds: "225"
|
regionIds: "225"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -772,7 +772,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
provider: "mcp_kv",
|
provider: "mcp_kv",
|
||||||
mode: "mcp_kv",
|
mode: "mcp_kv",
|
||||||
mcpEndpoint: endpoint,
|
mcpEndpoint: endpoint,
|
||||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
|
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -793,7 +793,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
||||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
|
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,46 +118,6 @@ export type SeoContextReviewModelTaskContract = {
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
matchedClusterIds: string[];
|
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<{
|
semanticSignals: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -264,13 +224,12 @@ const SEO_SKILL_MANIFESTS: SeoSkillManifest[] = [
|
||||||
summary:
|
summary:
|
||||||
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
|
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
|
||||||
owner: "seo_mode",
|
owner: "seo_mode",
|
||||||
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence", "site_text_evidence"],
|
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence"],
|
||||||
requiredEvidence: ["clean_visible_text_chunks", "semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
requiredEvidence: ["semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
||||||
deterministicChecks: [
|
deterministicChecks: [
|
||||||
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
|
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
|
||||||
{ id: "scope_ready", title: "Scope summary 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: [
|
modelTasks: [
|
||||||
{
|
{
|
||||||
|
|
@ -589,37 +548,10 @@ function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTa
|
||||||
.slice(0, 60);
|
.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(
|
export function buildSeoContextReviewTask(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
analysis: SemanticAnalysisRun
|
analysis: SemanticAnalysisRun
|
||||||
): SeoContextReviewModelTaskContract {
|
): SeoContextReviewModelTaskContract {
|
||||||
const siteTextEvidence = getTextCorpusEvidence(analysis);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskType: "seo.context_review",
|
taskType: "seo.context_review",
|
||||||
schemaVersion: "seo-context-review-task.v1",
|
schemaVersion: "seo-context-review-task.v1",
|
||||||
|
|
@ -665,7 +597,6 @@ export function buildSeoContextReviewTask(
|
||||||
wordCount: source.wordCount,
|
wordCount: source.wordCount,
|
||||||
matchedClusterIds: source.matchedClusters
|
matchedClusterIds: source.matchedClusters
|
||||||
})),
|
})),
|
||||||
siteTextEvidence,
|
|
||||||
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
|
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
|
||||||
evidenceLevel: cluster.evidence.level,
|
evidenceLevel: cluster.evidence.level,
|
||||||
id: cluster.id,
|
id: cluster.id,
|
||||||
|
|
@ -726,7 +657,7 @@ export function buildSeoContextReviewTask(
|
||||||
requireHumanReviewWhenAmbiguous: true
|
requireHumanReviewWhenAmbiguous: true
|
||||||
},
|
},
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Недостаточно clean text evidence или evidence refs для определения реального оффера сайта.",
|
"Недостаточно evidence refs для определения реального оффера сайта.",
|
||||||
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
|
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
|
||||||
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
|
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue