/** * Shared parser for `platform/neo4j/schema.cypher`. * * Two consumers today: * 1. The admin SCHEMA prompt block (`platform/ui/app/lib/admin-schema-block.ts`) * renders labels declared in schema.cypher into the agent's * system prompt. * 2. The memory-plugin schema validator treats schema.cypher * declarations as the "promise" half of the recognised-label set — * labels that exist as constraints/indexes but may not yet have a node * in the graph (fresh-install bootstrap). * * The previous home was admin-schema-block.ts; that owner was wrong as * soon as a second consumer arrived. Lifting here keeps a single source of * truth and removes the cross-package shape coupling. * * Also exports a compact `levenshtein` for "did you mean?" suggestions on * unknown labels — used by the memory-plugin validator. The graph-mcp * cypher validator has its own copy in schema-cache.ts (private to the * stale-miss heuristic); the duplication is intentional, since this * exported one targets human-readable error UX, not refresh debouncing. */ // Matches both declaration forms: // FOR (p:Person) single-label constraint / index // FOR (n:Person|Organization|...) the entity_search_admin fulltext union, // which may wrap across several lines // Before Task 1893 only the single form was matched, so seven labels declared // solely in the union (AdminConversation, AssistantMessage, EmailAccount, // FAQPage, OpeningHoursSpecification, PublicConversation, UserMessage) read as // undeclared to every consumer of this parser. // Backticks are allowed in the label run because Cypher escapes a label whose // name collides with a reserved word that way — `:`Order`` and `:`Case`` are // declared escaped in schema.cypher. The backticks are quoting, not part of the // label, so they are stripped below. const LABEL_PATTERN = /FOR\s*\(\s*\w+\s*:\s*([A-Za-z0-9_|\s`]+?)\s*\)/g; /** * Extract every Neo4j label declared in a `schema.cypher` file by scanning * `FOR (alias:Label)` and `FOR (alias:A|B|C)` constraint and index forms. * Returns a sorted, de-duped list. Pure function — no I/O. */ export function parseLabelsFromSchemaCypher(schemaCypher: string): string[] { const found = new Set(); for (const match of schemaCypher.matchAll(LABEL_PATTERN)) { for (const part of match[1].split("|")) { const label = part.replace(/`/g, "").trim(); if (label) found.add(label); } } return [...found].sort(); } /** * Standard iterative-DP Levenshtein. Used to suggest a near-match when an * agent submits an unknown label — small alphabets, short strings, runs in * microseconds. Identical algorithm to the private copy in schema-cache.ts; * exported here so callers outside graph-mcp don't have to roll their own. */ export function levenshtein(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; let prev = new Array(b.length + 1); let curr = new Array(b.length + 1); for (let j = 0; j <= b.length; j++) prev[j] = j; for (let i = 1; i <= a.length; i++) { curr[0] = i; for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[b.length]; } /** * Find the closest label in `candidates` to `unknown` by edit distance. * Returns null when the closest match is further than `maxDistance` (default * 3) — beyond that a suggestion is more confusing than helpful. */ export function nearestLabel( unknown: string, candidates: Iterable, maxDistance = 3, ): string | null { let best: string | null = null; let bestDist = Infinity; for (const candidate of candidates) { const d = levenshtein(unknown, candidate); if (d < bestDist) { bestDist = d; best = candidate; } } if (best === null || bestDist > maxDistance) return null; return best; }