Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Asterion Projection — Board #30 | |
| * | |
| * Asterion's website-facing query surface, separate from Sara. | |
| * | |
| * Sara interprets. Asterion verifies. Two distinct surfaces, same source. | |
| * | |
| * Asterion responses are: | |
| * - grounded in the canonical MD spec corpus (Archvie/sa-core/docs/specs/) | |
| * - cite specific files (and excerpts) by name | |
| * - refuse to speculate beyond the source | |
| * - return "no canonical reference found" when corpus does not contain the answer | |
| * | |
| * No write capability anywhere. Read-only by construction. | |
| * | |
| * Architecture: | |
| * - REST endpoint /sa-orch/v1/asterion/query | |
| * - Keyword search across spec corpus + LLM-framed citation answer | |
| * - Reuses SA_Orch_Asterion_Explorer::spec_root() and validate_relative_path() | |
| * - Reuses LLM provider stack (SA_Orch_LLM_Settings, wp_remote_post) | |
| * - Reuses wp_sa_token_log (Board #16) for usage accounting | |
| * - Distinct system prompt (librarian role; structurally different shape from Sara) | |
| * | |
| * Front-end: assets/asterion-bubble.js + assets/asterion-bubble.css (separate | |
| * floating button, top-right, distinct visual identity from Sara). | |
| */ | |
| class SA_Orch_Asterion_Projection { | |
| const HANDLE_JS = 'sa-orch-asterion-bubble'; | |
| const HANDLE_CSS = 'sa-orch-asterion-bubble'; | |
| const REQUEST_TIMEOUT = 30; | |
| /** Number of excerpts to return from corpus search. */ | |
| const TOP_K = 5; | |
| /** Max chars per excerpt window included in the LLM prompt. | |
| * Wide enough to give the LLM real context around the match — file headers and | |
| * one-paragraph blurbs aren't enough to ground a Truth-Class-style answer. */ | |
| const EXCERPT_CHARS = 1800; | |
| /* -------------------- Hooks -------------------- */ | |
| public static function register_hooks() { | |
| add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue' ] ); | |
| } | |
| public static function register_routes() { | |
| register_rest_route( 'sa-orch/v1', '/asterion/query', [ | |
| 'methods' => '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:<rel>" | "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"; | |
| } | |
| } | |