diff --git a/lib/Tests/Unit/AI/AISummarizerTest.php b/lib/Tests/Unit/AI/AISummarizerTest.php index 78287bc..bc8f878 100644 --- a/lib/Tests/Unit/AI/AISummarizerTest.php +++ b/lib/Tests/Unit/AI/AISummarizerTest.php @@ -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 ); + } } 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/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. 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' ) ) );