RcmEmailAutomation / sa-orchestration /includes /class-sa-seed-generator.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
22.8 kB
<?php
defined( 'ABSPATH' ) || exit;
/**
* Sara — Asterion Seed Generator (Board #26)
*
* Locked principle:
* Sara generates seeds. Asterion validates seeds. Human plants seeds.
*
* A "seed" is structured markdown — project momentum distilled into a
* non-canonical .md file ready for human review and (later) deliberate
* promotion into the Asterion canonical corpus. The seed is NEVER written
* to the canonical spec pack by this system; promotion is a separate,
* explicit human act that lives outside this Board's scope.
*
* 7 INTENT questions resolved (simplest valid implementation):
* (a) Storage: wp-content/sa-asterion-seeds/<project_slug>/
* (b) Filename: <yyyy-mm-dd>-<topic>.md, with frontmatter
* (c) Trigger: REST endpoint + admin submenu page (no auto-emission)
* (d) Project: Fluent Boards board_id
* (e) Strategy: Sara-mediated synthesis (LLM via SA_Orch_Sara_Service)
* (f) Promote: DEFERRED — out of scope for #26
* (g) Index: NO new table; pure-filesystem listing
*
* Hard boundaries:
* - No mutation to Archvie/sa-core/docs/specs/
* - No mutation to fbs_tasks / fbs_comments
* - No new schema; uses filesystem only
* - No fluent state changes
*/
class SA_Orch_Seed_Generator {
const SUBMENU_SLUG = 'sa-orchestration-seeds';
/* -------------------- Hooks -------------------- */
public static function register_routes() {
register_rest_route( 'sa-orch/v1', '/seed/generate', [
'methods' => 'POST',
'callback' => [ __CLASS__, 'rest_generate' ],
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => [
'board_id' => [ 'type' => 'integer', 'required' => true ],
'topic' => [ 'type' => 'string', 'required' => false ],
],
] );
register_rest_route( 'sa-orch/v1', '/seed/list', [
'methods' => 'GET',
'callback' => [ __CLASS__, 'rest_list' ],
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
] );
}
public static function register_menu() {
add_submenu_page(
SA_Orch_Admin::PARENT_SLUG,
'Asterion Seeds',
'Asterion Seeds',
SA_Orch_Admin::CAP,
self::SUBMENU_SLUG,
[ __CLASS__, 'render_page' ]
);
}
/* -------------------- Storage paths -------------------- */
public static function seeds_root(): string {
$base = trailingslashit( WP_CONTENT_DIR ) . 'sa-asterion-seeds';
$base = apply_filters( 'sa_orch_seed_root', $base );
return rtrim( str_replace( '\\', '/', (string) $base ), '/' );
}
private static function ensure_dir( string $abs ): bool {
if ( is_dir( $abs ) ) return true;
return wp_mkdir_p( $abs );
}
private static function slug( string $s ): string {
$s = sanitize_title( $s );
$s = $s !== '' ? $s : 'seed';
return $s;
}
/* -------------------- REST -------------------- */
public static function rest_generate( WP_REST_Request $request ) {
$board_id = (int) $request->get_param( 'board_id' );
$topic = sanitize_text_field( (string) $request->get_param( 'topic' ) );
if ( $board_id <= 0 ) {
return new WP_REST_Response( [
'error' => 'invalid_board_id',
'message' => 'board_id must be a positive integer',
], 400 );
}
$result = self::generate( $board_id, $topic );
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 );
}
public static function rest_list( WP_REST_Request $request ) {
return new WP_REST_Response( [ 'seeds' => self::list_seeds() ], 200 );
}
/* -------------------- Generation -------------------- */
/**
* Generate a seed packet for a Fluent Board.
*
* Steps:
* 1. Read project momentum from fbs_tasks (counts by stage, recent activity).
* 2. Build a structured analysis prompt for Sara.
* 3. Call SA_Orch_Sara_Service::invoke() — INTERPRETIVE-mode signals
* embedded in the prompt so synthesis (not paraphrase) is produced.
* 4. Compose final markdown with YAML frontmatter (artifact_class: seed,
* accepted_into_asterion: false) + Sara's synthesis as body.
* NOTE: artifact_class — NOT truth_class. Seeds are non-canonical
* proposed candidates that pre-date truth classification. I2's
* truth_class enum (canonical | projected | cached | inferred |
* derived | synthetic) stays sacred and is not widened to admit
* seeds; seeds get their own classification dimension.
* 5. Write to wp-content/sa-asterion-seeds/<slug>/<date>-<topic>.md.
* 6. Return the file path + relative path + summary stats.
*/
public static function generate( int $board_id, string $topic = '' ) {
global $wpdb;
// 1. Project metadata
$board = $wpdb->get_row( $wpdb->prepare(
"SELECT id, title FROM {$wpdb->prefix}fbs_boards WHERE id = %d",
$board_id
), ARRAY_A );
if ( ! $board ) {
return new WP_Error( 'board_not_found', "Board {$board_id} not found." );
}
// 2. Tasks on this board (parent only, non-archived)
$tasks = $wpdb->get_results( $wpdb->prepare(
"SELECT id, title, stage_id, status, priority, last_completed_at, created_at, updated_at
FROM {$wpdb->prefix}fbs_tasks
WHERE board_id = %d AND (parent_id IS NULL OR parent_id = 0) AND archived_at IS NULL
ORDER BY id ASC",
$board_id
), ARRAY_A );
// Stage label map
$stages = $wpdb->get_results( $wpdb->prepare(
"SELECT id, title FROM {$wpdb->prefix}fbs_board_terms WHERE board_id = %d AND type = 'stage'",
$board_id
), ARRAY_A );
$slabel = [];
foreach ( $stages as $s ) $slabel[ (int) $s['id'] ] = (string) $s['title'];
// 3. Build momentum analysis (deterministic, pre-LLM)
$momentum = self::build_momentum( $tasks, $slabel );
// 4. Build prompt for Sara (INTERPRETIVE mode trigger via doctrine vocabulary)
$analysis_block = self::format_momentum_for_prompt( $board, $momentum, $tasks, $slabel );
$sara_prompt = "Synthesize an Asterion seed packet for the project below.\n"
. "\n"
. "This is a doctrine-grade seed: the output will be a non-canonical\n"
. "markdown candidate for human review and possible promotion into the\n"
. "Asterion canonical spec corpus. Speak in interpretive mode — name\n"
. "the underlying thesis of the project's momentum, identify accepted\n"
. "invariants implied by the closed Boards, surface deferred items as\n"
. "their own structural class, and propose 2–4 specific Asterion\n"
. "additions (each as a one-line proposed canonical addition).\n"
. "\n"
. "Return the structured 4-key Sara shape. Use:\n"
. " summary — 2-4 sentence thesis of the project's current state\n"
. " suggestion — one specific architectural observation worth surfacing to Asterion\n"
. " draft — the proposed Asterion addition(s), markdown-formatted, one or more\n"
. " headed sections (## Proposed addition: …) ready to paste into a spec file\n"
. " notes — open questions, deferred items, attribution flags, low-confidence calls\n"
. "\n"
. "Project analysis (pre-computed):\n"
. "\n"
. $analysis_block;
// 5. Call Sara
$sara = SA_Orch_Sara_Service::invoke(
[ 'surface' => null, 'ref' => null ],
$sara_prompt,
[],
'sa-orchestration-seeds',
''
);
if ( is_wp_error( $sara ) ) {
return $sara;
}
$resp = isset( $sara['response'] ) && is_array( $sara['response'] ) ? $sara['response'] : [];
$log_id = $sara['log_id'] ?? null;
// 6. Compose final MD with frontmatter
$project_slug = self::slug( $board['title'] ?: ('board-' . $board_id) );
$date_utc = gmdate( 'Y-m-d' );
$time_utc = gmdate( 'Y-m-d\TH:i:s\Z' );
$topic_slug = $topic !== '' ? self::slug( $topic ) : ('momentum-' . substr( md5( $time_utc ), 0, 6 ));
$relative = $project_slug . '/' . $date_utc . '-' . $topic_slug . '.md';
$abs_dir = self::seeds_root() . '/' . $project_slug;
$abs_file = self::seeds_root() . '/' . $relative;
if ( ! self::ensure_dir( $abs_dir ) ) {
return new WP_Error( 'mkdir_failed', "Could not create seed directory {$abs_dir}" );
}
$md = self::compose_markdown(
$board,
$project_slug,
$time_utc,
$log_id,
$momentum,
$resp
);
$written = @file_put_contents( $abs_file, $md );
if ( $written === false ) {
return new WP_Error( 'write_failed', "Could not write seed file {$abs_file}" );
}
return [
'ok' => true,
'board_id' => $board_id,
'board_title' => $board['title'],
'project_slug' => $project_slug,
'relative' => $relative,
'absolute' => $abs_file,
'bytes' => strlen( $md ),
'log_id' => $log_id,
'momentum' => $momentum,
'sara_response'=> $resp,
];
}
/* -------------------- Helpers -------------------- */
private static function build_momentum( array $tasks, array $slabel ): array {
$by_stage = [];
$by_status = [];
$closed_recent = [];
$open_now = [];
$deferred = [];
$now = time();
$week = 7 * 24 * 3600;
foreach ( $tasks as $t ) {
$sn = $slabel[ (int) $t['stage_id'] ] ?? ('stage_' . $t['stage_id']);
$by_stage[ $sn ] = ( $by_stage[ $sn ] ?? 0 ) + 1;
$by_status[ $t['status'] ] = ( $by_status[ $t['status'] ] ?? 0 ) + 1;
if ( $t['status'] === 'closed' && $t['last_completed_at'] ) {
$ts = strtotime( $t['last_completed_at'] );
if ( $ts && $now - $ts <= $week ) {
$closed_recent[] = $t;
}
}
if ( $t['status'] !== 'closed' ) {
$open_now[] = $t;
}
if ( strpos( strtolower( (string) $t['title'] ), '[queue]' ) !== false ) {
$deferred[] = $t;
}
}
return [
'task_count' => count( $tasks ),
'by_stage' => $by_stage,
'by_status' => $by_status,
'closed_in_week' => count( $closed_recent ),
'open_now' => count( $open_now ),
'deferred' => count( $deferred ),
'closed_recent_titles' => array_map( function ( $t ) {
return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ];
}, $closed_recent ),
'open_now_titles' => array_map( function ( $t ) {
return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ];
}, $open_now ),
];
}
private static function format_momentum_for_prompt( array $board, array $momentum, array $tasks, array $slabel ): string {
$out = "Project: {$board['title']} (board_id={$board['id']})\n";
$out .= "Total tasks: {$momentum['task_count']}\n";
$out .= "Closed in last 7 days: {$momentum['closed_in_week']}\n";
$out .= "Currently open: {$momentum['open_now']}\n";
$out .= "Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n";
$out .= "\n";
$out .= "By stage:\n";
foreach ( $momentum['by_stage'] as $k => $n ) {
$out .= " {$k}: {$n}\n";
}
$out .= "\n";
$out .= "Recently closed (last 7 days):\n";
if ( empty( $momentum['closed_recent_titles'] ) ) {
$out .= " (none)\n";
} else {
foreach ( $momentum['closed_recent_titles'] as $r ) {
$out .= " - [#{$r['id']}] {$r['title']}\n";
}
}
$out .= "\n";
$out .= "Currently open:\n";
if ( empty( $momentum['open_now_titles'] ) ) {
$out .= " (none)\n";
} else {
foreach ( $momentum['open_now_titles'] as $r ) {
$out .= " - [#{$r['id']}] {$r['title']}\n";
}
}
$out .= "\n";
return $out;
}
/**
* Build the final seed markdown:
* - YAML frontmatter (artifact_class: seed; accepted_into_asterion: false)
* - Header with project + generation context
* - Momentum block (deterministic stats)
* - Sara synthesis (4 fields rendered as headed sections)
* - Footer noting non-canonical status + planting instructions
*
* artifact_class is intentionally NOT truth_class. Seeds are non-canonical
* proposed candidates predating truth classification. I2's enum stays
* sacred (canonical | projected | cached | inferred | derived | synthetic).
*/
private static function compose_markdown( array $board, string $project_slug, string $time_utc, $log_id, array $momentum, array $sara_resp ): string {
$title = (string) $board['title'];
$bid = (int) $board['id'];
$log = $log_id !== null ? (int) $log_id : 'null';
$md = "---\n";
$md .= "artifact_class: seed\n";
$md .= "accepted_into_asterion: false\n";
$md .= "project: " . self::yaml_escape( $title ) . "\n";
$md .= "project_slug: {$project_slug}\n";
$md .= "board_id: {$bid}\n";
$md .= "generated_at: {$time_utc}\n";
$md .= "sara_log_id: {$log}\n";
$md .= "generator: SA_Orch_Seed_Generator\n";
$md .= "---\n\n";
$md .= "# Asterion seed: {$title}\n\n";
$md .= "> **Non-canonical.** This file is a Sara-generated seed packet for human review.\n";
$md .= "> It has not been planted into the Asterion canonical spec corpus.\n";
$md .= "> Promotion is a separate, deliberate act outside this generator's scope.\n\n";
$md .= "## Project momentum (deterministic)\n\n";
$md .= "- Total tasks: {$momentum['task_count']}\n";
$md .= "- Closed in last 7 days: {$momentum['closed_in_week']}\n";
$md .= "- Currently open: {$momentum['open_now']}\n";
$md .= "- Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n\n";
$md .= "By stage:\n\n";
foreach ( $momentum['by_stage'] as $k => $n ) {
$md .= "- `{$k}`: {$n}\n";
}
$md .= "\n";
$md .= "## Sara synthesis (interpretive)\n\n";
$sum = trim( (string) ( $sara_resp['summary'] ?? '' ) );
$sug = trim( (string) ( $sara_resp['suggestion'] ?? '' ) );
$drf = trim( (string) ( $sara_resp['draft'] ?? '' ) );
$note = trim( (string) ( $sara_resp['notes'] ?? '' ) );
$md .= "### Thesis\n\n";
$md .= ( $sum !== '' ? $sum : '_(none)_' ) . "\n\n";
$md .= "### Architectural observation\n\n";
$md .= ( $sug !== '' ? $sug : '_(none)_' ) . "\n\n";
$md .= "### Proposed Asterion addition(s)\n\n";
$md .= ( $drf !== '' ? $drf : '_(none)_' ) . "\n\n";
$md .= "### Open questions / deferred / flags\n\n";
$md .= ( $note !== '' ? $note : '_(none)_' ) . "\n\n";
$md .= "---\n\n";
$md .= "## Planting (manual)\n\n";
$md .= "If accepted: review the proposed additions above, copy any that survive review into\n";
$md .= "`Archvie/sa-core/docs/specs/` (the canonical corpus), commit, and update this seed's\n";
$md .= "frontmatter `accepted_into_asterion: true` (the generator will not flip that flag).\n";
$md .= "If rejected: the seed remains in `wp-content/sa-asterion-seeds/` as a record that the\n";
$md .= "synthesis was attempted; nothing in canonical Asterion changes.\n";
return $md;
}
private static function yaml_escape( string $s ): string {
// YAML-safe single-line: wrap in double quotes, escape backslash + quotes.
$s = str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s );
return '"' . $s . '"';
}
/* -------------------- Listing -------------------- */
/**
* List existing seed files (filesystem-only; no index table per question (g)).
*/
public static function list_seeds(): array {
$root = self::seeds_root();
if ( ! is_dir( $root ) ) return [];
$out = [];
$projects = @scandir( $root );
if ( $projects === false ) return [];
foreach ( $projects as $p ) {
if ( $p === '.' || $p === '..' ) continue;
$abs = $root . '/' . $p;
if ( ! is_dir( $abs ) ) continue;
$files = @scandir( $abs );
if ( $files === false ) continue;
foreach ( $files as $f ) {
if ( $f === '.' || $f === '..' ) continue;
if ( substr( strtolower( $f ), -3 ) !== '.md' ) continue;
$rel = $p . '/' . $f;
$st = @stat( $abs . '/' . $f );
$out[] = [
'project_slug' => $p,
'file' => $f,
'relative' => $rel,
'size' => $st ? (int) $st['size'] : 0,
'mtime' => $st ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $st['mtime'] ) : '',
];
}
}
usort( $out, function ( $a, $b ) { return strcmp( $b['mtime'], $a['mtime'] ); } );
return $out;
}
/* -------------------- Admin page -------------------- */
public static function render_page() {
if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' );
// Handle synchronous form submission
$result = null;
$error = '';
if ( isset( $_POST['sa_orch_seed_generate'] ) && check_admin_referer( 'sa_orch_seed_generate' ) ) {
$bid = isset( $_POST['board_id'] ) ? (int) $_POST['board_id'] : 0;
$topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
if ( $bid <= 0 ) {
$error = 'board_id required';
} else {
$r = self::generate( $bid, $topic );
if ( is_wp_error( $r ) ) {
$error = $r->get_error_message();
} else {
$result = $r;
}
}
}
global $wpdb;
$boards = $wpdb->get_results( "SELECT id, title FROM {$wpdb->prefix}fbs_boards ORDER BY id", ARRAY_A );
$seeds = self::list_seeds();
echo '<div class="wrap">';
echo '<h1>Asterion Seeds <span style="font-size:13px;color:#777;font-weight:400;">— non-canonical seed packets generated by Sara</span></h1>';
echo '<p><em>Sara generates. Asterion validates. Human plants.</em></p>';
echo '<p>Seed root: <code>' . esc_html( self::seeds_root() ) . '</code></p>';
if ( $error !== '' ) {
echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>';
}
if ( $result ) {
echo '<div class="notice notice-success"><p>Seed written to <code>' . esc_html( $result['relative'] ) . '</code> ('
. (int) $result['bytes'] . ' bytes).</p></div>';
}
echo '<h2>Generate a new seed</h2>';
echo '<form method="post">';
wp_nonce_field( 'sa_orch_seed_generate' );
echo '<table class="form-table">';
echo '<tr><th><label for="sa-orch-seed-board">Project (Fluent Board)</label></th><td>';
echo '<select id="sa-orch-seed-board" name="board_id" required>';
echo '<option value="">— select board —</option>';
foreach ( $boards as $b ) {
echo '<option value="' . (int) $b['id'] . '">[#' . (int) $b['id'] . '] ' . esc_html( $b['title'] ) . '</option>';
}
echo '</select>';
echo '</td></tr>';
echo '<tr><th><label for="sa-orch-seed-topic">Topic (optional, becomes filename slug)</label></th><td>';
echo '<input id="sa-orch-seed-topic" name="topic" type="text" class="regular-text" placeholder="e.g. midweek-momentum">';
echo '</td></tr>';
echo '</table>';
echo '<p><button type="submit" name="sa_orch_seed_generate" value="1" class="button button-primary">Generate seed</button></p>';
echo '<p style="color:#666;font-size:12px;">Generation calls the configured LLM provider (Sara). One LLM call per generation; cost logged in <code>wp_sa_token_log</code>.</p>';
echo '</form>';
echo '<h2>Existing seeds (' . count( $seeds ) . ')</h2>';
if ( empty( $seeds ) ) {
echo '<p>No seeds generated yet.</p>';
} else {
echo '<table class="widefat striped"><thead><tr>';
echo '<th>Project</th><th>File</th><th>Size</th><th>Generated (UTC)</th>';
echo '</tr></thead><tbody>';
foreach ( $seeds as $s ) {
echo '<tr>';
echo '<td><code>' . esc_html( $s['project_slug'] ) . '</code></td>';
echo '<td><code>' . esc_html( $s['file'] ) . '</code></td>';
echo '<td>' . (int) $s['size'] . '</td>';
echo '<td>' . esc_html( $s['mtime'] ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
echo '<p style="color:#666;font-size:12px;">Files live on disk under the seed root. No DB index (per resolved question (g)).</p>';
}
echo '</div>';
}
}