From bb036306db433e7cb85ea6904f7dae7ccae24adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Mon, 17 Aug 2026 18:57:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(insights):=20the=20radar's=20skeleton=20?= =?UTF-8?q?=E2=80=94=20entity,=20extractor=20seam,=20harvester,=20pipeline?= =?UTF-8?q?=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MailInsight rows are facts read out of mail (a parcel, a flight, a pull request), upserted by an extractor-scoped dedupe key so a carrier's five status mails are one card, and dismissal outlives every later upsert. The extractor seam is a tagged interface collected by a registry that also drives the settings page, so a new kind of insight is one class — no registry edit, no settings edit, no pipeline edit. Harvesting rides the existing post-ingest pipeline; `app:backfill insights` walks stored mail for existing mailboxes. No extractors yet: this commit is the seam. Co-Authored-By: Claude Fable 5 --- migrations/Version20260817165438.php | 53 ++++++++ src/Command/Backfill/InsightsBackfillTask.php | 113 ++++++++++++++++++ src/Domain/Enum/Insight/InsightKind.php | 43 +++++++ src/Entity/Insight/MailInsight.php | 111 +++++++++++++++++ src/Entity/User/User.php | 11 ++ .../Insight/MailInsightRepository.php | 97 +++++++++++++++ src/Service/Insight/ExtractInsightsStep.php | 43 +++++++ src/Service/Insight/InsightDraft.php | 34 ++++++ .../Insight/InsightExtractorInterface.php | 63 ++++++++++ .../Insight/InsightExtractorRegistry.php | 66 ++++++++++ src/Service/Insight/InsightHarvester.php | 99 +++++++++++++++ 11 files changed, 733 insertions(+) create mode 100644 migrations/Version20260817165438.php create mode 100644 src/Command/Backfill/InsightsBackfillTask.php create mode 100644 src/Domain/Enum/Insight/InsightKind.php create mode 100644 src/Entity/Insight/MailInsight.php create mode 100644 src/Repository/Insight/MailInsightRepository.php create mode 100644 src/Service/Insight/ExtractInsightsStep.php create mode 100644 src/Service/Insight/InsightDraft.php create mode 100644 src/Service/Insight/InsightExtractorInterface.php create mode 100644 src/Service/Insight/InsightExtractorRegistry.php create mode 100644 src/Service/Insight/InsightHarvester.php diff --git a/migrations/Version20260817165438.php b/migrations/Version20260817165438.php new file mode 100644 index 00000000..484eb2b2 --- /dev/null +++ b/migrations/Version20260817165438.php @@ -0,0 +1,53 @@ +addSql('CREATE TABLE mail_insight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, kind VARCHAR(32) NOT NULL, title VARCHAR(255) NOT NULL, payload JSON NOT NULL, happens_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, extractor VARCHAR(32) NOT NULL, dedupe_key VARCHAR(160) NOT NULL, dismissed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, account_id INT NOT NULL, thread_id INT DEFAULT NULL, message_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_248992B49B6B5FBA ON mail_insight (account_id)'); + $this->addSql('CREATE INDEX IDX_248992B4E2904019 ON mail_insight (thread_id)'); + $this->addSql('CREATE INDEX IDX_248992B4537A1329 ON mail_insight (message_id)'); + $this->addSql('CREATE INDEX idx_mail_insight_happens_at ON mail_insight (happens_at)'); + $this->addSql('CREATE INDEX idx_mail_insight_created_at ON mail_insight (created_at)'); + $this->addSql('CREATE UNIQUE INDEX uniq_mail_insight_account_dedupe ON mail_insight (account_id, dedupe_key)'); + $this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B49B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B4E2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B4537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B49B6B5FBA'); + $this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B4E2904019'); + $this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B4537A1329'); + $this->addSql('DROP TABLE mail_insight'); + } +} diff --git a/src/Command/Backfill/InsightsBackfillTask.php b/src/Command/Backfill/InsightsBackfillTask.php new file mode 100644 index 00000000..c354e0a6 --- /dev/null +++ b/src/Command/Backfill/InsightsBackfillTask.php @@ -0,0 +1,113 @@ +messages->count([]); + + if (0 === $total) { + $io->success('No mail to read.'); + + return Command::SUCCESS; + } + + $io->progressStart($total); + + $offset = 0; + $written = 0; + + while (true) { + /** @var list $batch */ + $batch = $this->messages->findBy([], ['id' => 'ASC'], self::BATCH_SIZE, $offset); + + if (0 === count($batch)) { + break; + } + + foreach ($batch as $message) { + $offset++; + + // The harvester shields per extractor; a failed flush is the + // one fault left, and the manager-closed guard below is what + // answers it. + $count = $this->harvester->harvest($message); + + if ($count > 0) { + $written += $count; + + $this->em->flush(); + } + + if (false === $this->em->isOpen()) { + $io->progressFinish(); + $io->error(sprintf( + 'Stopped at message %d: the entity manager closed, so nothing further could be saved.', + (int) $message->id, + )); + + return Command::FAILURE; + } + + $io->progressAdvance(); + } + + $this->em->clear(); + } + + $io->progressFinish(); + $io->success(sprintf('%d insight(s) written or refreshed.', $written)); + + return Command::SUCCESS; + } +} diff --git a/src/Domain/Enum/Insight/InsightKind.php b/src/Domain/Enum/Insight/InsightKind.php new file mode 100644 index 00000000..859ab719 --- /dev/null +++ b/src/Domain/Enum/Insight/InsightKind.php @@ -0,0 +1,43 @@ + 'fa-solid fa-box', + self::Flight => 'fa-solid fa-plane', + self::Ticket => 'fa-solid fa-ticket', + self::GithubIssue => 'fa-solid fa-circle-dot', + self::GithubPullRequest => 'fa-solid fa-code-pull-request', + }; + } + + /** Translation key for the user-facing name. */ + public function transKey(): string + { + return 'insights.kind.' . $this->value; + } +} diff --git a/src/Entity/Insight/MailInsight.php b/src/Entity/Insight/MailInsight.php new file mode 100644 index 00000000..34bb2cee --- /dev/null +++ b/src/Entity/Insight/MailInsight.php @@ -0,0 +1,111 @@ + per-kind detail the card template reads */ + #[ORM\Column(type: Types::JSON)] + public array $payload = []; + + /** + * When the thing happens, if it has a when at all. A flight departs, a + * ticket admits, a parcel estimates; a pull request just is — null. + */ + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $happensAt = null; + + /** Which extractor wrote this — see InsightExtractorInterface::key(). */ + #[ORM\Column(type: 'string', length: 32)] + public string $extractor = ''; + + /** Identity of the THING, not the mail — see the class doc. */ + #[ORM\Column(type: 'string', length: 160)] + public string $dedupeKey = ''; + + /** + * Waved away by the user. The row stays so its dedupe key keeps absorbing + * follow-up mails silently — deleting it would resurrect the card on the + * carrier's next status update. + */ + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + public ?DateTimeImmutable $dismissedAt = null; +} diff --git a/src/Entity/User/User.php b/src/Entity/User/User.php index 0ad64bd0..78122dc8 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -473,6 +473,17 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat */ public const string SETTING_APPEARANCE_PREVIEW_WIDTH = 'appearance.preview_width'; + /** + * Insight extractors this user has switched OFF, as a list of extractor + * keys. Disabled rather than enabled, so an extractor shipped next + * release starts working without every user finding a new toggle — the + * same rule SETTING_ADMIN_COLLAPSED_PANELS records. The catalogue of + * legal keys is the extractor registry, not this class; an unknown key in + * the list is inert rather than an error, which is what lets an extractor + * be removed from a build without a data migration. + */ + public const string SETTING_INSIGHTS_DISABLED = 'insights.disabled_extractors'; + /** * The default is 19rem — what the column was fixed at before it could move. * diff --git a/src/Repository/Insight/MailInsightRepository.php b/src/Repository/Insight/MailInsightRepository.php new file mode 100644 index 00000000..3656604e --- /dev/null +++ b/src/Repository/Insight/MailInsightRepository.php @@ -0,0 +1,97 @@ +findOneBy(['account' => $account, 'dedupeKey' => $dedupeKey]); + } + + /** + * Dated insights inside the window: a little of the recent past — a + * parcel that landed this morning is still the answer to "where is my + * parcel?" — and the near future. Undismissed, active accounts only. + * + * @return list soonest first + */ + public function upcomingForUser( + UserInterface $user, + \DateTimeImmutable $now, + int $daysAhead = 14, + int $hoursBack = 18, + int $limit = 30, + ): array { + return $this->createQueryBuilder('i') + ->join('i.account', 'a') + ->where('a.usr = :user') + ->andWhere('a.isActive = true') + ->andWhere('i.dismissedAt IS NULL') + ->andWhere('i.happensAt IS NOT NULL') + ->andWhere('i.happensAt >= :from') + ->andWhere('i.happensAt <= :until') + ->orderBy('i.happensAt', 'ASC') + ->setMaxResults($limit) + ->setParameter('user', $user) + ->setParameter('from', $now->modify(sprintf('-%d hours', $hoursBack))) + ->setParameter('until', $now->modify(sprintf('+%d days', $daysAhead))) + ->getQuery() + ->getResult(); + } + + /** + * The undated family — GitHub activity and whatever joins it. Recency is + * the only timeline these have, so the window is on createdAt and the + * newest speak first. + * + * @return list + */ + public function recentUndatedForUser( + UserInterface $user, + \DateTimeImmutable $now, + int $daysBack = 7, + int $limit = 20, + ): array { + return $this->createQueryBuilder('i') + ->join('i.account', 'a') + ->where('a.usr = :user') + ->andWhere('a.isActive = true') + ->andWhere('i.dismissedAt IS NULL') + ->andWhere('i.happensAt IS NULL') + ->andWhere('i.createdAt >= :from') + ->orderBy('i.createdAt', 'DESC') + ->setMaxResults($limit) + ->setParameter('user', $user) + ->setParameter('from', $now->modify(sprintf('-%d days', $daysBack))) + ->getQuery() + ->getResult(); + } + + /** + * Every insight a conversation carries, for the card above the message. + * Dismissed rows included on purpose: dismissal hides a card from the + * upcoming panel, not from the mail that plainly states the fact. + * + * @return list + */ + public function forThread(MessageThread $thread): array + { + return $this->findBy(['thread' => $thread], ['happensAt' => 'ASC', 'id' => 'ASC']); + } +} diff --git a/src/Service/Insight/ExtractInsightsStep.php b/src/Service/Insight/ExtractInsightsStep.php new file mode 100644 index 00000000..fe0d46eb --- /dev/null +++ b/src/Service/Insight/ExtractInsightsStep.php @@ -0,0 +1,43 @@ +messages as $message) { + $written += $this->harvester->harvest($message); + } + + if (0 === $written) { + return; + } + + $this->em->flush(); + } +} diff --git a/src/Service/Insight/InsightDraft.php b/src/Service/Insight/InsightDraft.php new file mode 100644 index 00000000..4aeca27c --- /dev/null +++ b/src/Service/Insight/InsightDraft.php @@ -0,0 +1,34 @@ + $payload per-kind card detail; keep it JSON-safe scalars + */ + public function __construct( + public InsightKind $kind, + public string $title, + public string $dedupeKey, + public array $payload = [], + public ?\DateTimeImmutable $happensAt = null, + ) { + } +} diff --git a/src/Service/Insight/InsightExtractorInterface.php b/src/Service/Insight/InsightExtractorInterface.php new file mode 100644 index 00000000..35cd21f8 --- /dev/null +++ b/src/Service/Insight/InsightExtractorInterface.php @@ -0,0 +1,63 @@ +` / `..._hint` — declared here + * only through key(); this method names the Font Awesome icon the + * settings row and the panel's section heading wear. + */ + public function icon(): string; + + /** Higher runs first; matters only when two claim the same mail. */ + public function priority(): int; + + /** The cheap gate: headers and addresses, no body parsing. */ + public function supports(Message $message): bool; + + /** + * @return list empty is the normal outcome and never an + * error: most mail states no fact worth a card + */ + public function extract(Message $message): array; +} diff --git a/src/Service/Insight/InsightExtractorRegistry.php b/src/Service/Insight/InsightExtractorRegistry.php new file mode 100644 index 00000000..69241cbd --- /dev/null +++ b/src/Service/Insight/InsightExtractorRegistry.php @@ -0,0 +1,66 @@ + $extractors */ + public function __construct( + #[AutowireIterator('app.insight_extractor')] + private iterable $extractors, + ) { + } + + /** @return list highest priority first */ + public function all(): array + { + $all = [...$this->extractors]; + + usort($all, static fn (InsightExtractorInterface $a, InsightExtractorInterface $b): int => $b->priority() <=> $a->priority()); + + return $all; + } + + /** @return list */ + public function enabledFor(User $user): array + { + $disabled = $this->disabledKeys($user); + + return array_values(array_filter( + $this->all(), + static fn (InsightExtractorInterface $extractor): bool => false === in_array($extractor::key(), $disabled, true), + )); + } + + public function isEnabledFor(User $user, string $key): bool + { + return false === in_array($key, $this->disabledKeys($user), true); + } + + /** @return list */ + private function disabledKeys(User $user): array + { + $stored = $user->getSetting(User::SETTING_INSIGHTS_DISABLED); + + return is_array($stored) ? array_values(array_filter($stored, is_string(...))) : []; + } +} diff --git a/src/Service/Insight/InsightHarvester.php b/src/Service/Insight/InsightHarvester.php new file mode 100644 index 00000000..4be75847 --- /dev/null +++ b/src/Service/Insight/InsightHarvester.php @@ -0,0 +1,99 @@ +account; + $user = $account->usr; + + if (!$user instanceof User) { + return 0; + } + + $written = 0; + + foreach ($this->registry->enabledFor($user) as $extractor) { + // Per extractor, so one parser choking on one strange mail costs + // its own facts and nobody else's. + try { + if (false === $extractor->supports($message)) { + continue; + } + + foreach ($extractor->extract($message) as $draft) { + $this->land($message, $extractor::key(), $draft); + $written++; + } + } catch (\Throwable $e) { + $this->logger->warning('Insight extractor failed on a message', [ + 'extractor' => $extractor::key(), + 'message' => $message->id, + 'error' => $e->getMessage(), + ]); + } + } + + return $written; + } + + private function land(Message $message, string $extractorKey, InsightDraft $draft): void + { + $dedupeKey = mb_substr($extractorKey . ':' . $draft->dedupeKey, 0, 160); + + $insight = $this->insights->findOneByDedupe($message->account, $dedupeKey); + + if (null === $insight) { + $insight = new MailInsight(); + $insight->account = $message->account; + $insight->extractor = $extractorKey; + $insight->dedupeKey = $dedupeKey; + + $this->em->persist($insight); + } + + $insight->kind = $draft->kind; + $insight->title = mb_substr($draft->title, 0, 255); + $insight->payload = $draft->payload; + $insight->happensAt = $draft->happensAt; + // The newest mail in the series owns the link: "open the mail" should + // land on "delivered", not on "shipped". + $insight->message = $message; + $insight->thread = $message->thread; + } +}