From f9b240dbd18dd15a2a9542462e9922d51752a22f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 2 Jul 2026 13:48:13 +0300 Subject: [PATCH] feat: add SEO rewrite workspace contracts --- seo_mode/seo_mode/app/src/api.ts | 562 +++- seo_mode/seo_mode/package.json | 3 + .../server/migrations/010_semantic_blocks.sql | 36 + seo_mode/seo_mode/server/package.json | 3 + .../server/src/analysis/semanticAnalysis.ts | 64 +- .../server/src/evidence/yandexEvidence.ts | 157 +- .../server/src/market/wordstatRepository.ts | 97 +- .../server/src/patches/patchArtifact.ts | 1403 +++++++++ .../seo_mode/server/src/preview/routes.ts | 2786 ++++++++++++++++- .../server/src/preview/semanticBlockRules.ts | 163 + .../seo_mode/server/src/projects/routes.ts | 255 +- .../server/src/rewrite/rewriteDiff.ts | 357 ++- .../src/rewrite/visualComposerVariants.ts | 825 +++++ .../server/src/scans/projectScanner.ts | 720 ++++- .../server/src/scripts/checkDemandCoverage.ts | 45 + .../scripts/checkSemanticBlockUniversality.ts | 174 + .../scripts/checkSourceModelUniversality.ts | 116 + .../src/workspace/contentFieldWorkspace.ts | 294 ++ .../server/src/workspace/pageWorkspace.ts | 291 +- .../server/src/workspace/previewTargets.ts | 28 +- .../server/src/workspace/semanticBlocks.ts | 317 ++ 21 files changed, 8380 insertions(+), 316 deletions(-) create mode 100644 seo_mode/seo_mode/server/migrations/010_semantic_blocks.sql create mode 100644 seo_mode/seo_mode/server/src/patches/patchArtifact.ts create mode 100644 seo_mode/seo_mode/server/src/preview/semanticBlockRules.ts create mode 100644 seo_mode/seo_mode/server/src/rewrite/visualComposerVariants.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkSemanticBlockUniversality.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkSourceModelUniversality.ts create mode 100644 seo_mode/seo_mode/server/src/workspace/contentFieldWorkspace.ts create mode 100644 seo_mode/seo_mode/server/src/workspace/semanticBlocks.ts diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index ac260b5..54f2f9c 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -91,7 +91,7 @@ export type ProjectScanSummary = { scopeReason: string; previewSourcePath: string | null; previewTargetSelector: string | null; - previewKind: "page" | "home_section" | "raw_template" | "none"; + previewKind: "page" | "source_section" | "raw_template" | "none"; structureLabel: string | null; structureOrder: number | null; visibleInSite: boolean | null; @@ -103,6 +103,7 @@ export type ProjectScanSummary = { pageId: string | null; selected: boolean; title: string; + contentSourcePath: string | null; sourcePath: string; template: string; enabled: boolean; @@ -112,8 +113,27 @@ export type ProjectScanSummary = { previewSourcePath: string | null; previewTargetSelector: string | null; previewTargetIndex: number | null; - previewKind: "page" | "home_section" | "raw_template" | "none"; + previewKind: "page" | "source_section" | "raw_template" | "none"; scopeReason: string; + semanticBlock: { + confidence: number; + fieldCount: number; + id: string; + reasons: string[]; + source: "source_object" | "visual_section"; + title: string; + } | null; + editableFields: Array<{ + group: string | null; + id: string; + kind: string; + label: string; + order: number; + path: string; + renderAs: string | null; + role: string; + value: string; + }>; headings: Array<{ level: number; text: string }>; }>; assets: Array<{ @@ -1777,11 +1797,87 @@ export type RewriteDiffContract = { blockerCount: number; materializationTargetCount: number; modelReadySlotCount: number; + semanticBlockCount: number; + semanticGroupedSlotCount: number; + semanticRewriteGroupCount: number; sourcePendingSlotCount: number; + slotLevelFallbackCount: number; slotCount: number; targetCount: number; }; blockers: string[]; + semanticRewriteGroups: Array<{ + id: string; + pageId: string; + pageTitle: string | null; + sourcePath: string | null; + urlPath: string | null; + title: string; + status: "semantic_block" | "slot_level_fallback"; + fallbackReason: "no_semantic_block" | null; + semanticBlock: { + id: string; + blockKey: string; + title: string; + role: string; + source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section"; + workspaceKey: string; + scopeId: string; + anchors: unknown[]; + capturedEntities: unknown[]; + mediaContext: Record; + targetIds: string[]; + sourceBindings: unknown[]; + } | null; + sourceBindings: unknown[]; + sourceGraph: { + contentFieldCount: number; + mediaTargetIds: string[]; + permissionLabels: string[]; + selectorCount: number; + sourceBindingCount: number; + sourceTypes: string[]; + contentFields: Array<{ + fieldPath: string | null; + group: string | null; + kind: string | null; + renderAs: string | null; + role: string | null; + sectionId: string | null; + sourcePath: string | null; + title: string | null; + }>; + workspaceTargets: Array<{ + fallbackSelector: string | null; + kind: string | null; + permission: string | null; + sectionId: string | null; + selector: string | null; + source: string | null; + targetId: string | null; + title: string | null; + }>; + }; + groupText: string; + keywordBindings: Array<{ + decisionId: string; + phrase: string; + role: string; + frequency: number | null; + frequencyGroup: string | null; + exactLimit: number | null; + }>; + slots: Array<{ + blockers: string[]; + currentValue: string | null; + field: "body_section" | "h1" | "meta_description" | "title"; + instruction: string; + rewriteSlotId: string; + source: "missing_source" | "page_version" | "workspace_snapshot"; + status: "ready_for_model_review" | "source_pending"; + workspaceSectionId: string | null; + }>; + }>; targets: Array<{ id: string; pageId: string; @@ -1807,6 +1903,20 @@ export type RewriteDiffContract = { frequencyGroup: string | null; exactLimit: number | null; }>; + semanticBlock: { + id: string; + blockKey: string; + title: string; + role: string; + source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section"; + workspaceKey: string; + scopeId: string; + anchors: unknown[]; + capturedEntities: unknown[]; + mediaContext: Record; + targetIds: string[]; + sourceBindings: unknown[]; + } | null; }>; }>; nextActions: string[]; @@ -1927,14 +2037,316 @@ export type PageWorkspace = { kind: "meta" | "content_block" | "heading_group" | "media"; title: string; selector: string | null; + anchor: { + entityType: "document_meta" | "dom_section" | "heading_group" | "media_container"; + fingerprint: { + selector: string | null; + sourceHint: string; + text: string; + }; + selector: string | null; + sourceHint: string; + }; originalText: string; workingText: string; charCount: number; + semanticGroup: { + confidence: number; + evidence: string[]; + fieldCount: number; + fieldOrder: number; + fieldRole: string; + id: string; + source: "workspace_section"; + title: string; + } | null; summary: string; sourceHint: string; }>; }; +export type ContentFieldDraft = { + draftId: string; + draftItemId: string; + fieldKey: string; + fieldPath: string; + originalSnapshotKey: string | null; + originalText: string; + pageId: string; + scopeId: string; + sectionId: string; + sourcePath: string; + title: string; + updatedAt: string; + workingSnapshotKey: string | null; + workingText: string; +}; + +export type SemanticBlockModel = { + id: string; + projectId: string; + scanVersionId: string | null; + pageId: string; + workspaceKey: string; + scopeId: string; + blockKey: string; + title: string; + role: string; + source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section"; + identity: Record; + anchors: unknown[]; + capturedEntities: unknown[]; + sourceBindings: unknown[]; + mediaContext: Record; + geometryCache: Record; + rewriteState: Record; + targetIds: string[]; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type SemanticBlockSaveInput = { + scanVersionId?: string | null; + workspaceKey: string; + scopeId: string; + blocks: Array<{ + anchors?: unknown[]; + blockKey: string; + capturedEntities?: unknown[]; + geometryCache?: Record; + identity?: Record; + mediaContext?: Record; + rewriteState?: Record; + role?: string; + source?: SemanticBlockModel["source"]; + sourceBindings?: unknown[]; + targetIds?: string[]; + title: string; + }>; +}; + +export type PatchArtifactContract = { + schemaVersion: "seo-patch-artifact.v1"; + artifactKey: string; + changesetId: string; + generatedAt: string; + mode: "manifest_only"; + projectId: string; + state: "empty" | "ready_for_dry_run"; + readiness: { + affectedFileCount: number; + blockerCount: number; + changeCount: number; + changedDraftCount: number; + draftCount: number; + provenanceBlockerCount?: number; + sourceFingerprintCount?: number; + sourceUnreadableCount?: number; + }; + provenance?: { + schemaVersion: "seo-patch-provenance.v1"; + generatedAt: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + projectOntology: { + approvalStatus: string; + brandName: string; + domainPackage: { + id: string; + version: string; + coreOntologyVersion: string; + projectOntologySchema: string; + packagePath: string; + }; + id: string; + reviewProblemCount: number; + schemaVersion: string; + source: string; + version: number; + } | null; + rewritePlan: { + generatedAt: string; + readiness: RewritePlanContract["readiness"]; + state: RewritePlanContract["state"]; + } | null; + rewriteDiff: { + generatedAt: string; + readiness: RewriteDiffContract["readiness"]; + state: RewriteDiffContract["state"]; + } | null; + sourceRefs: { + draftIds: string[]; + draftItemIds: string[]; + scanVersionIds: string[]; + sourcePaths: string[]; + }; + assistantPolicy: { + mayProposeOntologyDelta: true; + mayMutateApprovedOntology: false; + }; + gates: { + productionPatchRun: { + blockers: string[]; + ready: boolean; + }; + visualComposer: { + blockers: string[]; + ready: boolean; + }; + }; + blockers: string[]; + warnings: string[]; + }; + sourceFingerprints?: Array<{ + byteLength: number | null; + capturedAt: string; + readable: boolean; + sha256: string | null; + sourcePath: string; + }>; + sourceSafety: { + editorWritesSourceFiles: false; + productionRequiresExplicitPatchRun: true; + sourceTreeReadOnly: true; + }; + storage: { + objectKey: string; + }; + changes: Array<{ + id: string; + afterLength: number; + afterValue: string; + beforeLength: number; + beforeValue: string; + changeType: "content_field" | "text_section"; + delta: number; + draftId: string; + draftItemId: string; + kind: string; + pageId: string; + selector: string | null; + sourceHint: string; + sourcePath: string; + title: string; + urlPath: string | null; + workspaceSectionId: string; + }>; + nextActions: string[]; +}; + +export type PatchDryRunContract = { + schemaVersion: "seo-patch-dry-run.v1"; + artifactKey: string; + changesetId: string; + generatedAt: string; + projectId: string; + state: "blocked" | "empty" | "passed"; + readiness: { + affectedFileCount: number; + blockerCount: number; + checkCount: number; + emptyAfterCount: number; + fingerprintMismatchCount?: number; + fingerprintMissingCount?: number; + missingSourceCount: number; + passedCount: number; + provenanceBlockerCount?: number; + staleChangeCount: number; + }; + provenance?: NonNullable; + sourceSafety: { + dryRunWritesSourceFiles: false; + patchRunExecuted: false; + sourceTreeReadOnly: true; + }; + storage: { + objectKey: string; + }; + checks: Array<{ + id: string; + afterNonEmpty: boolean; + beforeFound: boolean; + blockers: string[]; + changeId: string; + fingerprint?: { + currentSha256: string | null; + expectedSha256: string | null; + status: "matched" | "mismatch" | "missing_at_build" | "missing_at_dry_run"; + }; + fingerprintMatched?: boolean; + sourceMatch?: { + coverage: number; + matchedTokenCount: number; + strategy: "exact_raw" | "exact_visible" | "page_token_coverage" | "selector_token_coverage" | "none"; + totalTokenCount: number; + }; + sourceReadable?: boolean; + sourcePath: string; + status: "blocked" | "passed"; + title: string; + workspaceSectionId: string; + }>; + nextActions: string[]; +}; + +export type VisualComposerVariantsContract = { + schemaVersion: "visual-composer-variants.v1"; + generatedAt: string; + projectId: string; + state: "blocked" | "partial_ready" | "ready"; + generator: { + id: "local_deterministic_v0"; + modelBacked: false; + notes: string[]; + }; + readiness: { + blockerCount: number; + generatedSlotCount: number; + readySlotCount: number; + semanticBlockCount: number; + semanticGroupedSlotCount: number; + slotCount: number; + slotLevelFallbackCount: number; + targetCount: number; + variantCount: number; + }; + blockers: string[]; + variants: Array<{ + id: "balanced" | "commercial" | "concise" | "expert" | "seo_dense"; + label: string; + mode: "balanced" | "commercial" | "concise" | "expert" | "seo_dense"; + summary: string; + metrics: { + changedSlotCount: number; + includedPhraseCount: number; + totalDelta: number; + totalPhraseCount: number; + }; + slots: Array<{ + id: string; + afterText: string; + beforeText: string; + field: RewriteDiffContract["targets"][number]["slots"][number]["field"]; + includedPhraseCount: number; + instruction: string; + keywordPhrases: string[]; + pageContextText: string | null; + pageId: string; + pageTitle: string | null; + riskNotes: string[]; + rewriteSlotId: string; + semanticGroup: VisualComposerSeedInput["semanticGroup"] | null; + sourcePath: string | null; + title: string; + totalPhraseCount: number; + urlPath: string | null; + workspaceSectionId: string; + }>; + }>; + nextActions: string[]; +}; + export type CreateProjectInput = { name: string; sourcePath: string; @@ -2358,7 +2770,7 @@ export async function fetchLatestYandexEvidence(projectId: string) { return response.json() as Promise<{ yandexEvidence: YandexEvidenceContract }>; } -export async function runYandexEvidence(projectId: string, phraseLimit = 5) { +export async function runYandexEvidence(projectId: string, phraseLimit = 18) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence`, { body: JSON.stringify({ phraseLimit }), headers: { @@ -2601,7 +3013,11 @@ export async function openPageWorkspace(projectId: string, pageId: string) { throw new Error(await getErrorMessage(response, `Не удалось открыть рабочую зону: ${response.status}`)); } - return response.json() as Promise<{ workspace: PageWorkspace }>; + return response.json() as Promise<{ + contentDrafts?: ContentFieldDraft[]; + semanticBlocks?: SemanticBlockModel[]; + workspace: PageWorkspace; + }>; } export async function savePageWorkspace(projectId: string, pageId: string, workingText: string) { @@ -2620,6 +3036,54 @@ export async function savePageWorkspace(projectId: string, pageId: string, worki return response.json() as Promise<{ workspace: PageWorkspace }>; } +export async function saveContentFieldDraft( + projectId: string, + pageId: string, + input: { + fieldId: string | null; + fieldPath: string; + group: string | null; + kind: string; + originalText: string; + renderAs: string | null; + scopeId: string; + sectionId: string; + sourcePath: string; + title: string; + workingText: string; + } +) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/content-fields`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось сохранить content field draft: ${response.status}`)); + } + + return response.json() as Promise<{ contentDraft: ContentFieldDraft }>; +} + +export async function savePageSemanticBlocks(projectId: string, pageId: string, input: SemanticBlockSaveInput) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/semantic-blocks`, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось сохранить semantic blocks: ${response.status}`)); + } + + return response.json() as Promise<{ semanticBlocks: SemanticBlockModel[] }>; +} + export async function resetPageWorkspace(projectId: string, pageId: string) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/workspace/reset`, { method: "POST" @@ -2632,10 +3096,92 @@ export async function resetPageWorkspace(projectId: string, pageId: string) { return response.json() as Promise<{ workspace: PageWorkspace }>; } -export function getPreviewPageUrl(projectId: string, sourcePath: string, focusSelector?: string | null, focusIndex?: number | null) { +export async function fetchLatestPatchArtifact(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/latest`); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось загрузить patch artifact: ${response.status}`)); + } + + return response.json() as Promise<{ patchArtifact: PatchArtifactContract | null }>; +} + +export async function buildPatchArtifact(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/build`, { + method: "POST" + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось собрать patch artifact: ${response.status}`)); + } + + return response.json() as Promise<{ patchArtifact: PatchArtifactContract }>; +} + +export async function runPatchDryRun(projectId: string, changesetId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/${changesetId}/dry-run`, { + method: "POST" + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось выполнить dry-run: ${response.status}`)); + } + + return response.json() as Promise<{ dryRun: PatchDryRunContract }>; +} + +export type VisualComposerSeedInput = { + currentValue: string; + field?: "body_section" | "h1" | "meta_description" | "title"; + id: string; + instruction?: string; + keywordPhrases?: string[]; + pageContextText?: string | null; + pageId: string; + pageTitle?: string | null; + semanticGroup?: { + confidence: number; + evidence: string[]; + fieldCount: number; + fieldOrder: number; + fieldRole: string; + groupText: string; + id: string; + source: "source_object" | "visual_section" | "workspace_section"; + title: string; + }; + sourcePath?: string | null; + title: string; + urlPath?: string | null; + workspaceSectionId: string; +}; + +export async function generateVisualComposerVariants(projectId: string, seeds: VisualComposerSeedInput[] = []) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/visual-composer/variants`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ seeds }) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось собрать visual composer variants: ${response.status}`)); + } + + return response.json() as Promise<{ visualComposerVariants: VisualComposerVariantsContract }>; +} + +export function getPreviewPageUrl( + projectId: string, + sourcePath: string, + focusSelector?: string | null, + focusIndex?: number | null, + focusOrder?: number | null +) { const baseUrl = `${apiBaseUrl}/preview/projects/${encodeURIComponent(projectId)}/site/${encodePathname(sourcePath)}`; - if (!focusSelector && focusIndex == null) { + if (!focusSelector && focusIndex == null && focusOrder == null) { return baseUrl; } @@ -2649,6 +3195,10 @@ export function getPreviewPageUrl(projectId: string, sourcePath: string, focusSe params.set("focusIndex", String(focusIndex)); } + if (focusOrder != null) { + params.set("focusOrder", String(focusOrder)); + } + return `${baseUrl}?${params.toString()}`; } diff --git a/seo_mode/seo_mode/package.json b/seo_mode/seo_mode/package.json index 17087f5..f17b74f 100644 --- a/seo_mode/seo_mode/package.json +++ b/seo_mode/seo_mode/package.json @@ -12,6 +12,9 @@ "dev:app": "npm run dev -w app", "dev:server": "npm run dev -w server", "build": "npm run build -w server && npm run build -w app", + "check:demand-coverage": "npm run check:demand-coverage -w server", + "check:semantic-blocks": "npm run check:semantic-blocks -w server", + "check:source-models": "npm run check:source-models -w server", "typecheck": "npm run typecheck -w server && npm run typecheck -w app", "db:migrate": "npm run db:migrate -w server" }, diff --git a/seo_mode/seo_mode/server/migrations/010_semantic_blocks.sql b/seo_mode/seo_mode/server/migrations/010_semantic_blocks.sql new file mode 100644 index 0000000..1afbd49 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/010_semantic_blocks.sql @@ -0,0 +1,36 @@ +create table if not exists semantic_blocks ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + scan_version_id uuid references scan_versions(id) on delete set null, + page_id uuid not null references pages(id) on delete cascade, + workspace_key text not null, + scope_id text not null, + block_key text not null, + title text not null, + role text not null default 'content', + source text not null default 'manual', + identity jsonb not null default '{}'::jsonb, + anchors jsonb not null default '[]'::jsonb, + captured_entities jsonb not null default '[]'::jsonb, + source_bindings jsonb not null default '[]'::jsonb, + media_context jsonb not null default '{}'::jsonb, + geometry_cache jsonb not null default '{}'::jsonb, + rewrite_state jsonb not null default '{}'::jsonb, + target_ids text[] not null default '{}'::text[], + archived_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(project_id, page_id, workspace_key, scope_id, block_key), + constraint semantic_blocks_source_check check (source in ('auto', 'manual', 'source_object', 'visual_section', 'workspace_section')), + constraint semantic_blocks_role_check check (role <> '') +); + +create index if not exists semantic_blocks_project_page_idx +on semantic_blocks(project_id, page_id, workspace_key, scope_id, updated_at desc); + +create index if not exists semantic_blocks_project_scan_idx +on semantic_blocks(project_id, scan_version_id, updated_at desc); + +create index if not exists semantic_blocks_active_idx +on semantic_blocks(project_id, page_id, workspace_key, scope_id) +where archived_at is null; diff --git a/seo_mode/seo_mode/server/package.json b/seo_mode/seo_mode/server/package.json index 3bf8057..d7cb240 100644 --- a/seo_mode/seo_mode/server/package.json +++ b/seo_mode/seo_mode/server/package.json @@ -8,6 +8,9 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit -p tsconfig.json", + "check:demand-coverage": "tsx src/scripts/checkDemandCoverage.ts", + "check:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts", + "check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts", "check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts", "db:migrate": "tsx src/scripts/migrate.ts" }, diff --git a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts index a8e3f4d..cbdf761 100644 --- a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts +++ b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts @@ -923,7 +923,7 @@ async function buildPageDocument(source: LatestScanRow, page: PageRow): Promise< selected: page.selected, scope: pageScope.scope, scopeReason: pageScope.reason, - usedForCoverage: page.selected && pageScope.scope === "indexable_page", + usedForCoverage: page.selected && (pageScope.scope === "indexable_page" || pageScope.scope === "template_block"), title, sourcePath: page.source_path, urlPath: page.url_path, @@ -1393,12 +1393,13 @@ function pickHighestSemanticPriority(priorities: SemanticClusterDefinition["prio function buildLandingOpportunities(clusters: ReturnType, documents: ContentDocument[]) { const indexableDocuments = documents.filter((document) => document.scope === "indexable_page"); + const portfolioDocuments = documents.filter((document) => document.scope === "indexable_page" || document.scope === "template_block"); return clusters.map((cluster) => { const currentLandingPages = Array.from( new Set( cluster.sourceRefs - .filter((ref) => ref.scope === "indexable_page") + .filter((ref) => ref.scope === "indexable_page" || ref.scope === "template_block") .map((ref) => normalizeUrlLikePath(ref.urlPath ?? ref.sourcePath)) ) ).sort((first, second) => first.localeCompare(second, "ru-RU")); @@ -1409,46 +1410,44 @@ function buildLandingOpportunities(clusters: ReturnType, const currentNonTargetPages = currentLandingPages.filter( (landingPage) => !cluster.targetPages.some((targetPage) => normalizeUrlLikePath(targetPage) === landingPage) ); - const primaryTargetPage = missingTargetPages[0] ?? existingTargetPages[0] ?? cluster.targetPages[0] ?? "/"; + const actualPortfolioPages = currentLandingPages.filter((landingPage) => + portfolioDocuments.some((document) => normalizeUrlLikePath(document.urlPath ?? document.sourcePath) === landingPage) + ); + const existingOrCurrentPages = existingTargetPages.length > 0 ? existingTargetPages : actualPortfolioPages; + const primaryTargetPage = existingOrCurrentPages[0] ?? currentLandingPages[0] ?? existingTargetPages[0] ?? "/"; const pageType = getLandingOpportunityPageType(primaryTargetPage); const action: SemanticAnalysisOutput["landingOpportunities"][number]["action"] = - cluster.status === "covered" && missingTargetPages.length === 0 + existingOrCurrentPages.length === 0 + ? "validate_external" + : cluster.status === "covered" && missingTargetPages.length === 0 ? "validate_external" : cluster.status === "covered" ? "keep_home" - : missingTargetPages.length > 0 - ? "create_target_page" - : "strengthen_existing"; + : "strengthen_existing"; const recommendedPages = - action === "create_target_page" || action === "keep_home" - ? missingTargetPages.slice(0, 4).map((targetPage) => ({ - path: targetPage, - pageType: getLandingOpportunityPageType(targetPage), - action: "create" as const, - reason: - currentNonTargetPages.length > 0 - ? `Сейчас кластер найден на ${currentNonTargetPages.slice(0, 3).join(", ")}, но целевой страницы ${targetPage} нет.` - : `Целевая страница ${targetPage} отсутствует в indexable scope.` - })) - : existingTargetPages.slice(0, 4).map((targetPage) => ({ + existingOrCurrentPages.length > 0 + ? existingOrCurrentPages.slice(0, 4).map((targetPage) => ({ path: targetPage, pageType: getLandingOpportunityPageType(targetPage), action: (cluster.status === "covered" ? "validate" : "strengthen") as "strengthen" | "validate", reason: - cluster.status === "covered" - ? "Страница уже есть и кластер подтверждён; следующий шаг — Wordstat/SERP validation." - : `Страница есть, но evidence ${cluster.evidence.level}; нужно усилить текст, интент и коммерческие блоки.` - })); + currentNonTargetPages.length > 0 + ? `Сейчас кластер найден на ${currentNonTargetPages.slice(0, 3).join(", ")}; усиливаем реальный выбранный scope ${targetPage}.` + : cluster.status === "covered" + ? "Страница или блок уже есть и кластер подтверждён; следующий шаг — Wordstat/SERP validation." + : `Страница или блок есть в выбранном scope, но evidence ${cluster.evidence.level}; нужно усилить текст, интент и коммерческие блоки.` + })) + : []; const reason = - action === "validate_external" - ? "Кластер уже имеет сильную локальную evidence. Дальше не расширять вслепую, а проверять спрос и конкурентов." + existingOrCurrentPages.length === 0 + ? "Кластер остаётся темой для проверки, но deterministic analysis не создаёт страницу из ontology targetPages без реального project scope." + : action === "validate_external" + ? "Кластер уже имеет сильную локальную evidence. Дальше не расширять вслепую, а проверять спрос и конкурентов." : action === "keep_home" ? `Кластер можно оставить на главной как общий сигнал, но для SEO-охвата полезно вынести часть интента в ${missingTargetPages .slice(0, 3) .join(", ")}.` - : action === "create_target_page" - ? `Кластер словарно найден, но не имеет целевой посадочной из карты: ${missingTargetPages.slice(0, 3).join(", ")}.` - : `Кластер имеет целевую страницу, но evidence ${cluster.evidence.level}; нужна доработка существующей посадочной.`; + : `Кластер имеет целевую страницу или выбранный блок, но evidence ${cluster.evidence.level}; нужна доработка существующего scope.`; return { clusterId: cluster.id, @@ -1499,16 +1498,7 @@ function buildLandingPagePlan( pageType: page.pageType, action: page.action })); - const fallbackPageActions = - existingPageActions.length === 0 && recommendedPageActions.length === 0 - ? opportunity.targetPages.slice(0, 1).map((targetPage) => ({ - path: targetPage, - pageType: getLandingOpportunityPageType(targetPage), - action: "create" as const - })) - : []; - - for (const page of [...existingPageActions, ...recommendedPageActions, ...fallbackPageActions]) { + for (const page of [...existingPageActions, ...recommendedPageActions]) { const normalizedPath = normalizeUrlLikePath(page.path); const plan = planMap.get(normalizedPath) ?? diff --git a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts index 836a2d8..e68ec01 100644 --- a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts +++ b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts @@ -25,6 +25,12 @@ type EvidencePhraseInput = { reason: string; }; +type EvidencePhraseCandidate = EvidencePhraseInput & { + bucketKey: string; + order: number; + score: number; +}; + export type YandexEvidenceContract = { schemaVersion: "yandex-evidence.v1"; projectId: string; @@ -110,12 +116,32 @@ export type RunYandexEvidenceInput = { }; const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search"; -const DEFAULT_PHRASE_LIMIT = 5; +const DEFAULT_PHRASE_LIMIT = 18; +const MAX_PHRASE_LIMIT = 48; function normalizePhrase(value: string) { return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); } +function getPriorityScore(priority: YandexEvidencePriority) { + return priority === "high" ? 300 : priority === "medium" ? 200 : 100; +} + +function getKeywordMapRoleScore(role: string | null | undefined) { + if (role === "primary") return 700; + if (role === "secondary" || role === "differentiator") return 600; + if (role === "support") return 500; + return 350; +} + +function getCleaningDecisionScore(decision: KeywordCleaningContract["items"][number]["decision"]) { + if (decision === "use") return 500; + if (decision === "support") return 430; + if (decision === "article") return 240; + if (decision === "risky") return 120; + return 0; +} + function maskFolderId(folderId: string) { return folderId.length > 8 ? `${folderId.slice(0, 4)}...${folderId.slice(-4)}` : "configured"; } @@ -298,53 +324,120 @@ function selectPhrases(input: { market: MarketEnrichmentContract; phraseLimit: number; }) { - const selected = new Map(); + const candidates: EvidencePhraseCandidate[] = []; + const candidatePhraseKeys = new Set(); - function add(item: EvidencePhraseInput) { - const key = normalizePhrase(item.phrase); + function addCandidate(item: EvidencePhraseInput, bucketKey: string, score: number) { + const phraseKey = normalizePhrase(item.phrase); - if (!key || selected.has(key) || selected.size >= input.phraseLimit) { + if (!phraseKey || candidatePhraseKeys.has(phraseKey)) { return; } - selected.set(key, item); - } - - for (const item of input.keywordMap.persisted.items) { - add({ - phrase: item.phrase, - priority: item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low", - reason: "Approved keyword decision from SEO plan.", - source: "keyword_map", - targetPath: item.targetPath + candidatePhraseKeys.add(phraseKey); + candidates.push({ + ...item, + bucketKey: bucketKey || item.targetPath || item.source, + order: candidates.length, + score }); } - for (const item of input.cleaning.items.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))) { + for (const item of input.keywordMap.persisted.items) { + const priority = item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low"; + + addCandidate( + { + phrase: item.phrase, + priority, + reason: "Approved keyword decision from SEO plan.", + source: "keyword_map", + targetPath: item.targetPath + }, + item.targetPath ?? item.relatedLanding ?? "keyword_map", + 10_000 + getKeywordMapRoleScore(item.role) + getPriorityScore(priority) + ); + } + + for (const item of input.cleaning.items) { if (item.decision === "trash") { continue; } - add({ - phrase: item.phrase, - priority: (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low", - reason: "High demand candidate from keyword cleaning.", - source: "top_demand", - targetPath: item.targetPath - }); + const priority = (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low"; + + addCandidate( + { + phrase: item.phrase, + priority, + reason: "High demand candidate from keyword cleaning.", + source: "top_demand", + targetPath: item.targetPath + }, + item.targetPath ?? item.clusterId, + getCleaningDecisionScore(item.decision) + getPriorityScore(priority) + Math.min(item.frequency ?? 0, 10_000) / 20 + ); } for (const item of input.market.seedQueue) { - add({ - phrase: item.phrase, - priority: item.priority, - reason: item.normalization?.reviewReason ?? "Approved market seed.", - source: "market_seed", - targetPath: null - }); + addCandidate( + { + phrase: item.phrase, + priority: item.priority, + reason: item.normalization?.reviewReason ?? "Approved market seed.", + source: "market_seed", + targetPath: null + }, + item.clusterId, + getPriorityScore(item.priority) + (item.frequency ?? 0) / 50 + ); } - return Array.from(selected.values()); + const sortedCandidates = candidates.sort((left, right) => right.score - left.score || left.order - right.order); + const candidatesByBucket = sortedCandidates.reduce>((accumulator, candidate) => { + accumulator.set(candidate.bucketKey, [...(accumulator.get(candidate.bucketKey) ?? []), candidate]); + return accumulator; + }, new Map()); + const bucketKeys = Array.from(candidatesByBucket.keys()).sort((left, right) => { + const leftScore = candidatesByBucket.get(left)?.[0]?.score ?? 0; + const rightScore = candidatesByBucket.get(right)?.[0]?.score ?? 0; + + return rightScore - leftScore || left.localeCompare(right, "ru"); + }); + const selected = new Map(); + + for (let index = 0; selected.size < input.phraseLimit; index += 1) { + let added = false; + + for (const bucketKey of bucketKeys) { + const candidate = candidatesByBucket.get(bucketKey)?.[index]; + + if (!candidate) { + continue; + } + + selected.set(normalizePhrase(candidate.phrase), candidate); + added = true; + + if (selected.size >= input.phraseLimit) { + break; + } + } + + if (!added) { + break; + } + } + + for (const candidate of sortedCandidates) { + if (selected.size >= input.phraseLimit) { + break; + } + + selected.set(normalizePhrase(candidate.phrase), candidate); + } + + return Array.from(selected.values()).map(({ bucketKey, order, score, ...phrase }) => phrase); } async function postJson(input: { @@ -761,7 +854,7 @@ function normalizePhraseLimit(value: number | undefined) { return DEFAULT_PHRASE_LIMIT; } - return Math.max(1, Math.min(12, Math.round(value ?? DEFAULT_PHRASE_LIMIT))); + return Math.max(1, Math.min(MAX_PHRASE_LIMIT, Math.round(value ?? DEFAULT_PHRASE_LIMIT))); } async function createStartedRun(projectId: string, input: Record) { diff --git a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts index ddd730c..d865a7a 100644 --- a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts +++ b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts @@ -116,6 +116,9 @@ function normalizePhrase(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } +const WORDSTAT_TOP_RESULTS_LIMIT = 160; +const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12; + const RELATED_QUERY_STOP_WORDS = new Set([ "a", "an", @@ -299,24 +302,94 @@ function getFrequencyBucket(frequencyGroup: string | null) { return "unknown"; } +function compareWordstatResultRows(left: WordstatResultRow, right: WordstatResultRow) { + return ( + (right.frequency ?? 0) - (left.frequency ?? 0) || + (left.position ?? Number.MAX_SAFE_INTEGER) - (right.position ?? Number.MAX_SAFE_INTEGER) || + left.phrase.localeCompare(right.phrase, "ru") + ); +} + +function getRepresentativeTopResultRows(results: WordstatResultRow[]) { + const topResultByPhrase = new Map(); + + for (const result of results) { + if (result.frequency === null) { + continue; + } + + const normalizedPhrase = result.normalized_phrase ?? normalizePhrase(result.phrase); + const currentResult = topResultByPhrase.get(normalizedPhrase); + + if (!currentResult || compareWordstatResultRows(result, currentResult) < 0) { + topResultByPhrase.set(normalizedPhrase, result); + } + } + + const rows = Array.from(topResultByPhrase.values()).sort(compareWordstatResultRows); + const rowsBySource = rows.reduce>((accumulator, row) => { + const sourceKey = normalizePhrase(row.source_phrase || row.phrase); + accumulator.set(sourceKey, [...(accumulator.get(sourceKey) ?? []), row]); + return accumulator; + }, new Map()); + const sourceKeys = Array.from(rowsBySource.keys()).sort((left, right) => { + const leftFirst = rowsBySource.get(left)?.[0]; + const rightFirst = rowsBySource.get(right)?.[0]; + + if (!leftFirst || !rightFirst) { + return left.localeCompare(right, "ru"); + } + + return compareWordstatResultRows(leftFirst, rightFirst); + }); + const selectedRows = new Map(); + + for (let index = 0; selectedRows.size < WORDSTAT_TOP_RESULTS_LIMIT; index += 1) { + let added = false; + + for (const sourceKey of sourceKeys) { + if (index >= WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT) { + continue; + } + + const row = rowsBySource.get(sourceKey)?.[index]; + + if (!row) { + continue; + } + + selectedRows.set(row.normalized_phrase ?? normalizePhrase(row.phrase), row); + added = true; + + if (selectedRows.size >= WORDSTAT_TOP_RESULTS_LIMIT) { + break; + } + } + + if (!added) { + break; + } + } + + for (const row of rows) { + if (selectedRows.size >= WORDSTAT_TOP_RESULTS_LIMIT) { + break; + } + + selectedRows.set(row.normalized_phrase ?? normalizePhrase(row.phrase), row); + } + + return Array.from(selectedRows.values()).sort(compareWordstatResultRows); +} + function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence { const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase); const relatedCounts = new Map(); - const topResultByPhrase = new Map(); for (const result of results) { if (result.source_phrase && result.source_phrase !== result.phrase) { relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1); } - - if (result.frequency !== null) { - const normalizedPhrase = result.normalized_phrase ?? normalizePhrase(result.phrase); - const currentResult = topResultByPhrase.get(normalizedPhrase); - - if (!currentResult || (result.frequency ?? 0) > (currentResult.frequency ?? 0)) { - topResultByPhrase.set(normalizedPhrase, result); - } - } } return { @@ -332,9 +405,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]) relatedCount: relatedCounts.get(result.phrase) ?? 0, collectedAt: result.created_at.toISOString() })), - topResults: Array.from(topResultByPhrase.values()) - .sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0)) - .slice(0, 20) + topResults: getRepresentativeTopResultRows(results) .map((result) => ({ id: result.id, phrase: result.phrase, diff --git a/seo_mode/seo_mode/server/src/patches/patchArtifact.ts b/seo_mode/seo_mode/server/src/patches/patchArtifact.ts new file mode 100644 index 0000000..effc8e1 --- /dev/null +++ b/seo_mode/seo_mode/server/src/patches/patchArtifact.ts @@ -0,0 +1,1403 @@ +import { pool } from "../db/client.js"; +import { getLatestProjectOntologyVersion, getProjectOntologyVersion } from "../ontology/projectOntologyRepository.js"; +import { getRewriteDiffContract, type RewriteDiffContract } from "../rewrite/rewriteDiff.js"; +import { getRewritePlanContract, type RewritePlanContract } from "../rewrite/rewritePlan.js"; +import { storageProvider } from "../storage/index.js"; +import { readContentFieldSnapshot } from "../workspace/contentFieldWorkspace.js"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type PatchArtifactState = "empty" | "ready_for_dry_run"; +type PatchDryRunState = "blocked" | "empty" | "passed"; +type PatchChangeType = "content_field" | "text_section"; +type PatchSourceFingerprintStatus = "matched" | "mismatch" | "missing_at_build" | "missing_at_dry_run"; +type PatchSourceMatchStrategy = + | "exact_raw" + | "exact_visible" + | "page_token_coverage" + | "selector_token_coverage" + | "none"; + +type ProjectSourceRow = { + source_config: Record; + source_type: "browser_folder" | "git_repo" | "local_folder" | "site_crawl" | "zip_upload"; +}; + +type ActiveDraftRow = { + draft_id: string; + draft_item_id: string; + draft_updated_at: Date; + field: string; + original_snapshot_key: string | null; + page_id: string; + scan_version_id: string | null; + source_path: string | null; + url_path: string | null; + working_text: string | null; +}; + +type WorkspaceSnapshot = { + schemaVersion: "page-workspace.v2"; + originalText: string; + sections?: Array<{ + id: string; + kind: "content_block" | "heading_group" | "media" | "meta" | string; + originalText: string; + selector: string | null; + sourceHint: string; + title: string; + }>; +}; + +export type PatchArtifactChange = { + id: string; + afterLength: number; + afterValue: string; + beforeLength: number; + beforeValue: string; + changeType: PatchChangeType; + delta: number; + draftId: string; + draftItemId: string; + kind: string; + pageId: string; + selector: string | null; + sourceHint: string; + sourcePath: string; + title: string; + urlPath: string | null; + workspaceSectionId: string; +}; + +export type PatchSourceFingerprint = { + byteLength: number | null; + capturedAt: string; + readable: boolean; + sha256: string | null; + sourcePath: string; +}; + +export type PatchProvenanceContract = { + schemaVersion: "seo-patch-provenance.v1"; + generatedAt: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + projectOntology: { + approvalStatus: string; + brandName: string; + domainPackage: { + id: string; + version: string; + coreOntologyVersion: string; + projectOntologySchema: string; + packagePath: string; + }; + id: string; + reviewProblemCount: number; + schemaVersion: string; + source: string; + version: number; + } | null; + rewritePlan: { + generatedAt: string; + readiness: RewritePlanContract["readiness"]; + state: RewritePlanContract["state"]; + } | null; + rewriteDiff: { + generatedAt: string; + readiness: RewriteDiffContract["readiness"]; + state: RewriteDiffContract["state"]; + } | null; + sourceRefs: { + draftIds: string[]; + draftItemIds: string[]; + scanVersionIds: string[]; + sourcePaths: string[]; + }; + assistantPolicy: { + mayProposeOntologyDelta: true; + mayMutateApprovedOntology: false; + }; + gates: { + productionPatchRun: { + blockers: string[]; + ready: boolean; + }; + visualComposer: { + blockers: string[]; + ready: boolean; + }; + }; + blockers: string[]; + warnings: string[]; +}; + +export type PatchArtifactContract = { + schemaVersion: "seo-patch-artifact.v1"; + artifactKey: string; + changes: PatchArtifactChange[]; + changesetId: string; + generatedAt: string; + mode: "manifest_only"; + nextActions: string[]; + projectId: string; + readiness: { + affectedFileCount: number; + blockerCount: number; + changeCount: number; + changedDraftCount: number; + draftCount: number; + provenanceBlockerCount: number; + sourceFingerprintCount: number; + sourceUnreadableCount: number; + }; + provenance: PatchProvenanceContract; + sourceFingerprints: PatchSourceFingerprint[]; + sourceSafety: { + editorWritesSourceFiles: false; + productionRequiresExplicitPatchRun: true; + sourceTreeReadOnly: true; + }; + state: PatchArtifactState; + storage: { + objectKey: string; + }; +}; + +export type PatchDryRunCheck = { + id: string; + afterNonEmpty: boolean; + beforeFound: boolean; + blockers: string[]; + changeId: string; + fingerprint: { + currentSha256: string | null; + expectedSha256: string | null; + status: PatchSourceFingerprintStatus; + }; + fingerprintMatched: boolean; + sourceMatch: { + coverage: number; + matchedTokenCount: number; + strategy: PatchSourceMatchStrategy; + totalTokenCount: number; + }; + sourceReadable: boolean; + sourcePath: string; + status: "blocked" | "passed"; + title: string; + workspaceSectionId: string; +}; + +export type PatchDryRunContract = { + schemaVersion: "seo-patch-dry-run.v1"; + artifactKey: string; + changesetId: string; + checks: PatchDryRunCheck[]; + generatedAt: string; + nextActions: string[]; + projectId: string; + readiness: { + affectedFileCount: number; + blockerCount: number; + checkCount: number; + emptyAfterCount: number; + fingerprintMismatchCount: number; + fingerprintMissingCount: number; + missingSourceCount: number; + passedCount: number; + provenanceBlockerCount: number; + staleChangeCount: number; + }; + provenance: PatchProvenanceContract; + sourceSafety: { + dryRunWritesSourceFiles: false; + patchRunExecuted: false; + sourceTreeReadOnly: true; + }; + state: PatchDryRunState; + storage: { + objectKey: string; + }; +}; + +type PatchArtifactSummary = { + schemaVersion: "seo-patch-artifact-summary.v1"; + artifactKey: string; + changeCount: number; + generatedAt: string; + patchArtifactSchemaVersion: PatchArtifactContract["schemaVersion"]; + projectOntologyVersionId: string | null; + semanticRunId: string | null; + state: PatchArtifactState; +}; + +function getStringConfig(config: Record, key: string) { + const value = config[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizePath(value: string) { + return value.replace(/\\/g, "/").replace(/^\/+/, ""); +} + +function decodeHtmlEntities(value: string) { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/"/gi, "\"") + .replace(/'/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">"); +} + +function normalizeComparisonText(value: string | null | undefined) { + return (value ?? "").replace(/\r\n/g, "\n").trim(); +} + +function normalizeDryRunLookupText(value: string | null | undefined) { + return decodeHtmlEntities(value ?? "") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLocaleLowerCase("ru-RU"); +} + +function normalizeRawLookupText(value: string | null | undefined) { + return decodeHtmlEntities(value ?? "") + .replace(/\s+/g, " ") + .trim() + .toLocaleLowerCase("ru-RU"); +} + +function getLookupTokens(value: string | null | undefined) { + const tokens = normalizeDryRunLookupText(value).match(/[a-zа-яё0-9]{3,}/giu) ?? []; + return Array.from(new Set(tokens)).filter((token) => !/^\d+$/.test(token)); +} + +function getDataSeoTargetId(selector: string | null) { + return selector?.match(/data-seo-target-id=["']([^"']+)["']/i)?.[1] ?? null; +} + +function getSelectorFragment(sourceText: string | null, selector: string | null, workspaceSectionId: string) { + if (!sourceText) { + return null; + } + + const targetId = getDataSeoTargetId(selector) ?? workspaceSectionId; + const attributePattern = new RegExp(`data-seo-target-id=["']${targetId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, "i"); + const attributeMatch = attributePattern.exec(sourceText); + + if (attributeMatch?.index == null) { + return null; + } + + const tagStart = sourceText.lastIndexOf("<", attributeMatch.index); + + if (tagStart < 0) { + return null; + } + + const tagMatch = /^<([a-z0-9-]+)/i.exec(sourceText.slice(tagStart)); + + if (!tagMatch?.[1]) { + return null; + } + + const tagName = tagMatch[1].toLocaleLowerCase("en-US"); + let depth = 0; + const tagPattern = new RegExp(`]*>`, "gi"); + tagPattern.lastIndex = tagStart; + + for (const match of sourceText.slice(tagStart).matchAll(tagPattern)) { + const absoluteIndex = tagStart + (match.index ?? 0); + const token = match[0]; + + if (token.startsWith("")) { + depth += 1; + } + } + + const fallbackEnd = sourceText.indexOf("", tagStart); + return fallbackEnd > tagStart ? sourceText.slice(tagStart, fallbackEnd + "".length) : sourceText.slice(tagStart); +} + +function getTokenCoverage(beforeValue: string, lookupText: string | null) { + const beforeTokens = getLookupTokens(beforeValue); + const lookupTokens = new Set(getLookupTokens(lookupText)); + const matchedTokenCount = beforeTokens.filter((token) => lookupTokens.has(token)).length; + const coverage = beforeTokens.length > 0 ? matchedTokenCount / beforeTokens.length : 0; + + return { + coverage, + matchedTokenCount, + totalTokenCount: beforeTokens.length + }; +} + +function getSourceMatch(input: { + beforeValue: string; + selector: string | null; + sourceText: string | null; + workspaceSectionId: string; +}): PatchDryRunCheck["sourceMatch"] { + const normalizedBefore = normalizeDryRunLookupText(input.beforeValue); + const normalizedVisibleSource = normalizeDryRunLookupText(input.sourceText); + const normalizedRawSource = normalizeRawLookupText(input.sourceText); + + if (normalizedBefore && normalizedVisibleSource.includes(normalizedBefore)) { + return { + coverage: 1, + matchedTokenCount: getLookupTokens(input.beforeValue).length, + strategy: "exact_visible", + totalTokenCount: getLookupTokens(input.beforeValue).length + }; + } + + if (normalizedBefore && normalizedRawSource.includes(normalizedBefore)) { + return { + coverage: 1, + matchedTokenCount: getLookupTokens(input.beforeValue).length, + strategy: "exact_raw", + totalTokenCount: getLookupTokens(input.beforeValue).length + }; + } + + const selectorFragment = getSelectorFragment(input.sourceText, input.selector, input.workspaceSectionId); + const selectorCoverage = getTokenCoverage(input.beforeValue, selectorFragment); + + if (selectorFragment && selectorCoverage.totalTokenCount >= 4 && selectorCoverage.coverage >= 0.72) { + return { + ...selectorCoverage, + strategy: "selector_token_coverage" + }; + } + + const pageCoverage = getTokenCoverage(input.beforeValue, input.sourceText); + + if (pageCoverage.totalTokenCount >= 8 && pageCoverage.coverage >= 0.86) { + return { + ...pageCoverage, + strategy: "page_token_coverage" + }; + } + + return { + ...pageCoverage, + strategy: "none" + }; +} + +function buildSourceFingerprint(sourcePath: string, sourceText: string | null, capturedAt: string): PatchSourceFingerprint { + if (sourceText == null) { + return { + byteLength: null, + capturedAt, + readable: false, + sha256: null, + sourcePath + }; + } + + return { + byteLength: Buffer.byteLength(sourceText, "utf8"), + capturedAt, + readable: true, + sha256: createHash("sha256").update(sourceText, "utf8").digest("hex"), + sourcePath + }; +} + +async function buildSourceFingerprints(projectId: string, sourcePaths: string[], capturedAt: string) { + const source = await getProjectSource(projectId); + const uniqueSourcePaths = Array.from(new Set(sourcePaths.map(normalizePath).filter(Boolean))).sort((left, right) => + left.localeCompare(right) + ); + + return Promise.all( + uniqueSourcePaths.map(async (sourcePath) => { + const sourceText = await readSourceText(source, sourcePath); + return buildSourceFingerprint(sourcePath, sourceText, capturedAt); + }) + ); +} + +function getUniqueSortedStrings(values: Array) { + return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean))).sort((left, right) => + left.localeCompare(right) + ); +} + +async function getOptionalRewritePlan(projectId: string) { + try { + return await getRewritePlanContract(projectId); + } catch { + return null; + } +} + +async function getOptionalRewriteDiff(projectId: string) { + try { + return await getRewriteDiffContract(projectId); + } catch { + return null; + } +} + +async function buildPatchProvenance(input: { + changes: PatchArtifactChange[]; + generatedAt: string; + projectId: string; + rows: ActiveDraftRow[]; +}): Promise { + const [rewritePlan, rewriteDiff, latestOntologyVersion] = await Promise.all([ + getOptionalRewritePlan(input.projectId), + getOptionalRewriteDiff(input.projectId), + getLatestProjectOntologyVersion(input.projectId) + ]); + const projectOntologyVersionId = + rewriteDiff?.projectOntologyVersionId ?? + rewritePlan?.projectOntologyVersionId ?? + latestOntologyVersion?.id ?? + null; + const boundOntologyVersion = + projectOntologyVersionId && latestOntologyVersion?.id !== projectOntologyVersionId + ? await getProjectOntologyVersion(input.projectId, projectOntologyVersionId) + : latestOntologyVersion; + const semanticRunId = + rewriteDiff?.semanticRunId ?? + rewritePlan?.semanticRunId ?? + boundOntologyVersion?.semanticRunId ?? + null; + const reviewProblemCount = + boundOntologyVersion?.evidenceSummary.reviewItems?.filter((item) => item.level === "problem").length ?? 0; + const productionBlockers: string[] = []; + const visualBlockers: string[] = []; + const warnings: string[] = []; + + if (input.changes.length === 0) { + visualBlockers.push("Нет сохранённых draft-изменений: visual composer пока нечего собирать в patch view."); + } + + if (!semanticRunId) { + const blocker = "Patch artifact не привязан к semanticRunId: нужен контекст анализа перед visual composer и production patch-run."; + productionBlockers.push(blocker); + visualBlockers.push(blocker); + } + + if (!projectOntologyVersionId || !boundOntologyVersion) { + const blocker = "Patch artifact не привязан к projectOntologyVersionId: нужен reviewed project ontology snapshot."; + productionBlockers.push(blocker); + visualBlockers.push(blocker); + } else { + if (reviewProblemCount > 0) { + const blocker = "Project ontology содержит problem review items; visual composer и production patch-run заблокированы до review."; + productionBlockers.push(blocker); + visualBlockers.push(blocker); + } + + if (boundOntologyVersion.approvalStatus !== "approved" && boundOntologyVersion.approvalStatus !== "needs_review") { + productionBlockers.push( + `Project ontology v${boundOntologyVersion.version} имеет статус ${boundOntologyVersion.approvalStatus}; нужен needs_review или approved.` + ); + warnings.push("Visual composer может показать draft-правки, но модельные варианты нельзя считать SEO-safe без reviewed ontology."); + } + + if (boundOntologyVersion.id !== projectOntologyVersionId) { + warnings.push("Latest ontology version отличается от rewrite context; artifact использует bound rewrite ontology version."); + } + } + + if (!rewritePlan) { + productionBlockers.push("Rewrite plan недоступен: patch artifact не имеет планового SEO-контекста."); + warnings.push("Visual composer будет работать как manual patch review без rewrite plan."); + } else if (rewritePlan.state === "blocked") { + productionBlockers.push("Rewrite plan заблокирован; production patch-run нельзя запускать поверх неполного плана."); + warnings.push("Visual composer будет ограничен manual draft-правками, пока rewrite plan blocked."); + } + + if (!rewriteDiff) { + productionBlockers.push("Rewrite diff недоступен: patch artifact не имеет field/section review context."); + warnings.push("Visual composer будет работать без field/section rewrite context."); + } else if (rewriteDiff.state === "blocked" || rewriteDiff.state === "materialization_required") { + productionBlockers.push(`Rewrite diff state=${rewriteDiff.state}; сначала закрыть bindings/materialization.`); + warnings.push(`Rewrite diff state=${rewriteDiff.state}; это production/apply blocker, но не blocker для visual composer существующих draft-правок.`); + } + + if (input.changes.length > 0 && rewriteDiff?.readiness.slotCount === 0) { + warnings.push("Patch содержит изменения, но rewrite diff не содержит model-ready slots; возможны ручные правки вне SEO-плана."); + } + + return { + assistantPolicy: { + mayMutateApprovedOntology: false, + mayProposeOntologyDelta: true + }, + blockers: productionBlockers, + generatedAt: input.generatedAt, + gates: { + productionPatchRun: { + blockers: productionBlockers, + ready: productionBlockers.length === 0 + }, + visualComposer: { + blockers: visualBlockers, + ready: visualBlockers.length === 0 + } + }, + projectOntology: boundOntologyVersion + ? { + approvalStatus: boundOntologyVersion.approvalStatus, + brandName: boundOntologyVersion.brandName, + domainPackage: boundOntologyVersion.domainPackage, + id: boundOntologyVersion.id, + reviewProblemCount, + schemaVersion: boundOntologyVersion.schemaVersion, + source: boundOntologyVersion.source, + version: boundOntologyVersion.version + } + : null, + projectOntologyVersionId, + rewriteDiff: rewriteDiff + ? { + generatedAt: rewriteDiff.generatedAt, + readiness: rewriteDiff.readiness, + state: rewriteDiff.state + } + : null, + rewritePlan: rewritePlan + ? { + generatedAt: rewritePlan.generatedAt, + readiness: rewritePlan.readiness, + state: rewritePlan.state + } + : null, + schemaVersion: "seo-patch-provenance.v1", + semanticRunId, + sourceRefs: { + draftIds: getUniqueSortedStrings(input.rows.map((row) => row.draft_id)), + draftItemIds: getUniqueSortedStrings(input.rows.map((row) => row.draft_item_id)), + scanVersionIds: getUniqueSortedStrings(input.rows.map((row) => row.scan_version_id)), + sourcePaths: getUniqueSortedStrings(input.changes.map((change) => change.sourcePath)) + }, + warnings + }; +} + +function getWorkspaceSectionTexts(workingText: string, sectionCount: number) { + const headings = Array.from(workingText.matchAll(/^## .+$/gm)); + + if (headings.length === 0) { + return []; + } + + return headings.slice(0, sectionCount).map((heading, index) => { + const start = (heading.index ?? 0) + heading[0].length; + const end = headings[index + 1]?.index ?? workingText.length; + + return workingText.slice(start, end).trim(); + }); +} + +async function getActiveDraftRows(projectId: string) { + const result = await pool.query( + ` + with active_drafts as ( + select distinct on (d.page_id) + d.id as draft_id, + d.page_id, + d.updated_at as draft_updated_at, + di.id as draft_item_id, + di.field, + di.original_snapshot_key, + di.working_text + from drafts d + join draft_items di on di.draft_id = d.id + where d.project_id = $1 + and d.page_id is not null + and d.status = 'active' + and di.field = 'page_text' + order by d.page_id, d.updated_at desc, di.updated_at desc + ), + latest_pages as ( + select distinct on (pv.page_id) + pv.page_id, + pv.scan_version_id, + pv.source_path, + pv.url_path + from page_versions pv + join scan_versions sv on sv.id = pv.scan_version_id + where sv.project_id = $1 + and sv.status = 'done' + order by pv.page_id, sv.completed_at desc nulls last, sv.created_at desc + ) + select + active_drafts.draft_id, + active_drafts.draft_item_id, + active_drafts.draft_updated_at, + active_drafts.field, + active_drafts.original_snapshot_key, + active_drafts.page_id, + active_drafts.working_text, + latest_pages.scan_version_id, + latest_pages.source_path, + latest_pages.url_path + from active_drafts + left join latest_pages on latest_pages.page_id = active_drafts.page_id + order by latest_pages.source_path nulls last, active_drafts.draft_updated_at desc; + `, + [projectId] + ); + + return result.rows; +} + +async function getActiveContentDraftRows(projectId: string) { + const result = await pool.query( + ` + with active_drafts as ( + select distinct on (d.page_id) + d.id as draft_id, + d.page_id, + d.updated_at as draft_updated_at + from drafts d + where d.project_id = $1 + and d.page_id is not null + and d.status = 'active' + order by d.page_id, d.updated_at desc + ) + select + active_drafts.draft_id, + di.id as draft_item_id, + active_drafts.draft_updated_at, + di.field, + di.original_snapshot_key, + active_drafts.page_id, + null::text as scan_version_id, + null::text as source_path, + null::text as url_path, + di.working_text + from active_drafts + join draft_items di on di.draft_id = active_drafts.draft_id + where di.field like 'content_field:%' + order by di.field asc, di.updated_at desc; + `, + [projectId] + ); + + return result.rows; +} + +async function readWorkspaceSnapshot(row: ActiveDraftRow) { + if (!row.original_snapshot_key) { + return null; + } + + try { + const rawSnapshot = await storageProvider.getObjectText(row.original_snapshot_key); + return JSON.parse(rawSnapshot) as WorkspaceSnapshot; + } catch { + return null; + } +} + +async function buildPatchChanges(rows: ActiveDraftRow[]) { + const changes: PatchArtifactChange[] = []; + const draftIdsWithChanges = new Set(); + + await Promise.all( + rows.map(async (row) => { + const snapshot = await readWorkspaceSnapshot(row); + const sections = snapshot?.sections ?? []; + const sourcePath = row.source_path ?? ""; + const workingSectionTexts = getWorkspaceSectionTexts(row.working_text ?? snapshot?.originalText ?? "", sections.length); + + if (!snapshot || sections.length === 0 || !sourcePath) { + return; + } + + sections.forEach((section, index) => { + const beforeValue = section.originalText; + const afterValue = workingSectionTexts[index] ?? section.originalText; + + if (normalizeComparisonText(beforeValue) === normalizeComparisonText(afterValue)) { + return; + } + + draftIdsWithChanges.add(row.draft_id); + changes.push({ + afterLength: afterValue.length, + afterValue, + beforeLength: beforeValue.length, + beforeValue, + changeType: "text_section", + delta: afterValue.length - beforeValue.length, + draftId: row.draft_id, + draftItemId: row.draft_item_id, + id: `${row.page_id}:${section.id}`, + kind: section.kind, + pageId: row.page_id, + selector: section.selector, + sourceHint: section.sourceHint, + sourcePath, + title: section.title, + urlPath: row.url_path, + workspaceSectionId: section.id + }); + }); + }) + ); + + return { + changes: changes.sort((left, right) => { + return ( + left.sourcePath.localeCompare(right.sourcePath) || + left.workspaceSectionId.localeCompare(right.workspaceSectionId) + ); + }), + changedDraftCount: draftIdsWithChanges.size + }; +} + +async function buildContentFieldPatchChanges(rows: ActiveDraftRow[]) { + const changes: PatchArtifactChange[] = []; + const draftIdsWithChanges = new Set(); + + await Promise.all( + rows.map(async (row) => { + const snapshot = await readContentFieldSnapshot(row.original_snapshot_key); + + if (!snapshot?.sourcePath) { + return; + } + + const beforeValue = snapshot.originalText; + const afterValue = row.working_text ?? snapshot.originalText; + + if (normalizeComparisonText(beforeValue) === normalizeComparisonText(afterValue)) { + return; + } + + const workspaceSectionId = `${snapshot.sectionId}:${snapshot.fieldPath}`; + draftIdsWithChanges.add(row.draft_id); + changes.push({ + afterLength: afterValue.length, + afterValue, + beforeLength: beforeValue.length, + beforeValue, + changeType: "content_field", + delta: afterValue.length - beforeValue.length, + draftId: row.draft_id, + draftItemId: row.draft_item_id, + id: `${snapshot.pageId}:${workspaceSectionId}`, + kind: snapshot.renderAs === "html" || snapshot.kind === "textarea" ? "content_block" : "heading_group", + pageId: snapshot.pageId, + selector: null, + sourceHint: snapshot.sourceHint, + sourcePath: snapshot.sourcePath, + title: snapshot.title, + urlPath: row.url_path, + workspaceSectionId + }); + }) + ); + + return { + changes: changes.sort((left, right) => { + return ( + left.sourcePath.localeCompare(right.sourcePath) || + left.workspaceSectionId.localeCompare(right.workspaceSectionId) + ); + }), + changedDraftCount: draftIdsWithChanges.size + }; +} + +function buildPatchNextActions(contract: Omit) { + if (contract.readiness.changeCount === 0) { + return [ + "Сохранить хотя бы одну секционную правку в working draft перед сборкой patch artifact.", + "Production patch-run недоступен: patch artifact пустой." + ]; + } + + if (contract.readiness.sourceUnreadableCount > 0) { + return [ + "Source fingerprint собран не для всех affected files: dry-run покажет missing source blockers.", + "Проверить подключение source tree и пересобрать patch artifact.", + "Production patch-run недоступен до dry-run без blockers." + ]; + } + + if (contract.readiness.provenanceBlockerCount > 0) { + return [ + "Проверить provenance blockers: ontology binding, semantic run и rewrite context обязательны перед production patch-run.", + "Вернуться к project ontology / keyword map / rewrite plan, закрыть блокеры и пересобрать patch artifact.", + "Production patch-run недоступен до dry-run без provenance blockers." + ]; + } + + return [ + "Показать пользователю patch manifest: affected files, sections, before/after и delta.", + "Сделать dry-run against current source tree перед production patch-run.", + "Заблокировать production patch-run, если source tree изменился после dry-run." + ]; +} + +async function persistPatchArtifact( + projectId: string, + generatedAt: string, + draftCount: number, + changedDraftCount: number, + changes: PatchArtifactChange[], + provenance: PatchProvenanceContract, + sourceFingerprints: PatchSourceFingerprint[] +) { + const state: PatchArtifactState = changes.length > 0 ? "ready_for_dry_run" : "empty"; + const affectedFileCount = new Set(changes.map((change) => change.sourcePath)).size; + const sourceUnreadableCount = sourceFingerprints.filter((fingerprint) => !fingerprint.readable).length; + const provenanceBlockerCount = provenance.blockers.length; + const client = await pool.connect(); + + try { + await client.query("begin"); + + const changesetResult = await client.query<{ id: string }>( + ` + insert into changesets (project_id, status, summary) + values ($1, $2, '{}'::jsonb) + returning id; + `, + [projectId, state === "empty" ? "patch_empty" : "patch_ready"] + ); + const changesetId = changesetResult.rows[0]?.id; + + if (!changesetId) { + throw new Error("Не удалось создать changeset для patch artifact."); + } + + const artifactKey = `projects/${projectId}/patches/${changesetId}/seo-patch-artifact.json`; + const contractWithoutActions: Omit = { + artifactKey, + changes, + changesetId, + generatedAt, + mode: "manifest_only", + projectId, + readiness: { + affectedFileCount, + blockerCount: sourceUnreadableCount + provenanceBlockerCount, + changeCount: changes.length, + changedDraftCount, + draftCount, + provenanceBlockerCount, + sourceFingerprintCount: sourceFingerprints.length, + sourceUnreadableCount + }, + provenance, + schemaVersion: "seo-patch-artifact.v1", + sourceFingerprints, + sourceSafety: { + editorWritesSourceFiles: false, + productionRequiresExplicitPatchRun: true, + sourceTreeReadOnly: true + }, + state, + storage: { + objectKey: artifactKey + } + }; + const contract: PatchArtifactContract = { + ...contractWithoutActions, + nextActions: buildPatchNextActions(contractWithoutActions) + }; + + await storageProvider.putObject({ + body: JSON.stringify(contract, null, 2), + contentType: "application/json; charset=utf-8", + key: artifactKey + }); + + for (const change of changes) { + await client.query( + ` + insert into changeset_items ( + changeset_id, + change_type, + source_path, + selector_or_media_id, + before_value, + after_value + ) + values ($1, $2, $3, $4, $5, $6); + `, + [ + changesetId, + change.changeType, + change.sourcePath, + change.selector ?? change.workspaceSectionId, + change.beforeValue, + change.afterValue + ] + ); + } + + const summary: PatchArtifactSummary = { + artifactKey, + changeCount: changes.length, + generatedAt, + patchArtifactSchemaVersion: contract.schemaVersion, + projectOntologyVersionId: provenance.projectOntologyVersionId, + schemaVersion: "seo-patch-artifact-summary.v1", + semanticRunId: provenance.semanticRunId, + state + }; + + await client.query( + ` + update changesets + set summary = $1::jsonb + where id = $2; + `, + [JSON.stringify(summary), changesetId] + ); + + await client.query("commit"); + return contract; + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} + +export async function buildPatchArtifact(projectId: string) { + const generatedAt = new Date().toISOString(); + const rows = await getActiveDraftRows(projectId); + const contentRows = await getActiveContentDraftRows(projectId); + const pagePatchChanges = await buildPatchChanges(rows); + const contentPatchChanges = await buildContentFieldPatchChanges(contentRows); + const allRows = [...rows, ...contentRows]; + const changes = [...pagePatchChanges.changes, ...contentPatchChanges.changes].sort((left, right) => { + return ( + left.sourcePath.localeCompare(right.sourcePath) || + left.workspaceSectionId.localeCompare(right.workspaceSectionId) + ); + }); + const changedDraftCount = new Set(changes.map((change) => change.draftId)).size; + const draftCount = new Set(allRows.map((row) => row.draft_id)).size; + const [provenance, sourceFingerprints] = await Promise.all([ + buildPatchProvenance({ changes, generatedAt, projectId, rows: allRows }), + buildSourceFingerprints( + projectId, + changes.map((change) => change.sourcePath), + generatedAt + ) + ]); + + return persistPatchArtifact(projectId, generatedAt, draftCount, changedDraftCount, changes, provenance, sourceFingerprints); +} + +export async function getLatestPatchArtifact(projectId: string) { + const result = await pool.query<{ summary: PatchArtifactSummary }>( + ` + select summary + from changesets + where project_id = $1 + and summary->>'patchArtifactSchemaVersion' = 'seo-patch-artifact.v1' + order by created_at desc + limit 1; + `, + [projectId] + ); + const summary = result.rows[0]?.summary; + + if (!summary?.artifactKey) { + return null; + } + + try { + const rawArtifact = await storageProvider.getObjectText(summary.artifactKey); + return JSON.parse(rawArtifact) as PatchArtifactContract; + } catch { + return null; + } +} + +async function getProjectSource(projectId: string) { + const result = await pool.query( + ` + select type as source_type, config as source_config + from project_sources + where project_id = $1 + order by created_at asc + limit 1; + `, + [projectId] + ); + + return result.rows[0] ?? null; +} + +async function readSourceText(source: ProjectSourceRow | null, requestedPath: string) { + const normalizedRequestedPath = normalizePath(requestedPath); + + if (!source) { + return null; + } + + if (source.source_type === "local_folder") { + const rootPath = getStringConfig(source.source_config, "path"); + + if (!rootPath) { + return null; + } + + try { + return await fs.readFile(path.join(rootPath, normalizedRequestedPath), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + + throw error; + } + } + + if (source.source_type === "browser_folder") { + const importPrefix = getStringConfig(source.source_config, "importPrefix"); + const rootName = getStringConfig(source.source_config, "rootName"); + + if (!importPrefix) { + return null; + } + + const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`; + const candidates = [ + normalizedRequestedPath, + rootName ? `${normalizePath(rootName)}/${normalizedRequestedPath}` : null + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + try { + return await storageProvider.getObjectText(`${normalizedPrefix}${candidate}`); + } catch { + // Browser-folder imports can either include or strip the top folder name. + } + } + } + + return null; +} + +async function getPatchArtifactByChangeset(projectId: string, changesetId: string) { + const result = await pool.query<{ summary: PatchArtifactSummary }>( + ` + select summary + from changesets + where project_id = $1 + and id = $2 + and summary->>'patchArtifactSchemaVersion' = 'seo-patch-artifact.v1' + limit 1; + `, + [projectId, changesetId] + ); + const summary = result.rows[0]?.summary; + + if (!summary?.artifactKey) { + return null; + } + + const rawArtifact = await storageProvider.getObjectText(summary.artifactKey); + return JSON.parse(rawArtifact) as PatchArtifactContract; +} + +function getFingerprintStatus( + expected: PatchSourceFingerprint | null, + current: PatchSourceFingerprint | null +): PatchSourceFingerprintStatus { + if (!expected?.readable || !expected.sha256) { + return "missing_at_build"; + } + + if (!current?.readable || !current.sha256) { + return "missing_at_dry_run"; + } + + return expected.sha256 === current.sha256 ? "matched" : "mismatch"; +} + +function getFingerprintBlocker(status: PatchSourceFingerprintStatus) { + const blockers: Record = { + matched: null, + mismatch: "Source fingerprint изменился после сборки patch artifact; нужен re-scan/rebuild patch.", + missing_at_build: "Source fingerprint не был зафиксирован при сборке patch artifact; пересоберите patch artifact.", + missing_at_dry_run: "Source fingerprint не удалось проверить на текущем source tree; patch-run заблокирован." + }; + + return blockers[status]; +} + +function buildDryRunChecks( + artifact: PatchArtifactContract, + sourceTexts: Map, + currentFingerprints: Map +) { + const expectedFingerprints = new Map( + (artifact.sourceFingerprints ?? []).map((fingerprint) => [normalizePath(fingerprint.sourcePath), fingerprint]) + ); + + return artifact.changes.map((change): PatchDryRunCheck => { + const blockers: string[] = []; + const normalizedSourcePath = normalizePath(change.sourcePath); + const sourceText = sourceTexts.get(normalizedSourcePath) ?? sourceTexts.get(change.sourcePath) ?? null; + const expectedFingerprint = expectedFingerprints.get(normalizedSourcePath) ?? null; + const currentFingerprint = currentFingerprints.get(normalizedSourcePath) ?? currentFingerprints.get(change.sourcePath) ?? null; + const fingerprintStatus = getFingerprintStatus(expectedFingerprint, currentFingerprint); + const fingerprintBlocker = getFingerprintBlocker(fingerprintStatus); + const normalizedAfter = normalizeDryRunLookupText(change.afterValue); + const sourceMatch = getSourceMatch({ + beforeValue: change.beforeValue, + selector: change.selector, + sourceText, + workspaceSectionId: change.workspaceSectionId + }); + const beforeFound = sourceMatch.strategy !== "none"; + const afterNonEmpty = normalizedAfter.length > 0; + + if (sourceText == null) { + blockers.push("Source file не найден или не читается."); + } + + if (fingerprintBlocker) { + blockers.push(fingerprintBlocker); + } + + if (!afterNonEmpty) { + blockers.push("After value пустой: patch-run может удалить смысловой блок."); + } + + if (!beforeFound) { + blockers.push("Expected original text не найден в текущем source tree; нужен re-scan/rebuild patch или ручной merge."); + } + + return { + afterNonEmpty, + beforeFound, + blockers, + changeId: change.id, + fingerprint: { + currentSha256: currentFingerprint?.sha256 ?? null, + expectedSha256: expectedFingerprint?.sha256 ?? null, + status: fingerprintStatus + }, + fingerprintMatched: fingerprintStatus === "matched", + id: `${artifact.changesetId}:${change.id}`, + sourceMatch, + sourceReadable: sourceText != null, + sourcePath: change.sourcePath, + status: blockers.length > 0 ? "blocked" : "passed", + title: change.title, + workspaceSectionId: change.workspaceSectionId + }; + }); +} + +function getArtifactProvenance(artifact: PatchArtifactContract): PatchProvenanceContract { + if (artifact.provenance) { + return artifact.provenance; + } + + return { + assistantPolicy: { + mayMutateApprovedOntology: false, + mayProposeOntologyDelta: true + }, + blockers: ["Patch artifact собран до появления provenance envelope; пересоберите patch artifact."], + gates: { + productionPatchRun: { + blockers: ["Patch artifact собран до появления provenance envelope; пересоберите patch artifact."], + ready: false + }, + visualComposer: { + blockers: ["Patch artifact собран до появления provenance envelope; пересоберите patch artifact."], + ready: false + } + }, + generatedAt: artifact.generatedAt, + projectOntology: null, + projectOntologyVersionId: null, + rewriteDiff: null, + rewritePlan: null, + schemaVersion: "seo-patch-provenance.v1", + semanticRunId: null, + sourceRefs: { + draftIds: Array.from(new Set(artifact.changes.map((change) => change.draftId))).sort(), + draftItemIds: Array.from(new Set(artifact.changes.map((change) => change.draftItemId))).sort(), + scanVersionIds: [], + sourcePaths: Array.from(new Set(artifact.changes.map((change) => change.sourcePath))).sort() + }, + warnings: [] + }; +} + +function buildDryRunNextActions(contract: Omit) { + if (contract.state === "empty") { + return [ + "Вернуться в 07 План правок и собрать непустой patch artifact.", + "Production patch-run недоступен: нет изменений." + ]; + } + + if (contract.state === "blocked") { + return [ + "Разобрать blocked checks: provenance, missing source, stale original text или пустой after value.", + "После ontology/rewrite review или re-scan/rebuild patch повторить dry-run.", + "Production patch-run заблокирован до dry-run без blockers." + ]; + } + + return [ + "Показать dry-run report пользователю перед production patch-run.", + "Следующий слой: подготовить patch script и backup поверх уже проверенного source fingerprint.", + "Production patch-run всё ещё требует отдельного подтверждения пользователя." + ]; +} + +async function persistDryRun(projectId: string, artifact: PatchArtifactContract, checks: PatchDryRunCheck[]) { + const generatedAt = new Date().toISOString(); + const provenance = getArtifactProvenance(artifact); + const changeBlockerCount = checks.reduce((sum, check) => sum + check.blockers.length, 0); + const provenanceBlockerCount = provenance.gates.productionPatchRun.blockers.length; + const blockerCount = changeBlockerCount + provenanceBlockerCount; + const fingerprintMismatchCount = checks.filter((check) => check.fingerprint.status === "mismatch").length; + const fingerprintMissingCount = checks.filter( + (check) => check.fingerprint.status === "missing_at_build" || check.fingerprint.status === "missing_at_dry_run" + ).length; + const missingSourceCount = checks.filter((check) => !check.sourceReadable).length; + const staleChangeCount = checks.filter((check) => !check.beforeFound).length; + const emptyAfterCount = checks.filter((check) => !check.afterNonEmpty).length; + const state: PatchDryRunState = + artifact.changes.length === 0 ? "empty" : blockerCount > 0 ? "blocked" : "passed"; + const dryRunKey = `projects/${projectId}/patches/${artifact.changesetId}/seo-patch-dry-run.json`; + const contractWithoutActions: Omit = { + artifactKey: artifact.artifactKey, + changesetId: artifact.changesetId, + checks, + generatedAt, + projectId, + readiness: { + affectedFileCount: new Set(checks.map((check) => check.sourcePath)).size, + blockerCount, + checkCount: checks.length, + emptyAfterCount, + fingerprintMismatchCount, + fingerprintMissingCount, + missingSourceCount, + passedCount: checks.filter((check) => check.status === "passed").length, + provenanceBlockerCount, + staleChangeCount + }, + provenance, + schemaVersion: "seo-patch-dry-run.v1", + sourceSafety: { + dryRunWritesSourceFiles: false, + patchRunExecuted: false, + sourceTreeReadOnly: true + }, + state, + storage: { + objectKey: dryRunKey + } + }; + const contract: PatchDryRunContract = { + ...contractWithoutActions, + nextActions: buildDryRunNextActions(contractWithoutActions) + }; + const summaryResult = await pool.query<{ summary: Record }>( + ` + select summary + from changesets + where project_id = $1 and id = $2 + limit 1; + `, + [projectId, artifact.changesetId] + ); + const currentSummary = summaryResult.rows[0]?.summary ?? {}; + + await storageProvider.putObject({ + body: JSON.stringify(contract, null, 2), + contentType: "application/json; charset=utf-8", + key: dryRunKey + }); + + await pool.query( + ` + update changesets + set status = $1, + summary = $2::jsonb + where project_id = $3 and id = $4; + `, + [ + state === "passed" ? "dry_run_passed" : state === "empty" ? "dry_run_empty" : "dry_run_blocked", + JSON.stringify({ + ...currentSummary, + dryRun: { + blockerCount, + fingerprintMismatchCount, + fingerprintMissingCount, + generatedAt, + objectKey: dryRunKey, + passedCount: contract.readiness.passedCount, + provenanceBlockerCount, + schemaVersion: contract.schemaVersion, + state + } + }), + projectId, + artifact.changesetId + ] + ); + + return contract; +} + +export async function runPatchDryRun(projectId: string, changesetId: string) { + const artifact = await getPatchArtifactByChangeset(projectId, changesetId); + + if (!artifact) { + return null; + } + + const source = await getProjectSource(projectId); + const sourcePaths = Array.from( + new Set([ + ...artifact.changes.map((change) => normalizePath(change.sourcePath)), + ...(artifact.sourceFingerprints ?? []).map((fingerprint) => normalizePath(fingerprint.sourcePath)) + ]) + ).filter(Boolean); + const sourceEntries = await Promise.all( + sourcePaths.map(async (sourcePath) => { + const sourceText = await readSourceText(source, sourcePath); + return [sourcePath, sourceText, buildSourceFingerprint(sourcePath, sourceText, new Date().toISOString())] as const; + }) + ); + const sourceTexts = new Map(sourceEntries.map(([sourcePath, sourceText]) => [sourcePath, sourceText])); + const currentFingerprints = new Map( + sourceEntries.map(([sourcePath, , fingerprint]) => [sourcePath, fingerprint]) + ); + const checks = buildDryRunChecks(artifact, sourceTexts, currentFingerprints); + + return persistDryRun(projectId, artifact, checks); +} diff --git a/seo_mode/seo_mode/server/src/preview/routes.ts b/seo_mode/seo_mode/server/src/preview/routes.ts index ae572c7..1f50a88 100644 --- a/seo_mode/seo_mode/server/src/preview/routes.ts +++ b/seo_mode/seo_mode/server/src/preview/routes.ts @@ -5,6 +5,7 @@ import type { Response } from "express"; import { pool } from "../db/client.js"; import { storageProvider } from "../storage/index.js"; import { annotatePreviewTargets, getPreviewTargetSeeds } from "../workspace/previewTargets.js"; +import { CAROUSEL_LIKE_SEMANTIC_PATTERN_SOURCE } from "./semanticBlockRules.js"; import { getPreviewSnapshot } from "./snapshotCache.js"; type ProjectSourceRow = { @@ -25,6 +26,7 @@ type ResolvedAsset = { }; type PreviewRewriteOptions = { + focusOrder?: number | null; focusSelector?: string | null; focusIndex?: number | null; }; @@ -337,10 +339,35 @@ function buildPreviewBridgeScript() { return `