Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions lib/Tests/Unit/AI/AISummarizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,96 @@ public function test_build_prompt_creates_proper_structure() {
public function test_ai_summarizer_can_be_instantiated() {
$this->assertInstanceOf( AI_Summarizer::class, $this->summarizer );
}

/**
* Test build_prompt includes PHP Errors section when aggregated events are provided.
*/
public function test_build_prompt_includes_php_errors_section_when_aggregated_events_present() {
$aggregated_events = array(
array(
'dimensions' => json_encode( array( 'level' => 'warning', 'signature' => 'abc123' ) ),
'total' => '5',
'meta' => json_encode( array( 'file' => '/var/www/wp-config.php', 'line' => 42, 'message' => 'Undefined variable bar' ) ),
),
array(
'dimensions' => json_encode( array( 'level' => 'fatal_error', 'signature' => 'def456' ) ),
'total' => '1',
'meta' => json_encode( array( 'file' => '/var/www/plugin.php', 'line' => 10, 'message' => 'Call to undefined function foo()' ) ),
),
);

$reflection = new \ReflectionClass( $this->summarizer );
$method = $reflection->getMethod( 'build_prompt' );
$method->setAccessible( true );

$prompt = $method->invoke( $this->summarizer, array(), array(), array(), $aggregated_events );

$this->assertStringContainsString( '## PHP Errors', $prompt );
$this->assertStringContainsString( 'Warning: Undefined variable bar', $prompt );
$this->assertStringContainsString( '5 occurrences', $prompt );
$this->assertStringContainsString( 'Fatal Error: Call to undefined function foo()', $prompt );
$this->assertStringContainsString( '1 occurrence', $prompt );
}

/**
* Test build_prompt excludes PHP Errors section when aggregated events are empty.
*/
public function test_build_prompt_excludes_php_errors_section_when_aggregated_events_empty() {
$reflection = new \ReflectionClass( $this->summarizer );
$method = $reflection->getMethod( 'build_prompt' );
$method->setAccessible( true );

$prompt = $method->invoke( $this->summarizer, array(), array(), array(), array() );

$this->assertStringNotContainsString( '## PHP Errors', $prompt );
}

/**
* Test generate_summary passes aggregated events through to build_prompt.
*/
public function test_generate_summary_passes_aggregated_events_to_transport() {
$this->transport->shouldReceive( 'complete' )
->once()
->andReturn( 'Summary with errors' );

$aggregated_events = array(
array(
'dimensions' => json_encode( array( 'level' => 'notice', 'signature' => 'aaa' ) ),
'total' => '3',
'meta' => json_encode( array( 'file' => '/app/foo.php', 'line' => 1, 'message' => 'Array to string conversion' ) ),
),
);

$result = $this->summarizer->generate_summary( array(), array(), array(), $aggregated_events );

$this->assertSame( 'Summary with errors', $result );
}

/**
* Test build_prompt caps PHP Errors section at 5 entries when more than 5 aggregated events are provided.
*/
public function test_build_prompt_caps_php_errors_section_at_five_entries() {
$aggregated_events = array();
for ( $i = 1; $i <= 7; $i++ ) {
$aggregated_events[] = array(
'dimensions' => json_encode( array( 'level' => 'warning', 'signature' => 'sig' . $i ) ),
'total' => (string) ( 10 - $i ),
'meta' => json_encode( array( 'file' => '/app/file.php', 'line' => $i, 'message' => 'Error message ' . $i ) ),
);
}

$reflection = new \ReflectionClass( $this->summarizer );
$method = $reflection->getMethod( 'build_prompt' );
$method->setAccessible( true );

$prompt = $method->invoke( $this->summarizer, array(), array(), array(), $aggregated_events );

// Entries 1–5 should be present.
for ( $i = 1; $i <= 5; $i++ ) {
$this->assertStringContainsString( 'Error message ' . $i, $prompt );
}
// Entries 6 and 7 must be absent (capped at 5).
$this->assertStringNotContainsString( 'Error message 6', $prompt );
$this->assertStringNotContainsString( 'Error message 7', $prompt );
}
}
35 changes: 26 additions & 9 deletions lib/ai/class-ai-summarizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ public function __construct( Report_Repository $report_repo, Event_Registry $eve
/**
* Generate AI summary for events.
*
* @param array<int, array<string, mixed>> $events Array of events.
* @param array<string, int> $totals Event totals by type.
* @param array<string, array<string, mixed>> $trends Trend data comparing to previous report.
* @param array<int, array<string, mixed>> $events Array of events.
* @param array<string, int> $totals Event totals by type.
* @param array<string, array<string, mixed>> $trends Trend data comparing to previous report.
* @param array<int, array<string, string>> $aggregated_events Aggregated event rows (e.g. PHP errors) from Aggregated_Event_Repository::get_rows_for_report().
* @return string|null AI-generated summary or null if transport fails.
*/
public function generate_summary( array $events, array $totals, array $trends ): ?string {
public function generate_summary( array $events, array $totals, array $trends, array $aggregated_events = array() ): ?string {
// Build the prompt.
$prompt = $this->build_prompt( $events, $totals, $trends );
$prompt = $this->build_prompt( $events, $totals, $trends, $aggregated_events );

// Call transport.
try {
Expand All @@ -87,12 +88,13 @@ public function generate_summary( array $events, array $totals, array $trends ):
/**
* Build the prompt for the AI provider.
*
* @param array<int, array<string, mixed>> $events Array of events.
* @param array<string, int> $totals Event totals by type.
* @param array<string, array<string, mixed>> $trends Trend data.
* @param array<int, array<string, mixed>> $events Array of events.
* @param array<string, int> $totals Event totals by type.
* @param array<string, array<string, mixed>> $trends Trend data.
* @param array<int, array<string, string>> $aggregated_events Aggregated event rows (e.g. PHP errors).
* @return string The prompt.
*/
private function build_prompt( array $events, array $totals, array $trends ): string {
private function build_prompt( array $events, array $totals, array $trends, array $aggregated_events = array() ): string {
$prompt = 'You are a friendly coworker reviewing WordPress site activity for the week. ';
$prompt .= "Write a conversational summary as if you're telling a colleague what happened on their website. ";
$prompt .= 'Be warm, encouraging, and focus on the most important changes. ';
Expand Down Expand Up @@ -144,6 +146,21 @@ private function build_prompt( array $events, array $totals, array $trends ): st
}
}

// Add PHP errors section if aggregated events are present.
if ( ! empty( $aggregated_events ) ) {
$prompt .= "\n## PHP Errors\n";
$top_errors = array_slice( $aggregated_events, 0, 5 );
foreach ( $top_errors as $row ) {
$dimensions = json_decode( $row['dimensions'], true );
$meta = json_decode( $row['meta'], true );
$level = isset( $dimensions['level'] ) ? ucwords( str_replace( '_', ' ', (string) $dimensions['level'] ) ) : 'Unknown';
$message = isset( $meta['message'] ) ? (string) $meta['message'] : '';
$count = (int) $row['total'];
$prompt .= "- {$level}: {$message} — {$count} occurrence" . ( 1 === $count ? '' : 's' ) . "\n";
}
$prompt .= "\n";
}

$prompt .= "\n## Instructions\n";
$prompt .= 'Write a friendly 3-5 sentence summary highlighting the most important activities. ';
$prompt .= 'Mention trends if significant. Use a warm, encouraging tone. ';
Expand Down
22 changes: 22 additions & 0 deletions lib/docs/ai-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ if ( is_wp_error( $result ) ) {
return $result;
```

## AI_Summarizer Prompt Composition

`AI_Summarizer::generate_summary()` accepts four parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `$events` | `array` | Singular event rows for the period |
| `$totals` | `array` | Event counts by type |
| `$trends` | `array` | Week-over-week trend data |
| `$aggregated_events` | `array` (optional, default `[]`) | Aggregated event rows from `Aggregated_Event_Repository::get_rows_for_report()` |

`build_prompt()` assembles the prompt in sections:

1. **Event Summary** — total event count and breakdown by type from `$totals`.
2. **Trends vs. Last Week** — directional changes (↑/↓) from `$trends`.
3. **Recent Events** — up to 10 most recent singular events, each formatted via `Event_Registry::get_ai_description()`.
4. **PHP Errors** — included only when `$aggregated_events` is non-empty. Lists up to 5 error signatures, each showing the error level (from `dimensions['level']`), the message snippet (from `meta['message']`), and the occurrence count (from `total`). Rows arrive pre-sorted by `total DESC` from the repository, so the top-5 are automatically the most frequent.

Callers are responsible for fetching aggregated events before calling `generate_summary()`:
- For the **current period** (active report), pass `Aggregated_Event_Repository::get_rows_for_report('php_error', null)` — `null` targets the `report_id = 0` sentinel rows.
- For a **frozen report**, pass `get_rows_for_report('php_error', $report_id)`.

## Version Gating

`Factory::create_ai_summarizer()` gates on WP 7 availability and returns `null` on WP < 7:
Expand Down
9 changes: 5 additions & 4 deletions lib/docs/report-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,11 @@ AI-generated prose summaries are generated on demand, never at freeze time. The

On the report detail page (**Sybgo Reports → View Details**), a "Generate AI Summary" button calls the AJAX action `sybgo_generate_ai_summary`, which:

1. Fetches the relevant events — for frozen reports via `Event_Repository::get_by_report($report_id)`, for active reports via `get_by_report(null)` (unassigned events).
2. Calls `Report_Generator::generate_live_summary()` to compute current totals and trends.
3. Calls `AI_Summarizer::generate_summary()` via the WP7 native AI API.
4. Persists the result. For **frozen** reports, `Report_Repository::set_ai_summary()` merges only the AI text into the existing `summary_data` JSON. For **active** reports, `Report_Repository::save_summary_data()` saves the full stats + AI text object atomically (because no frozen `summary_data` exists yet to merge into).
1. Fetches the relevant singular events — for frozen reports via `Event_Repository::get_by_report($report_id)`, for active reports via `get_by_report(null)` (unassigned events).
2. Fetches aggregated error rows via `Aggregated_Event_Repository::get_rows_for_report('php_error', ...)` — `null` for active reports (targets the `report_id = 0` sentinel), `$report_id` for frozen reports.
3. Calls `Report_Generator::generate_live_summary()` to compute current totals and trends.
4. Calls `AI_Summarizer::generate_summary()` passing both singular events and aggregated error rows. When PHP errors are present, the prompt includes a "PHP Errors" section listing up to 5 signatures with level, message, and occurrence count.
5. Persists the result. For **frozen** reports, `Report_Repository::set_ai_summary()` merges only the AI text into the existing `summary_data` JSON. For **active** reports, `Report_Repository::save_summary_data()` saves the full stats + AI text object atomically (because no frozen `summary_data` exists yet to merge into).

Once generated, the button label changes to "Regenerate AI Summary" and the text is shown inline.

Expand Down
2 changes: 2 additions & 0 deletions wp-plugin/Tests/Unit/Admin/DashboardWidgetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ public function test_ajax_widget_ai_summary_persists_summary_when_active_report_
$this->report_generator->shouldReceive( 'generate_live_summary' )
->with( array(), 5 )
->andReturn( $live_summary );
$this->aggregated_repo->shouldReceive( 'get_rows_for_report' )->with( 'php_error', null )->andReturn( array() );
$this->ai_summarizer->shouldReceive( 'generate_summary' )->andReturn( 'Widget summary.' );

// Must persist the full object.
Expand Down Expand Up @@ -277,6 +278,7 @@ public function test_ajax_widget_ai_summary_does_not_persist_when_no_active_repo
$this->report_generator->shouldReceive( 'generate_live_summary' )
->with( array(), 0 )
->andReturn( $live_summary );
$this->aggregated_repo->shouldReceive( 'get_rows_for_report' )->with( 'php_error', null )->andReturn( array() );
$this->ai_summarizer->shouldReceive( 'generate_summary' )->andReturn( 'No active report summary.' );

// Must NOT persist.
Expand Down
6 changes: 5 additions & 1 deletion wp-plugin/Tests/Unit/Admin/ReportsPageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,9 @@ public function test_ajax_generate_ai_summary_calls_set_ai_summary(): void {
$this->report_generator->shouldReceive( 'generate_live_summary' )
->with( array(), 20 )
->andReturn( array( 'totals' => array(), 'trends' => array() ) );
$this->aggregated_repo->shouldReceive( 'get_rows_for_report' )->with( 'php_error', 20 )->andReturn( array() );
$this->ai_summarizer->shouldReceive( 'generate_summary' )
->with( array(), array(), array() )
->with( array(), array(), array(), array() )
->andReturn( 'A great week!' );

// The key assertion: set_ai_summary must be called with the generated text.
Expand Down Expand Up @@ -498,6 +499,9 @@ public function test_ajax_generate_ai_summary_active_report_uses_null_and_saves_
->with( array(), 30 )
->andReturn( $live_summary );

// Must use null for active report (report_id = 0 sentinel).
$this->aggregated_repo->shouldReceive( 'get_rows_for_report' )->with( 'php_error', null )->andReturn( array() );

$this->ai_summarizer->shouldReceive( 'generate_summary' )
->andReturn( 'Active week summary.' );

Expand Down
9 changes: 5 additions & 4 deletions wp-plugin/admin/class-dashboard-widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,15 @@ public function ajax_widget_ai_summary(): void {
return; // @phpstan-ignore deadCode.unreachable
}

$events = $this->event_repo->get_by_report( null );
$active_report = $this->report_repo->get_active();
$live_summary = $this->report_generator->generate_live_summary(
$events = $this->event_repo->get_by_report( null );
$active_report = $this->report_repo->get_active();
$live_summary = $this->report_generator->generate_live_summary(
$events,
$active_report ? (int) $active_report['id'] : 0
);
$aggregated_events = $this->aggregated_repo->get_rows_for_report( 'php_error', null );

$summary = $this->ai_summarizer->generate_summary( $events, $live_summary['totals'], $live_summary['trends'] );
$summary = $this->ai_summarizer->generate_summary( $events, $live_summary['totals'], $live_summary['trends'], $aggregated_events );

if ( null === $summary ) {
wp_send_json_error( array( 'message' => __( 'The AI summary could not be generated. Please check your WordPress AI connector configuration.', 'sybgo' ) ) );
Expand Down
9 changes: 5 additions & 4 deletions wp-plugin/admin/class-reports-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -941,10 +941,11 @@ public function ajax_generate_ai_summary(): void {
return; // @phpstan-ignore deadCode.unreachable
}

$is_active = 'active' === $report['status'];
$events = $this->event_repo->get_by_report( $is_active ? null : $report_id );
$live_summary = $this->report_generator->generate_live_summary( $events, $report_id );
$ai_summary = $this->ai_summarizer->generate_summary( $events, $live_summary['totals'], $live_summary['trends'] );
$is_active = 'active' === $report['status'];
$events = $this->event_repo->get_by_report( $is_active ? null : $report_id );
$live_summary = $this->report_generator->generate_live_summary( $events, $report_id );
$aggregated_events = $this->aggregated_repo->get_rows_for_report( 'php_error', $is_active ? null : $report_id );
$ai_summary = $this->ai_summarizer->generate_summary( $events, $live_summary['totals'], $live_summary['trends'], $aggregated_events );

if ( null === $ai_summary ) {
wp_send_json_error( array( 'message' => __( 'The AI summary could not be generated. Please check your WordPress AI connector configuration.', 'sybgo' ) ) );
Expand Down
Loading