RcmEmailAutomation / sa-orchestration /includes /class-sa-asterion-projection.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
19.1 kB
<?php
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";
}
}