diff --git a/README.md b/README.md index 3ece605..66c1a65 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,8 @@ missing historical endpoint. Snapshot times can differ from the requested window marked approximate/incomplete rather than asserted as exact daily totals. GitHub title enrichment preserves event-time status; a later merge cannot turn an earlier close into a merge event. -The owner still reviews every draft. The local LLM fact-check is best effort: if it fails, the draft -is retained and the failure is logged. GitHub feeds/search and Discord gateway observations are +The owner still reviews every draft. If the enabled local LLM fact-check fails, the generated prose is replaced with a +source-based activity list and the approval message explains that the factual review failed. +Supporter-update creation stops when its factual review fails; retry after the model is available. GitHub feeds/search and Discord gateway observations are bounded sources, not a complete audit of all work. This does not establish inactivity outside the recorded evidence. No new environment settings are required for these measurement rules. diff --git a/src/Newsletter/Writer.php b/src/Newsletter/Writer.php index bef4bb2..7fdb43c 100644 --- a/src/Newsletter/Writer.php +++ b/src/Newsletter/Writer.php @@ -99,13 +99,20 @@ public function write(Window $window, array $reports, array $previous = []): Pro ->then(function () use ($window, $reports, &$notes, $previous): PromiseInterface { return $this->compose($window, $reports, $notes, $previous); }) - ->then(function (array $composed) use (&$notes, $previous, $reports): PromiseInterface { + ->then(function (array $composed) use (&$notes, $previous, $reports, $window): PromiseInterface { if ($composed['fallback'] !== null || ! $this->factCheck || $notes === []) { return resolve($composed); } return $this->review($composed['draft'], $notes, $previous, $reports) - ->then(static fn(Draft $draft): array => ['draft' => $draft, 'fallback' => null]); + ->then( + static fn(Draft $draft): array => ['draft' => $draft, 'fallback' => null], + function (\Throwable $error) use ($window, $reports, &$notes): array { + $this->logger->warning('Fact-check failed; using the source-based fallback: ' . $error->getMessage()); + return ['draft' => self::fallback($window, $reports, $notes, $this->author), + 'fallback' => 'The factual review could not be completed. This draft uses the recorded source facts instead.']; + }, + ); }) ->then(function (array $composed) use (&$notes): array { return ['draft' => $composed['draft'], 'notes' => $notes, 'fallback' => $composed['fallback']]; @@ -203,7 +210,7 @@ function (\Throwable $e) use ($window, $reports, $notes): array { /** * Fact-checks $draft against original reports (saved notes for legacy editions) * and returns a corrected draft. Any - * failure keeps the original: a missed check is better than a lost draft. + * failure rejects; callers must explicitly choose a source-based fallback or retry. * * @param array $notes * @param list> $previous Published editions, newest first. @@ -239,11 +246,7 @@ public function review(Draft $draft, array $notes, array $previous = [], array $ author's voice; do not pad the corrected draft to its original length or publish raw error diagnostics. Reply with ONLY the corrected newsletter, in exactly the same Markdown format. PROMPT], - ], 0.1, false)->then(null, function (\Throwable $e) use ($draft): Draft { - $this->logger->warning("Fact-check pass failed, keeping the draft: {$e->getMessage()}"); - - return $draft; - }); + ], 0.1, false); } /** diff --git a/tests/WriterTest.php b/tests/WriterTest.php index 73ace74..93185aa 100644 --- a/tests/WriterTest.php +++ b/tests/WriterTest.php @@ -81,11 +81,25 @@ public function testQuietDayNeedsNoModel(): void $this->assertSame([], $this->prompts); } - public function testFactCheckFailureKeepsTheDraft(): void + public function testFactCheckFailureFallsBackToOriginalSourcesAndWarnsTheOwner(): void { - $writer = new Writer($this->fakeLlm(['notes', self::DRAFT, 'garbage']), 'Valithor'); + $invented = '{"headline":"Released version 99","intro":"I released version 99 with every API supported.","sections":[],"signoff":""}'; + foreach (['garbage', new \RuntimeException('private transport diagnostic')] as $failure) { + $writer = new Writer($this->fakeLlm(['Invented notes', $invented, $failure]), 'Valithor'); + $written = self::settle($writer->write($this->window(), $this->reports())); + $text = $written['draft']->toMarkdown(); + self::assertStringNotContainsString('version 99', $text); + self::assertStringContainsString('merged pull request #1400', $text); + self::assertStringContainsString('factual review could not be completed', $written['fallback']); + self::assertStringNotContainsString('private transport diagnostic', $written['fallback']); + } + } - $this->assertSame('Polls land in DiscordPHP', self::settle($writer->write($this->window(), $this->reports()))['draft']->headline); + public function testSupporterReviewFailureRejectsInsteadOfReturningUncheckedContent(): void + { + $writer = new Writer($this->fakeLlm([self::DRAFT, new \RuntimeException('Review unavailable')]), 'Valithor'); + $this->expectExceptionMessage('Review unavailable'); + self::settle($writer->supporterUpdate(Draft::fromLlmJson(self::DRAFT))); } public function testReviseSendsTheInstructionsAndCurrentDraft(): void