diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index bd75168..23794ec 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -233,6 +233,18 @@ Durable Object + SQLite は次の structured record を保持する。 - `list_recent_activity` - semantic search hit の enrichment +### 8. Graph Index(opt-in, D1 `doc_edges`) + +判断知(Decision Structure = wiki の kebab エントリ)の関係を索引化する additive なグラフ層。dense(Vectorize)/ sparse(FTS5)に並ぶ第3の surface だが、**既定では retrieval から読まれない**。 + +- **スキーマ**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)`(`migrations/0002_graph_edges.sql`)。src/dst は wiki の決定的 vector_id(`wikiDocVectorId`)。 +- **エッジ抽出**: wiki ページ index 時(`processAndUpsertWikiDoc`)、同 repo の既知 wiki slug が本文に出現したら A→B の "mention" エッジを生成(`src/graph.ts` の `indexWikiEdges`)。**決定的 slug-match(LLM 不要・ロスなし)**。dst は計算で求まるので未 index でも記録可(dangling 可)。typed(supersede/depend/conflict)は将来スコープ。 +- **traversal**: `queryNeighbors` が `WITH RECURSIVE`(標準 SQLite、拡張不要)で seed の 1–2 hop neighbor を無向に辿る。 +- **retrieval 統合**: `search` の `graph_expand`(既定 false)/ `graph_hops`(既定 1)。true の時のみ、RRF 後の最終結果を seed に neighbor を辿り、関連 wiki ページを `graph_hop` / `graph_from` 付きで末尾に append。**false の時は既存挙動と完全同一(回帰なし)**。 +- **delete fan-out**: wiki ページ削除時に `deleteEdgesForVector`(当該 vector を端点に持つエッジを除去)。 +- **backfill**: `POST /admin/backfill-edges?repo=owner/repo`(GITHUB_TOKEN ヘッダ)。既存 index 済み wiki の content から一括抽出(GitHub 再取得不要)。 +- **評価**: 本番 ship 後の実運用観測(judgment-learning が関連判断を拾えるか)。offline eval harness は作らない。 + ## Retrieval Model retrieval layer は hybrid search(dense + sparse)+ cross-encoder rerank + structured filter を 3 段で支える(2026 production baseline)。 diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 50b0f09..c39bc13 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -234,6 +234,18 @@ This store supports: - `list_recent_activity` - enrichment of semantic search hits +### 8. Graph Index (opt-in, D1 `doc_edges`) + +An additive graph layer that indexes relationships between Decision-Structure wiki entries (kebab-case wiki pages). A third surface alongside dense (Vectorize) and sparse (FTS5), but **never read by retrieval unless explicitly requested**. + +- **Schema**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)` (`migrations/0002_graph_edges.sql`). src/dst are deterministic wiki vector IDs (`wikiDocVectorId`). +- **Edge extraction**: at wiki index time (`processAndUpsertWikiDoc`), when another known wiki slug in the same repo appears in the content, an A→B "mention" edge is written (`indexWikiEdges` in `src/graph.ts`). **Deterministic slug-match (no LLM, no lossy extraction)**; the dst ID is computed, so dangling edges to not-yet-indexed pages are allowed. Typed edges (supersede/depend/conflict) are future scope. +- **Traversal**: `queryNeighbors` walks 1–2 hop undirected neighbors of the seeds via `WITH RECURSIVE` (standard SQLite, no extension). +- **Retrieval integration**: `search` gains `graph_expand` (default false) / `graph_hops` (default 1). Only when true, the final result set seeds a traversal and related wiki pages are appended (tagged `graph_hop` / `graph_from`). **When false the behavior is byte-identical to before (no regression).** +- **Delete fan-out**: on wiki page deletion, `deleteEdgesForVector` removes edges touching that vector. +- **Backfill**: `POST /admin/backfill-edges?repo=owner/repo` (GITHUB_TOKEN header) re-extracts edges from the stored content of already-indexed wiki pages (no GitHub refetch). +- **Evaluation**: real-use observation after ship (does judgment-learning surface related decisions), not an offline eval harness. + ## Retrieval Model The retrieval layer supports a 3-tier pipeline: hybrid search (dense + sparse), RRF fusion, and cross-encoder reranking, with structured filtering applied along the way (the 2026 production baseline). diff --git a/migrations/0002_graph_edges.sql b/migrations/0002_graph_edges.sql new file mode 100644 index 0000000..3c133c5 --- /dev/null +++ b/migrations/0002_graph_edges.sql @@ -0,0 +1,49 @@ +-- D1 graph edges migration — opt-in GraphRAG layer (additive). +-- +-- Layer = L4 Operations (graph retrieval surface, sibling of the FTS5 sparse side) +-- +-- Overview: +-- +-- doc_edges — directed "mention" references between wiki pages (Decision Structure +-- entries). src/dst are deterministic Vectorize vector IDs computed by +-- wikiDocVectorId() in src/pipeline/vector-id.ts, so an edge can be +-- recorded without a lookup and may point at a not-yet-indexed page +-- (dangling allowed). Traversal (recursive CTE in src/graph.ts) treats +-- edges as undirected. +-- +-- Notes: +-- - Fully additive: no existing table or query depends on doc_edges. The default +-- retrieval path (dense + sparse RRF) never reads it. It is consulted only when +-- the `search` tool is called with `graph_expand=true`. +-- - Cloudflare D1 ships standard SQLite, so recursive CTE traversal needs no extension +-- (same as the pre-compiled FTS5 used by 0001). +-- - PRIMARY KEY (src_vector_id, dst_vector_id) gives the src-prefix index for free; +-- the extra dst index supports the undirected reverse hop. + +CREATE TABLE IF NOT EXISTS doc_edges ( + -- Deterministic Vectorize vector ID of the referencing page ("w:..." for wiki). + src_vector_id TEXT NOT NULL, + + -- Deterministic Vectorize vector ID of the referenced page (wikiDocVectorId(repo, dst_slug)). + dst_vector_id TEXT NOT NULL, + + -- Repository ("owner/repo"); edges only form within one repo's wiki. + repo TEXT NOT NULL DEFAULT '', + + -- Human-readable slugs (wiki page names), kept for debugging / backfill clarity. + src_slug TEXT NOT NULL DEFAULT '', + dst_slug TEXT NOT NULL DEFAULT '', + + -- Relationship kind. Currently only 'mention' (generic related-to). Typed + -- supersede/depend/conflict edges are a follow-up. + edge_kind TEXT NOT NULL DEFAULT 'mention', + + updated_at TEXT NOT NULL DEFAULT '', + + PRIMARY KEY (src_vector_id, dst_vector_id) +); + +-- Reverse-direction lookups for undirected traversal. +CREATE INDEX IF NOT EXISTS idx_doc_edges_dst ON doc_edges (dst_vector_id); +-- Per-repo scoping of traversal. +CREATE INDEX IF NOT EXISTS idx_doc_edges_repo ON doc_edges (repo); diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..06f1060 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,233 @@ +/** + * D1 graph layer — opt-in GraphRAG over wiki Decision Structure entries. + * + * Layer = L4 Operations (graph retrieval surface) + * + * Additive sibling of fts.ts (sparse / BM25) and Vectorize (dense). The default + * retrieval path never touches this layer; it is consulted only when the `search` + * tool is called with `graph_expand=true`. + * + * Edges are deterministic slug-mention references between wiki pages: when page A's + * content mentions page B's slug, an A→B "mention" edge is recorded. The dst vector + * ID is computed with wikiDocVectorId() (no lookup, dangling allowed). Extraction is + * exact string matching — no LLM, no lossy relation extraction. + * + * Traversal uses a recursive CTE (standard SQLite, no extension) and treats edges + * as undirected so a query can reach both referencing and referenced neighbors. + */ + +import { wikiDocVectorId } from "./pipeline/vector-id.js"; + +/** One outbound edge from a source page. */ +export interface DocEdge { + dstVectorId: string; + dstSlug: string; + edgeKind: string; // "mention" for now +} + +/** A neighbor produced by graph traversal. */ +export interface GraphNeighbor { + vectorId: string; + hop: number; + fromVectorId: string; +} + +/** Escape a string for safe inclusion in a RegExp. */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Whether `content` references the kebab-case `slug` as a standalone token. + * Requires the slug not to be flanked by [a-z0-9-] so that a slug which is a + * substring of a longer slug (e.g. "decision-structure" inside + * "decision-structure-rename-rationale") does not produce a false edge. + */ +export function mentionsSlug(content: string, slug: string): boolean { + if (slug.length === 0 || !content.includes(slug)) return false; + const re = new RegExp(`(^|[^a-z0-9-])${escapeRegExp(slug)}([^a-z0-9-]|$)`); + return re.test(content); +} + +/** + * Extract mention edges from a wiki page's content. For each known slug in the + * same repo (other than the source) that the content references, emit an edge to + * its deterministically-computed wiki vector ID. + */ +export async function extractMentionEdges( + repo: string, + srcSlug: string, + content: string, + knownSlugs: string[], +): Promise { + const edges: DocEdge[] = []; + const seen = new Set(); + for (const slug of knownSlugs) { + if (slug === srcSlug || seen.has(slug)) continue; + if (mentionsSlug(content, slug)) { + seen.add(slug); + edges.push({ + dstVectorId: await wikiDocVectorId(repo, slug), + dstSlug: slug, + edgeKind: "mention", + }); + } + } + return edges; +} + +/** Fetch the known wiki page slugs for a repo from the FTS content table. */ +export async function knownWikiSlugs( + db: D1Database, + repo: string, +): Promise { + const res = await db + .prepare( + `SELECT doc_path FROM search_docs WHERE type = 'wiki_doc' AND repo = ?`, + ) + .bind(repo) + .all<{ doc_path: string }>(); + return (res.results ?? []) + .map((r) => String(r.doc_path ?? "")) + .filter((s) => s.length > 0); +} + +/** + * Replace all outbound edges for a source node (delete-then-insert) so a re-index + * of the same page does not accumulate stale edges. + */ +export async function upsertEdges( + db: D1Database, + srcVectorId: string, + repo: string, + srcSlug: string, + edges: DocEdge[], +): Promise { + const now = new Date().toISOString(); + const stmts: D1PreparedStatement[] = [ + db.prepare(`DELETE FROM doc_edges WHERE src_vector_id = ?`).bind(srcVectorId), + ]; + for (const e of edges) { + stmts.push( + db + .prepare( + `INSERT OR REPLACE INTO doc_edges + (src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(srcVectorId, e.dstVectorId, repo, srcSlug, e.dstSlug, e.edgeKind, now), + ); + } + await db.batch(stmts); +} + +/** + * Convenience: extract + upsert mention edges for one wiki page. Called from the + * ingest pipeline right after the page is written to Vectorize + FTS5. + */ +export async function indexWikiEdges( + db: D1Database, + repo: string, + srcSlug: string, + srcVectorId: string, + content: string, +): Promise { + const known = await knownWikiSlugs(db, repo); + const edges = await extractMentionEdges(repo, srcSlug, content, known); + await upsertEdges(db, srcVectorId, repo, srcSlug, edges); + return edges.length; +} + +/** Remove every edge touching a vector ID (as src or dst). For delete fan-out. */ +export async function deleteEdgesForVector( + db: D1Database, + vectorId: string, +): Promise { + await db + .prepare(`DELETE FROM doc_edges WHERE src_vector_id = ? OR dst_vector_id = ?`) + .bind(vectorId, vectorId) + .run(); +} + +/** + * Traverse up to `hops` (1–2) neighbors of the seed vector IDs via a recursive CTE. + * Edges are followed in both directions. Returns neighbors NOT in the seed set, + * each with its shortest hop distance and one originating seed. + */ +export async function queryNeighbors( + db: D1Database, + seedVectorIds: string[], + opts: { hops?: number; repo?: string; limit?: number } = {}, +): Promise { + const seeds = [...new Set(seedVectorIds)].filter((s) => s.length > 0); + if (seeds.length === 0) return []; + const hops = Math.max(1, Math.min(2, opts.hops ?? 1)); + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + + // Anchor: each seed at depth 0 with itself as origin. + const seedValues = seeds.map(() => "(?, 0, ?)").join(", "); + const repoFilter = opts.repo ? "AND e.repo = ?" : ""; + + const sql = ` + WITH RECURSIVE reach(id, depth, origin) AS ( + SELECT * FROM (VALUES ${seedValues}) + UNION + SELECT CASE WHEN e.src_vector_id = r.id THEN e.dst_vector_id + ELSE e.src_vector_id END, + r.depth + 1, + r.origin + FROM doc_edges e + JOIN reach r + ON (e.src_vector_id = r.id OR e.dst_vector_id = r.id) + WHERE r.depth < ? ${repoFilter} + ) + SELECT id, MIN(depth) AS hop, origin + FROM reach + WHERE depth > 0 + GROUP BY id + ORDER BY hop ASC + LIMIT ? + `; + + const binds: (string | number)[] = []; + for (const s of seeds) binds.push(s, s); // (id, origin) per seed VALUES row + binds.push(hops); + if (opts.repo) binds.push(opts.repo); + binds.push(limit); + + const res = await db + .prepare(sql) + .bind(...binds) + .all<{ id: string; hop: number; origin: string }>(); + + const seedSet = new Set(seeds); + return (res.results ?? []) + .map((r) => ({ + vectorId: String(r.id ?? ""), + hop: Number(r.hop ?? 0), + fromVectorId: String(r.origin ?? ""), + })) + .filter((n) => n.vectorId.length > 0 && !seedSet.has(n.vectorId)); +} + +/** + * Fetch search_docs rows by vector ID, keyed by vector_id. Used to enrich graph + * neighbors that did not appear in the dense/sparse result sets. + */ +export async function getDocsByVectorIds( + db: D1Database, + vectorIds: string[], +): Promise>> { + const ids = [...new Set(vectorIds)].filter((s) => s.length > 0); + const out = new Map>(); + if (ids.length === 0) return out; + const placeholders = ids.map(() => "?").join(", "); + const res = await db + .prepare(`SELECT * FROM search_docs WHERE vector_id IN (${placeholders})`) + .bind(...ids) + .all>(); + for (const r of res.results ?? []) { + out.set(String(r.vector_id ?? ""), r); + } + return out; +} diff --git a/src/index.ts b/src/index.ts index 1f8da0a..c964654 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ import { import { handleScheduled } from "./poller.js"; import { handleWebhook } from "./webhook.js"; import { RagMcpAgentV2 } from "./mcp.js"; +import { indexWikiEdges } from "./graph.js"; // Durable Object: issue/PR state store (SQLite-backed) export { IssueStore } from "./store.js"; @@ -126,6 +127,57 @@ const innerHandler: ExportedHandler = { }); } + // -- Admin: backfill graph edges for a repo's already-indexed wiki pages -- + // POST /admin/backfill-edges?repo=owner/repo (requires GITHUB_TOKEN header) + // Re-extracts mention edges from each wiki_doc row's stored content (no GitHub + // refetch) and writes them to doc_edges. One-off after deploy; live indexing + // keeps edges current thereafter. + if (request.method === "POST" && url.pathname === "/admin/backfill-edges") { + const authHeader = request.headers.get("GITHUB_TOKEN"); + if (!authHeader || authHeader !== env.GITHUB_TOKEN) { + return new Response("Unauthorized", { status: 401 }); + } + + const repo = url.searchParams.get("repo"); + if (!repo) { + return new Response("missing repo query parameter", { status: 400 }); + } + + let pages = 0; + let edges = 0; + try { + const rows = await env.DB_FTS + .prepare( + `SELECT vector_id, doc_path, content FROM search_docs WHERE type = 'wiki_doc' AND repo = ?`, + ) + .bind(repo) + .all<{ vector_id: string; doc_path: string; content: string }>(); + for (const r of rows.results ?? []) { + const n = await indexWikiEdges( + env.DB_FTS, + repo, + String(r.doc_path ?? ""), + String(r.vector_id ?? ""), + String(r.content ?? ""), + ); + pages++; + edges += n; + } + } catch (err) { + return new Response( + JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + return new Response( + JSON.stringify({ repo, wikiPagesProcessed: pages, edgesWritten: edges }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + // -- MCP endpoint (OAuth-protected, ctx.props set by OAuthProvider) -- if (url.pathname.startsWith("/mcp")) { const props = readGitHubProps(ctx); diff --git a/src/mcp.ts b/src/mcp.ts index 2cfead1..b8e0356 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -37,6 +37,7 @@ import { type FtsFilter, } from "./fts.js"; import { rerankCandidates, RERANK_MAX_CANDIDATES } from "./rerank.js"; +import { queryNeighbors, getDocsByVectorIds } from "./graph.js"; /** User context passed via props from OAuth layer */ interface McpProps extends Record { @@ -236,6 +237,25 @@ export class RagMcpAgentV2 extends McpAgent { `${INCLUDE_CONTENT_MAX_DOCS} doc rows in the result set to bound API fan-out. ` + "Non-doc rows are unaffected.", ), + graph_expand: z + .boolean() + .optional() + .default(false) + .describe( + "Opt-in GraphRAG expansion (search mode only). When true, after fusion the top " + + "results seed a traversal of the Decision-Structure mention graph (D1 doc_edges); " + + "related wiki pages are appended as extra results marked with graph_hop / graph_from. " + + "Default false = byte-identical to standard hybrid retrieval (no graph read).", + ), + graph_hops: z + .number() + .min(1) + .max(2) + .optional() + .default(1) + .describe( + "Graph traversal depth for graph_expand (1 or 2). Default 1. Ignored when graph_expand is false.", + ), }, async ({ query, @@ -252,11 +272,15 @@ export class RagMcpAgentV2 extends McpAgent { since, until, include_content, + graph_expand, + graph_hops, }) => { const requestedTopK = top_k ?? 10; const fusionMode = fusion ?? "rrf"; const rerankEnabled = rerank ?? true; const trimmedQuery = (query ?? "").trim(); + const graphExpand = graph_expand ?? false; + const graphHops = graph_hops ?? 1; const isScanMode = trimmedQuery.length === 0; const effectiveSort = sort ?? (isScanMode ? "updated_desc" : "relevance"); @@ -1005,6 +1029,8 @@ export class RagMcpAgentV2 extends McpAgent { review_id?: number; line?: number; content?: string; + graph_hop?: number; + graph_from?: string; }; const items: ResultItem[] = filtered.map((f) => { @@ -1187,6 +1213,80 @@ export class RagMcpAgentV2 extends McpAgent { await this.inlineDocContent(items); } + // ── Optional graph expansion (opt-in; default off leaves everything + // above byte-identical). Seeds from the final result set, traverses the + // Decision-Structure mention graph, and appends related wiki pages as + // extra results marked with graph_hop / graph_from. Best-effort: any + // failure returns the organic results unchanged. + let graphNeighborsAdded = 0; + if (graphExpand && filtered.length > 0) { + try { + const seedIds = filtered.map((f) => f.vectorId); + const seedSet = new Set(seedIds); + const neighbors = await queryNeighbors(this.env.DB_FTS, seedIds, { + hops: graphHops, + repo, + limit: Math.min(requestedTopK * 2, 30), + }); + const fresh = neighbors.filter((n) => !seedSet.has(n.vectorId)); + if (fresh.length > 0) { + const enrich = await getDocsByVectorIds( + this.env.DB_FTS, + fresh.map((n) => n.vectorId), + ); + const slugOf = (vid: string): string => { + const p = payload.get(vid); + return p?.meta?.wiki_path ?? p?.ftsRow?.docPath ?? vid; + }; + for (const n of fresh) { + const row = enrich.get(n.vectorId); + if (!row) continue; // dangling edge (target not indexed) — skip + const nRepo = String(row.repo ?? ""); + const nType = String(row.type ?? ""); + const nPath = String(row.doc_path ?? ""); + const url = + nType === "wiki_doc" && nRepo && nPath + ? `https://github.com/${nRepo}/wiki/${nPath}` + : nType === "doc" && nRepo && nPath + ? `https://github.com/${nRepo}/blob/HEAD/${nPath}` + : ""; + const item: ResultItem = { + number: Number(row.number ?? 0), + title: nPath || n.vectorId, + state: String(row.state ?? ""), + type: nType, + labels: [], + milestone: String(row.milestone ?? ""), + assignees: [], + score: 0, + dense_score: null, + sparse_score: null, + dense_rank: null, + sparse_rank: null, + rerank_score: null, + url, + updated_at: String(row.updated_at ?? ""), + repo: nRepo, + graph_hop: n.hop, + graph_from: slugOf(n.fromVectorId), + }; + if (nType === "wiki_doc") item.wiki_path = nPath; + if (nType === "doc") item.doc_path = nPath; + if (includeContent && typeof row.content === "string") { + item.content = row.content; + } + items.push(item); + graphNeighborsAdded++; + } + } + } catch (graphErr) { + console.error( + "graph_expand failed (returning organic results):", + graphErr instanceof Error ? graphErr.message : String(graphErr), + ); + } + } + return { content: [ { @@ -1210,6 +1310,8 @@ export class RagMcpAgentV2 extends McpAgent { rerank_applied: rerankApplied, since: since ?? null, until: until ?? null, + graph_expanded: graphExpand, + graph_neighbors: graphNeighborsAdded, results: items, }, null, diff --git a/src/pipeline/embed-doc.ts b/src/pipeline/embed-doc.ts index ac1b193..5cad10c 100644 --- a/src/pipeline/embed-doc.ts +++ b/src/pipeline/embed-doc.ts @@ -9,6 +9,7 @@ import type { Env, DocRecord, WikiDocRecord } from "../types.js"; import { upsertFtsRow } from "../fts.js"; +import { indexWikiEdges } from "../graph.js"; import { prepareEmbeddingInput, sha256Hex } from "./hash.js"; import { generateEmbedding } from "./embedding.js"; import { docVectorId, wikiDocVectorId } from "./vector-id.js"; @@ -191,6 +192,18 @@ export async function processAndUpsertWikiDoc( ); } + // Extract + index graph "mention" edges to other wiki pages in this repo. + // Additive: the default retrieval path never reads these. Failures here must + // not break indexing of the page itself. + try { + await indexWikiEdges(env.DB_FTS, repo, pageName, wvid, content); + } catch (edgeErr) { + console.error( + `Failed to index graph edges for wiki ${repo}/${pageName}:`, + edgeErr instanceof Error ? edgeErr.message : String(edgeErr), + ); + } + const contentHash = await sha256Hex(content); const record: WikiDocRecord = { repo, diff --git a/src/poller.ts b/src/poller.ts index 8ca896b..69d1aa0 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -34,6 +34,7 @@ import { type GitHubPRReviewCommentData, } from "./pipeline.js"; import { deleteFtsRow } from "./fts.js"; +import { deleteEdgesForVector } from "./graph.js"; /** GitHub API page size */ const PER_PAGE = 100; @@ -1205,6 +1206,15 @@ async function pollWiki( ftsErr instanceof Error ? ftsErr.message : String(ftsErr), ); } + // Remove graph edges touching this page (its out-edges and any in-edges). + try { + await deleteEdgesForVector(env.DB_FTS, wvid); + } catch (edgeErr) { + console.error( + `Failed to delete graph edges for wiki ${repo}/${wikiDoc.pageName}:`, + edgeErr instanceof Error ? edgeErr.message : String(edgeErr), + ); + } await storeStub.fetch( new Request( `http://store/wiki-doc?repo=${encodeURIComponent(repo)}&page=${encodeURIComponent(wikiDoc.pageName)}`,