'POST', 'callback' => [ __CLASS__, 'rest_query' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'args' => [ 'query' => [ 'type' => 'string', 'required' => true ], 'asterion_file' => [ 'type' => 'string', 'required' => false ], ], ] ); } public static function maybe_enqueue( $hook ) { if ( ! current_user_can( 'manage_options' ) ) return; $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; // Asterion bubble: any SA-Orchestration admin page (the Explorer is a subpage, // but Asterion is also useful from Demand Trace / Acceptance Events). if ( strpos( $page, 'sa-orchestration' ) !== 0 ) return; wp_enqueue_style( self::HANDLE_CSS, SA_ORCH_URL . 'assets/asterion-bubble.css', [], SA_ORCH_VERSION ); wp_enqueue_script( self::HANDLE_JS, SA_ORCH_URL . 'assets/asterion-bubble.js', [], SA_ORCH_VERSION, true ); wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ASTERION', [ 'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/asterion/query' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'page' => $page, ] ); } /* -------------------- REST handler -------------------- */ public static function rest_query( WP_REST_Request $request ) { $query = (string) $request->get_param( 'query' ); $asterion_file = sanitize_text_field( (string) $request->get_param( 'asterion_file' ) ); if ( trim( $query ) === '' ) { return new WP_REST_Response( [ 'error' => 'empty_query', 'message' => 'query is required', ], 400 ); } $result = self::query( $query, $asterion_file ); if ( is_wp_error( $result ) ) { return new WP_REST_Response( [ 'error' => $result->get_error_code(), 'message' => $result->get_error_message(), ], 502 ); } return new WP_REST_Response( $result, 200 ); } /* -------------------- Core query -------------------- */ /** * Run a corpus query and return a structured Asterion response. * * Response shape: * { * answer: string, // citation-grounded answer or "no canonical reference found" * citations: array, // [{ file, excerpt }, ...] * scope: string, // "single_file:" | "spec_corpus" | "no_corpus" * usage: array|null, * log_id: int|null * } */ public static function query( string $query, string $asterion_file = '' ) { if ( ! class_exists( 'SA_Orch_Asterion_Explorer' ) ) { return new WP_Error( 'no_corpus', 'Asterion Explorer not loaded.' ); } $root = SA_Orch_Asterion_Explorer::spec_root(); if ( ! is_dir( $root ) ) { return [ 'answer' => 'no canonical reference found in the spec corpus (corpus root unreachable)', 'citations' => [], 'scope' => 'no_corpus', 'usage' => null, 'log_id' => null, ]; } // Determine search scope. $scope = 'spec_corpus'; $bound_md = null; if ( $asterion_file !== '' ) { $resolved = SA_Orch_Asterion_Explorer::validate_relative_path( $asterion_file ); if ( is_array( $resolved ) ) { $bound_md = $resolved; $scope = 'single_file:' . $resolved['relative']; } } // Retrieve excerpts. $excerpts = $bound_md ? self::search_single_file( $bound_md['absolute'], $bound_md['relative'], $query ) : self::search_corpus( $root, $query ); // No matches → refuse to speculate. if ( empty( $excerpts ) ) { return [ 'answer' => 'no canonical reference found in the spec corpus', 'citations' => [], 'scope' => $scope, 'usage' => null, 'log_id' => null, ]; } // Frame with LLM (Model B). If LLM is disabled or fails, fall through // to raw-excerpt mode so the surface still works. $framed = self::frame_with_llm( $query, $excerpts, $scope ); if ( is_array( $framed ) ) { return $framed + [ 'citations' => $excerpts, 'scope' => $scope, ]; } // Fallback: raw excerpts as the "answer" when LLM unavailable. $answer_lines = [ 'Citation-only mode (LLM unavailable). Raw excerpts:' ]; foreach ( $excerpts as $i => $ex ) { $answer_lines[] = sprintf( '[%d] %s', $i + 1, $ex['file'] ); } return [ 'answer' => implode( "\n", $answer_lines ), 'citations' => $excerpts, 'scope' => $scope, 'usage' => null, 'log_id' => null, ]; } /* -------------------- Search -------------------- */ /** * Search the entire spec corpus for terms in $query. * Returns up to TOP_K excerpts ranked by hit score. */ private static function search_corpus( string $root, string $query ): array { $terms = self::extract_terms( $query ); if ( empty( $terms ) ) return []; $hits = []; self::walk_corpus( $root, '', function ( $abs, $rel ) use ( $terms, &$hits ) { $content = @file_get_contents( $abs ); if ( $content === false ) return; $score = 0; foreach ( $terms as $t ) { $score += substr_count( strtolower( $content ), $t ); } if ( $score > 0 ) { $hits[] = [ 'file' => $rel, 'score' => $score, 'excerpt' => self::best_excerpt( $content, $terms ), ]; } } ); usort( $hits, function ( $a, $b ) { return $b['score'] - $a['score']; } ); $hits = array_slice( $hits, 0, self::TOP_K ); $out = []; foreach ( $hits as $h ) { $out[] = [ 'file' => $h['file'], 'excerpt' => $h['excerpt'] ]; } return $out; } /** * Search a single bound MD file. */ private static function search_single_file( string $abs, string $rel, string $query ): array { $content = @file_get_contents( $abs ); if ( $content === false ) return []; $terms = self::extract_terms( $query ); if ( empty( $terms ) ) return []; $score = 0; foreach ( $terms as $t ) { $score += substr_count( strtolower( $content ), $t ); } if ( $score === 0 ) return []; return [ [ 'file' => $rel, 'excerpt' => self::best_excerpt( $content, $terms ), ] ]; } private static function walk_corpus( string $abs, string $rel_prefix, callable $cb ) { $entries = @scandir( $abs ); if ( $entries === false ) return; foreach ( $entries as $e ) { if ( $e === '.' || $e === '..' ) continue; $a = $abs . '/' . $e; $r = $rel_prefix === '' ? $e : $rel_prefix . '/' . $e; if ( is_dir( $a ) ) { self::walk_corpus( $a, $r, $cb ); } elseif ( is_file( $a ) && substr( strtolower( $e ), -3 ) === '.md' ) { $cb( $a, $r ); } } } /** * Extract lowercased search terms from the query. * Strips stopwords/punctuation; minimum 3-char tokens. */ private static function extract_terms( string $query ): array { $q = strtolower( $query ); $q = preg_replace( '/[^a-z0-9_\-\s]/', ' ', $q ); $tokens = preg_split( '/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY ); $stop = [ 'the','and','but','for','are','was','were','have','has','had','will', 'with','this','that','from','what','which','about','they','them', 'their','our','your','his','her','its','some','any','all','can', 'how','why','when','where','who','whom','does','did','done','not', 'into','onto','off','out','only','also','each','more','less','very', 'such','than','then','than','these','those','say','says','said' ]; $out = []; foreach ( $tokens as $t ) { if ( strlen( $t ) < 3 ) continue; if ( in_array( $t, $stop, true ) ) continue; $out[ $t ] = true; } return array_keys( $out ); } /** * Find the best excerpt window in $content given $terms. * * Strategy: pick the position with the highest local term-density across a * sliding window — not just the first match. File headers and credit blurbs * often contain isolated keyword hits; the actual definition is further down. * Windowing on density beats first-match for citation quality. */ private static function best_excerpt( string $content, array $terms ): string { $lower = strtolower( $content ); $len = strlen( $lower ); if ( $len === 0 ) return ''; // Collect all match positions for any term. $positions = []; foreach ( $terms as $t ) { $off = 0; while ( ( $p = strpos( $lower, $t, $off ) ) !== false ) { $positions[] = $p; $off = $p + max( 1, strlen( $t ) ); } } if ( empty( $positions ) ) { // No match — fall back to first chars return function_exists( 'mb_substr' ) ? mb_substr( $content, 0, self::EXCERPT_CHARS ) : substr( $content, 0, self::EXCERPT_CHARS ); } sort( $positions ); // Slide a window across positions; pick the position whose window // contains the most match positions (density). $window = self::EXCERPT_CHARS; $best_pos = $positions[0]; $best_count = 0; foreach ( $positions as $p ) { $count = 0; foreach ( $positions as $q ) { if ( $q >= $p && $q < $p + $window ) $count++; if ( $q >= $p + $window ) break; } if ( $count > $best_count ) { $best_count = $count; $best_pos = $p; } } // Center window on best_pos (back off slightly so match isn't at edge) $half = (int) ( $window / 4 ); // back off 25%, not 50% — anchor near top of window $start = max( 0, $best_pos - $half ); $exc = function_exists( 'mb_substr' ) ? mb_substr( $content, $start, $window ) : substr( $content, $start, $window ); if ( $start > 0 ) $exc = '… ' . $exc; if ( $start + $window < strlen( $content ) ) $exc .= ' …'; return $exc; } /* -------------------- LLM framing -------------------- */ /** * Frame the excerpts as a librarian-tone, citation-grounded answer. * Returns array { answer, usage, log_id } on success, null on failure. */ private static function frame_with_llm( string $query, array $excerpts, string $scope ) { if ( ! class_exists( 'SA_Orch_LLM_Settings' ) ) return null; $settings = SA_Orch_LLM_Settings::get(); if ( empty( $settings['enabled'] ) || empty( $settings['api_token'] ) || empty( $settings['api_base_url'] ) || empty( $settings['model'] ) ) { return null; } $url = rtrim( (string) $settings['api_base_url'], '/' ) . '/chat/completions'; $excerpt_block = "Excerpts retrieved from the spec corpus (verbatim; do not invent beyond these):\n\n"; foreach ( $excerpts as $i => $ex ) { $n = $i + 1; $excerpt_block .= "[CITATION {$n}] file: {$ex['file']}\n"; $excerpt_block .= "----\n"; $excerpt_block .= $ex['excerpt'] . "\n"; $excerpt_block .= "----\n\n"; } $messages = [ [ 'role' => 'system', 'content' => self::system_prompt() ], [ 'role' => 'user', 'content' => $excerpt_block . "User question:\n\n" . $query ], ]; $payload = [ 'model' => (string) $settings['model'], 'messages' => $messages, 'response_format' => [ 'type' => 'json_object' ], 'temperature' => 0.1, ]; $response = wp_remote_post( $url, [ 'headers' => [ 'Authorization' => 'Bearer ' . (string) $settings['api_token'], 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $payload ), 'timeout' => self::REQUEST_TIMEOUT, ] ); if ( is_wp_error( $response ) ) return null; $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) return null; $body = (string) wp_remote_retrieve_body( $response ); $envelope = json_decode( $body, true ); if ( ! is_array( $envelope ) || ! isset( $envelope['choices'][0]['message']['content'] ) ) return null; $content = (string) $envelope['choices'][0]['message']['content']; $parsed = json_decode( $content, true ); if ( ! is_array( $parsed ) ) return null; $answer = isset( $parsed['answer'] ) ? (string) $parsed['answer'] : ''; if ( $answer === '' ) { $answer = 'no canonical reference found in the spec corpus'; } // Token log $usage = isset( $envelope['usage'] ) && is_array( $envelope['usage'] ) ? $envelope['usage'] : null; $log_id = null; if ( $usage !== null && class_exists( 'SA_Orch_Token_Log' ) ) { $log_id = SA_Orch_Token_Log::record( 'asterion', (string) $settings['model'], $usage, (int) get_current_user_id() ); $log_id = $log_id > 0 ? $log_id : null; } return [ 'answer' => $answer, 'usage' => $usage, 'log_id' => $log_id, ]; } /** * Asterion's system prompt — librarian role. * Structurally distinct from Sara's prompt: * - no interpretation tone, no synthesis, no advice * - citation-grounded, refusal-to-speculate, file-named * - returns { answer: string } JSON; no summary/suggestion/draft/notes */ public static function system_prompt(): string { return "ASTERION — canonical librarian for the SA-Core spec corpus.\n" . "\n" . "You are NOT Sara. You are NOT an interpreter. You are NOT an advisor.\n" . "You are the verifier: a strict, citation-grounded surface over\n" . "Archvie/sa-core/docs/specs/. Your job is to ground every claim in\n" . "specific source excerpts that have been provided to you, and to\n" . "refuse if the answer is not present in those excerpts.\n" . "\n" . "TONE\n" . " - Direct. Citation-led. No interpretive framing. No synthesis tone.\n" . " - No \"I think\", no \"likely\", no \"probably\", no advisory voice.\n" . " - Reference the corpus by file name + section. Use phrases like\n" . " \"per 02-invariants.md, I2 ...\" — name the file and the labeled\n" . " element.\n" . "\n" . "OUTPUT (strict JSON; no other keys)\n" . " { \"answer\": string }\n" . "\n" . "RULES\n" . " - If the excerpts contain the answer: state it directly and cite the\n" . " file(s) and labeled elements (I1, I2, R-O-1, named sections). Quote\n" . " short phrases verbatim where it adds verifiability.\n" . " - If the excerpts do NOT contain the answer (the question is off-topic,\n" . " or the corpus is silent): set answer to exactly:\n" . " \"no canonical reference found in the spec corpus\"\n" . " Do not speculate. Do not extrapolate. Do not invent.\n" . " - You may NOT introduce concepts not present in the excerpts.\n" . " - You may NOT advise the user on what to do.\n" . " - You may NOT use Sara's structured 4-key shape. Only { \"answer\" }.\n" . "\n" . "Sara interprets. You verify. The Human arbitrates.\n"; } }