From d8087cf012cb56ab48f893a5aa290edc58c4a9fb Mon Sep 17 00:00:00 2001 From: Mathieu Lamiot Date: Sun, 17 May 2026 13:43:53 +0200 Subject: [PATCH 1/3] feat(ai): include PHP error aggregated events in AI summary prompt (#59) Extends AI_Summarizer::generate_summary() with an optional $aggregated_events parameter. When non-empty, build_prompt() appends a "PHP Errors" section listing up to 5 error signatures (level, message, occurrence count). Dashboard_Widget and Reports_Page AJAX handlers now fetch aggregated events via Aggregated_Event_Repository::get_rows_for_report() before calling the summarizer, using null for the active-period sentinel (report_id = 0) and the frozen report_id otherwise. Co-Authored-By: Claude Sonnet 4.6 --- lib/Tests/Unit/AI/AISummarizerTest.php | 64 +++++++++++++++++++ lib/ai/class-ai-summarizer.php | 35 +++++++--- .../Tests/Unit/Admin/DashboardWidgetTest.php | 2 + .../Tests/Unit/Admin/ReportsPageTest.php | 6 +- wp-plugin/admin/class-dashboard-widget.php | 9 +-- wp-plugin/admin/class-reports-page.php | 9 +-- 6 files changed, 107 insertions(+), 18 deletions(-) diff --git a/lib/Tests/Unit/AI/AISummarizerTest.php b/lib/Tests/Unit/AI/AISummarizerTest.php index 78287bc..18a2608 100644 --- a/lib/Tests/Unit/AI/AISummarizerTest.php +++ b/lib/Tests/Unit/AI/AISummarizerTest.php @@ -154,4 +154,68 @@ 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 ); + } } diff --git a/lib/ai/class-ai-summarizer.php b/lib/ai/class-ai-summarizer.php index 9b2e6ed..8cc9733 100644 --- a/lib/ai/class-ai-summarizer.php +++ b/lib/ai/class-ai-summarizer.php @@ -64,14 +64,15 @@ public function __construct( Report_Repository $report_repo, Event_Registry $eve /** * Generate AI summary for events. * - * @param array> $events Array of events. - * @param array $totals Event totals by type. - * @param array> $trends Trend data comparing to previous report. + * @param array> $events Array of events. + * @param array $totals Event totals by type. + * @param array> $trends Trend data comparing to previous report. + * @param array> $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 { @@ -87,12 +88,13 @@ public function generate_summary( array $events, array $totals, array $trends ): /** * Build the prompt for the AI provider. * - * @param array> $events Array of events. - * @param array $totals Event totals by type. - * @param array> $trends Trend data. + * @param array> $events Array of events. + * @param array $totals Event totals by type. + * @param array> $trends Trend data. + * @param array> $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. '; @@ -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. '; diff --git a/wp-plugin/Tests/Unit/Admin/DashboardWidgetTest.php b/wp-plugin/Tests/Unit/Admin/DashboardWidgetTest.php index 1d68290..d7b0836 100644 --- a/wp-plugin/Tests/Unit/Admin/DashboardWidgetTest.php +++ b/wp-plugin/Tests/Unit/Admin/DashboardWidgetTest.php @@ -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. @@ -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. diff --git a/wp-plugin/Tests/Unit/Admin/ReportsPageTest.php b/wp-plugin/Tests/Unit/Admin/ReportsPageTest.php index 36de364..8a3ae08 100644 --- a/wp-plugin/Tests/Unit/Admin/ReportsPageTest.php +++ b/wp-plugin/Tests/Unit/Admin/ReportsPageTest.php @@ -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. @@ -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.' ); diff --git a/wp-plugin/admin/class-dashboard-widget.php b/wp-plugin/admin/class-dashboard-widget.php index b3fc878..44cf2e9 100644 --- a/wp-plugin/admin/class-dashboard-widget.php +++ b/wp-plugin/admin/class-dashboard-widget.php @@ -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' ) ) ); diff --git a/wp-plugin/admin/class-reports-page.php b/wp-plugin/admin/class-reports-page.php index 75e726a..1518ab2 100644 --- a/wp-plugin/admin/class-reports-page.php +++ b/wp-plugin/admin/class-reports-page.php @@ -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' ) ) ); From 64bec3b9eb5efcd309e891a52efef5868a110ab1 Mon Sep 17 00:00:00 2001 From: Mathieu Lamiot Date: Thu, 28 May 2026 21:35:37 +0200 Subject: [PATCH 2/3] docs(ai): document aggregated events in AI_Summarizer prompt composition Add prompt composition section to ai-transport.md explaining the four parameters of generate_summary() and the four-section prompt structure including the new PHP Errors section. Update report-lifecycle.md AI Summary flow to list the aggregated events fetch step and note the PHP Errors prompt section. Co-Authored-By: Claude Sonnet 4.6 --- lib/docs/ai-transport.md | 22 ++++++++++++++++++++++ lib/docs/report-lifecycle.md | 9 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/docs/ai-transport.md b/lib/docs/ai-transport.md index 1869c60..6196ba9 100644 --- a/lib/docs/ai-transport.md +++ b/lib/docs/ai-transport.md @@ -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: diff --git a/lib/docs/report-lifecycle.md b/lib/docs/report-lifecycle.md index e6fb6aa..59fc47d 100644 --- a/lib/docs/report-lifecycle.md +++ b/lib/docs/report-lifecycle.md @@ -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. From 00a62f2d9532d5722093f494387f1361f581d181 Mon Sep 17 00:00:00 2001 From: Mathieu Lamiot Date: Thu, 28 May 2026 21:47:19 +0200 Subject: [PATCH 3/3] test(ai): add cap-at-5 test for PHP Errors section in build_prompt Verifies that build_prompt() never emits more than 5 entries in the PHP Errors section even when more than 5 aggregated event rows are provided. Co-Authored-By: Claude Sonnet 4.6 --- lib/Tests/Unit/AI/AISummarizerTest.php | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/Tests/Unit/AI/AISummarizerTest.php b/lib/Tests/Unit/AI/AISummarizerTest.php index 18a2608..bc8f878 100644 --- a/lib/Tests/Unit/AI/AISummarizerTest.php +++ b/lib/Tests/Unit/AI/AISummarizerTest.php @@ -218,4 +218,32 @@ public function test_generate_summary_passes_aggregated_events_to_transport() { $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 ); + } }