{value}" lines at chunk/upload time (DD5). * - pivot() — the PARSER: the DD6 trigger, run after retrieval pass 1. * Writer and parser living together is what keeps the format a contract * instead of a coincidence (test-pinned round trip). * * Pure static string/array logic — no WP state, no I/O — deliberately on * the exercisable side of the test-harness boundary. */ class AnswerRails_Focus_Resolver { /** * The canonical entry separator. Changing this breaks every synced * lookup item's chunks until re-sync — treat as frozen. */ public const SEPARATOR = ' -> '; /** * DD6: exactly one pivot per turn — determinism over cleverness. The * #143 bench is the instrument that would justify raising this. */ public const MAX_PIVOTS = 1; /* ================================================================== * The WRITER (DD5) — declared JSON → canonical entry lines * ================================================================== */ /** * Render a declared lookup item's JSON as canonical entry lines. * * Supported shapes: * - FLAT OBJECT of scalars ({"Laurie": "Clan MacLaren", ...}) — the * declared field names are ignored (property name → property value * is the only reading). * - ARRAY OF OBJECTS ([{"surname": "Laurie", "clan": "Clan MacLaren"}, * ...]) — the owner's declared key/value field names select the two * columns. Rows missing either field are SKIPPED (a sparse row is * data, not an error); zero usable rows is an error. * * PATHS: real customer JSON nests the mapping inside a richer document (the * Clan Name Relationships file holds it at `sept_lookup` beside * knowledge_base_info / business_guidance). `$path` (dot-separated * keys) selects the mapping NODE; the flat/array logic then applies to * that node. '' = the whole document is the mapping (unchanged). * * @param string $json The item's stored JSON content. * @param string $key_field Declared key field (array-of-objects shape). * @param string $value_field Declared value field (array-of-objects shape). * @param string $path Dot-path to the mapping node ('' = root). * @return string|null Entry-line text, or null when the declaration * does not fit the content (callers surface the * typed `lookup_fields_missing` error). */ public static function entry_lines($json, $key_field = '', $value_field = '', $path = '') { $document = json_decode((string) $json, true); if (!is_array($document) || empty($document)) { return null; } $decoded = self::node_at($document, $path); if (!is_array($decoded) || empty($decoded)) { return null; } $lines = array(); if (array_keys($decoded) !== range(0, count($decoded) - 1)) { // Associative shape: flat key → value over scalar values only. foreach ($decoded as $key => $value) { if (!is_scalar($value)) { continue; } $key = self::clean((string) $key); $value = self::clean((string) $value); if ('' === $key || '' === $value) { continue; } $lines[] = $key . self::SEPARATOR . $value; } return empty($lines) ? null : implode("\n", $lines); } // Sequential shape: array of objects with declared fields. $key_field = trim((string) $key_field); $value_field = trim((string) $value_field); if ('' === $key_field || '' === $value_field) { return null; } foreach ($decoded as $row) { if (!is_array($row)) { continue; } if (!isset($row[$key_field]) || !isset($row[$value_field])) { continue; } if (!is_scalar($row[$key_field]) || !is_scalar($row[$value_field])) { continue; } $key = self::clean((string) $row[$key_field]); $value = self::clean((string) $row[$value_field]); if ('' === $key || '' === $value) { continue; } $lines[] = $key . self::SEPARATOR . $value; } return empty($lines) ? null : implode("\n", $lines); } /** * The full GROUNDING text for a declared lookup item: the mapping node * rendered as canonical entry lines PLUS * the REMAINDER of the document — nothing the owner stored is lost or * ignored at retrieval time (business guidance, usage notes and the * rest keep grounding as ordinary chunks). Whole-document mappings * ('' path) have no remainder and return the lines alone. * * The trigger stays safe with mixed chunks: pivot extraction matches * only the canonical " -> " line format, which JSON text (colons, * braces) never produces. * * @return string|null Null iff the declaration does not fit (same * contract as entry_lines()). */ public static function grounding_text($json, $key_field = '', $value_field = '', $paths = '') { // MULTI-SECTION: `$paths` is a // comma-separated list of cross-reference sections declared on ONE // item — the alternative (loading the same document twice with // different sections) would duplicate the remainder's chunks and // make them compete with themselves in the top-N. Every declared // section must fit or the whole declaration is rejected (typed // error, no partial silently-working declarations). Known // limitation, documented: section KEYS containing '.' or ',' cannot // be addressed (dot is the path separator, comma the list // separator). $list = array_values(array_filter(array_map('trim', explode(',', (string) $paths)), 'strlen')); if (empty($list)) { return self::entry_lines($json, $key_field, $value_field); } $blocks = array(); foreach ($list as $path) { $lines = self::entry_lines($json, $key_field, $value_field, $path); if (null === $lines) { return null; } $blocks[] = $lines; } $document = json_decode((string) $json, true); foreach ($list as $path) { self::remove_node($document, $path); } $combined = implode("\n", $blocks); if (empty($document)) { return $combined; } $remainder = wp_json_encode($document, JSON_PRETTY_PRINT); return is_string($remainder) && '' !== $remainder ? $combined . "\n\n" . $remainder : $combined; } /** * Resolve a dot-path inside decoded JSON ('' = the document itself). * * @param array $document Decoded JSON (assoc). * @param string $path Dot-separated keys. * @return mixed|null The node, or null when any segment is missing. */ private static function node_at(array $document, $path) { $path = trim((string) $path); if ('' === $path) { return $document; } $node = $document; foreach (explode('.', $path) as $segment) { if (!is_array($node) || !array_key_exists($segment, $node)) { return null; } $node = $node[$segment]; } return $node; } /** * Remove the node at a dot-path (for the remainder rendering). * * @param array $document Decoded JSON, modified in place. * @param string $path Dot-separated keys ('' is never passed). */ private static function remove_node(array &$document, $path) { $segments = explode('.', trim((string) $path)); $last = array_pop($segments); $node =& $document; foreach ($segments as $segment) { if (!is_array($node) || !array_key_exists($segment, $node)) { return; } $node =& $node[$segment]; } if (is_array($node)) { unset($node[$last]); } } /* ================================================================== * The PARSER (DD6) — the deterministic second-pass trigger * ================================================================== */ /** * Resolve the pivot term for a retrieval pass, or null when the * trigger conditions are not met. No model call decides this. * * Conditions (GROUNDING_COMPLETENESS_DESIGN): * 2. A lookup chunk contains an entry line whose KEY side matches the * query (every key token present in the query's token set, * case-insensitive). Longest key match wins (most tokens, then * longest string, then first encountered — fully deterministic). * 3. The entry's VALUE side (the pivot) is UNCOVERED: no other * injected text contains it. If the focused content already made * it into pass 1, a second pass is redundant. * * (Condition 1 — the chunk belongs to a lookup-flagged item — is the * caller's job: it owns item identity on each engine.) * * @param string $query The visitor's query text. * @param string[] $lookup_texts Injected chunk texts from lookup items. * @param string[] $other_texts All OTHER injected chunk texts. * @return string|null The pivot term, or null (no second pass). */ public static function pivot($query, array $lookup_texts, array $other_texts) { if (empty($lookup_texts)) { return null; } $query_tokens = self::tokens((string) $query); if (empty($query_tokens)) { return null; } $best = null; $best_tokens = 0; $best_length = 0; foreach ($lookup_texts as $text) { foreach (explode("\n", (string) $text) as $line) { $pos = strpos($line, self::SEPARATOR); if (false === $pos) { continue; } $key = trim(substr($line, 0, $pos)); $value = trim(substr($line, $pos + strlen(self::SEPARATOR))); if ('' === $key || '' === $value) { continue; } $key_tokens = self::tokens($key); if (empty($key_tokens) || array_diff($key_tokens, $query_tokens)) { continue; // key does not match the query } $n_tokens = count($key_tokens); $length = strlen($key); if ( null === $best || $n_tokens > $best_tokens || ($n_tokens === $best_tokens && $length > $best_length) ) { $best = $value; $best_tokens = $n_tokens; $best_length = $length; } } } if (null === $best) { return null; } // Condition 3: uncovered — the pivot must not already be present in // any other injected block (case-insensitive). $needle = self::fold($best); foreach ($other_texts as $text) { if (false !== strpos(self::fold((string) $text), $needle)) { return null; } } return $best; } /** * Lowercased word tokens (letters/digits, length >= 2). * * @param string $text * @return string[] */ private static function tokens($text) { preg_match_all('/[\p{L}\p{N}]{2,}/u', self::fold($text), $matches); return array_values(array_unique($matches[0])); } /** * Case fold for matching. */ private static function fold($text) { return function_exists('mb_strtolower') ? mb_strtolower((string) $text, 'UTF-8') : strtolower((string) $text); } /** * One-line cleanup for entry sides (a key/value with a newline would * corrupt the line format; the separator inside a side would corrupt * parsing — collapse both). */ private static function clean($side) { $side = preg_replace('/\s+/u', ' ', (string) $side); $side = str_replace(self::SEPARATOR, ' - ', $side); return trim($side); } }