mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(insights): the radar's skeleton — entity, extractor seam, harvester, pipeline step
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
82d1eb749a
commit
bb036306db
11 files changed
+733
No files matched your search
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The radar's table: mail_insight, one row per fact read out of somebody's
|
||||
* mail — a parcel, a flight, a ticket, a pull request.
|
||||
*
|
||||
* Additive; nothing existing is touched and no backfill runs here. Existing
|
||||
* mailboxes get their rows from `app:backfill insights`, which the harvester
|
||||
* makes idempotent by the unique (account_id, dedupe_key) below — that
|
||||
* constraint is also what turns a carrier's five status mails into one row
|
||||
* under concurrent ingestion.
|
||||
*
|
||||
* thread_id and message_id are SET NULL on purpose: the insight can outlive
|
||||
* the mail that announced it (a deleted shipping confirmation does not
|
||||
* un-ship the parcel); it merely loses its link. account_id CASCADEs — no
|
||||
* account, no radar.
|
||||
*/
|
||||
final class Version20260817165438 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create mail_insight — the extracted parcels, flights, tickets and code review behind the radar';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command\Backfill;
|
||||
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Repository\Mail\MessageRepository;
|
||||
use App\Service\Insight\InsightHarvester;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Re-read stored mail through the insight extractors — how existing mailboxes
|
||||
* get their parcels, flights and pull requests without a resync, and how a
|
||||
* better extractor next release upgrades everyone's cards.
|
||||
*
|
||||
* Idempotent by the harvester's upsert: the same mail lands on the same
|
||||
* dedupe key and refreshes the same row, and a dismissed insight stays
|
||||
* dismissed (see InsightHarvester). Extractors read persisted data only, so
|
||||
* no provider is asked anything.
|
||||
*
|
||||
* Every category is walked, unlike the proposal task's Primary-only rule,
|
||||
* because the interesting senders live exactly where proposals do not look:
|
||||
* carriers file under Updates, GitHub under Forums or Updates, ticket shops
|
||||
* under Promotions. supports() is the cheap gate that keeps the walk from
|
||||
* parsing every newsletter body.
|
||||
*
|
||||
* Offset paging with the same licence EventProposalBackfillTask claims: this
|
||||
* writes into mail_insight and never touches `message`, so the set it pages
|
||||
* over cannot shift under it.
|
||||
*/
|
||||
final readonly class InsightsBackfillTask implements BackfillTaskInterface
|
||||
{
|
||||
private const int BATCH_SIZE = 200;
|
||||
|
||||
public function __construct(
|
||||
private MessageRepository $messages,
|
||||
private InsightHarvester $harvester,
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'insights';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Re-read stored mail for parcels, flights, tickets and code review — the cards on the radar.';
|
||||
}
|
||||
|
||||
public function run(SymfonyStyle $io): int
|
||||
{
|
||||
$total = $this->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<Message> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Insight;
|
||||
|
||||
/**
|
||||
* What an extracted mail insight is about.
|
||||
*
|
||||
* The sibling of Calendar\ExtractionKind, and deliberately a separate enum
|
||||
* rather than more cases on it: that one classifies CALENDAR EVENTS — things
|
||||
* with an occurrence on a timeline — and doubles as the Happening Soon
|
||||
* filter. An insight is the other family: a fact read out of a mail that is
|
||||
* worth surfacing whether or not it carries a date. A parcel has an ETA some
|
||||
* days wide, a pull request has none at all; forcing either into an
|
||||
* occurrence row would put fake times on real things.
|
||||
*/
|
||||
enum InsightKind: string
|
||||
{
|
||||
case Parcel = 'parcel';
|
||||
case Flight = 'flight';
|
||||
case Ticket = 'ticket';
|
||||
case GithubIssue = 'github-issue';
|
||||
case GithubPullRequest = 'github-pr';
|
||||
|
||||
/** Font Awesome icon, so a card or a chip can render without a match arm. */
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Parcel => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
use App\Domain\Trait\TimestampableTrait;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A fact read out of a mail that is worth showing before the mail is: a parcel
|
||||
* on its way, a flight with a time, a ticket, a pull request asking for eyes.
|
||||
*
|
||||
* The sibling of EventProposal with the opposite lifecycle. A proposal is a
|
||||
* GUESS held until a person answers it, and answering destroys the row; an
|
||||
* insight is a STATEMENT the extractor stands behind — nobody accepts a parcel
|
||||
* — so the row lives, updates itself as follow-up mails arrive, and retires by
|
||||
* time or by the user waving it away. That is why dismissal is a column here
|
||||
* rather than an EventSuppression: suppression exists to stop a guess being
|
||||
* re-offered, while a dismissed insight must still absorb its own follow-ups
|
||||
* (the DHL "it's here" mail upserts the same dedupe key) without coming back.
|
||||
*
|
||||
* $dedupeKey is what makes re-extraction an update instead of a duplicate.
|
||||
* The extractor derives it from the thing, not the mail — tracking number,
|
||||
* flight number + date, repo + issue number — so five mails about one parcel
|
||||
* are one card. Unique per ACCOUNT rather than per user: two accounts may
|
||||
* genuinely both receive tracking for the same parcel, and merging them would
|
||||
* make one account's dismissal eat the other's card.
|
||||
*
|
||||
* $payload is per-kind and deliberately schemaless at this layer; the
|
||||
* extractor that wrote it is named in $extractor, and the card template for
|
||||
* the kind is the reader. Versioned re-extraction (a better parser next
|
||||
* release) is the backfill's job, which is why nothing here forbids
|
||||
* overwriting a payload with a richer one.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: MailInsightRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'mail_insight')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_mail_insight_account_dedupe', columns: ['account_id', 'dedupe_key'])]
|
||||
// The one sweep this table serves: "what is coming up / recent for this
|
||||
// user's accounts". happens_at is nullable and the undated rows are reached
|
||||
// by created_at, so both carry an index; kind rides along free in reads.
|
||||
#[ORM\Index(name: 'idx_mail_insight_happens_at', columns: ['happens_at'])]
|
||||
#[ORM\Index(name: 'idx_mail_insight_created_at', columns: ['created_at'])]
|
||||
class MailInsight
|
||||
{
|
||||
use TimestampableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
public private(set) ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Account::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
public Account $account;
|
||||
|
||||
/**
|
||||
* Where the card links to. SET NULL rather than CASCADE: the insight can
|
||||
* outlive the mail that announced it — a deleted shipping confirmation
|
||||
* does not un-ship the parcel — it merely loses its "open the mail" link.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: MessageThread::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
public ?MessageThread $thread = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Message::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
public ?Message $message = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32, enumType: InsightKind::class)]
|
||||
public InsightKind $kind;
|
||||
|
||||
/** The card's one line: "DHL parcel from Zalando", "LH 1234 to JFK". */
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
public string $title = '';
|
||||
|
||||
/** @var array<string, mixed> 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;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Insight;
|
||||
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class MailInsightRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailInsight::class);
|
||||
}
|
||||
|
||||
/** The upsert's read half — see InsightHarvester. */
|
||||
public function findOneByDedupe(Account $account, string $dedupeKey): ?MailInsight
|
||||
{
|
||||
return $this->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<MailInsight> 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<MailInsight>
|
||||
*/
|
||||
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<MailInsight>
|
||||
*/
|
||||
public function forThread(MessageThread $thread): array
|
||||
{
|
||||
return $this->findBy(['thread' => $thread], ['happensAt' => 'ASC', 'id' => 'ASC']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Domain\DTO\Mail\PostIngestResult;
|
||||
use App\Domain\Interface\PostIngestStepInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* The insight pipeline's foothold in the sync: every freshly ingested message
|
||||
* is offered to the extractors, exactly the way ProposeEventsStep offers them
|
||||
* to the proposal detectors.
|
||||
*
|
||||
* The harvester already shields the batch per extractor and per message, so
|
||||
* the only job left here is the flush — once, after the batch, and only when
|
||||
* something was actually written. The pipeline's own catch shields the sync
|
||||
* from this step entirely; see PostIngestStepInterface.
|
||||
*/
|
||||
final readonly class ExtractInsightsStep implements PostIngestStepInterface
|
||||
{
|
||||
public function __construct(
|
||||
private InsightHarvester $harvester,
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
public function afterCommit(PostIngestResult $result): void
|
||||
{
|
||||
$written = 0;
|
||||
|
||||
foreach ($result->messages as $message) {
|
||||
$written += $this->harvester->harvest($message);
|
||||
}
|
||||
|
||||
if (0 === $written) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Domain\Enum\Insight\InsightKind;
|
||||
|
||||
/**
|
||||
* What an extractor hands back: the fact, before it is a row.
|
||||
*
|
||||
* A DTO rather than a MailInsight so extractors can be tested as pure
|
||||
* functions of a Message — no entity manager, no account wiring, no
|
||||
* lifecycle. The harvester owns turning drafts into rows and is the only
|
||||
* place upsert semantics live.
|
||||
*/
|
||||
final readonly class InsightDraft
|
||||
{
|
||||
/**
|
||||
* @param string $dedupeKey identity of the THING (tracking number,
|
||||
* flight+date, repo#number) — never of the mail.
|
||||
* The harvester scopes it by extractor key, so
|
||||
* extractors need not name themselves in it.
|
||||
* @param array<string, mixed> $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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Entity\Mail\Message;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
/**
|
||||
* Something that can read a fact out of a mail — a parcel, a flight, a pull
|
||||
* request — deterministically.
|
||||
*
|
||||
* The extension seam, and everything hangs off the tag: implementations are
|
||||
* collected by InsightExtractorRegistry, run by InsightHarvester after every
|
||||
* ingest, and listed BY THE REGISTRY on the settings page, where each can be
|
||||
* switched off per user. Adding a kind of insight is one class implementing
|
||||
* this — no registry edit, no settings edit, no pipeline edit. That is the
|
||||
* "extendable" the feature promises, so keep it true.
|
||||
*
|
||||
* Deterministic on purpose, like DeterministicDateDetector next door: sender
|
||||
* domains, headers, and regular shapes (tracking numbers, flight codes,
|
||||
* "#123"). An extractor that guesses puts invented parcels on someone's
|
||||
* radar; return nothing rather than probably-something. A model-backed
|
||||
* extractor would be one more implementation with a lower priority — the same
|
||||
* seam ProposalDetectorInterface documents — and nothing else would change.
|
||||
*
|
||||
* supports() is the cheap gate (headers, sender), extract() the real work;
|
||||
* the harvester never calls extract() without supports() saying yes. Neither
|
||||
* may throw for ordinary mail: unparseable is `[]`, not an exception — the
|
||||
* pipeline shields the sync either way, but a throwing extractor costs every
|
||||
* OTHER extractor its facts for that message.
|
||||
*/
|
||||
#[AutoconfigureTag('app.insight_extractor')]
|
||||
interface InsightExtractorInterface
|
||||
{
|
||||
/**
|
||||
* Stable registry id: stored on every row it writes, the settings toggle
|
||||
* key, and the dedupe scope. Short kebab-case ("parcel", "github").
|
||||
* Renaming it orphans rows and forgets who switched it off.
|
||||
*/
|
||||
public static function key(): string;
|
||||
|
||||
/**
|
||||
* Presentation for the settings page, resolved as translation keys
|
||||
* `settings.insights.extractor.<key>` / `...<key>_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<InsightDraft> empty is the normal outcome and never an
|
||||
* error: most mail states no fact worth a card
|
||||
*/
|
||||
public function extract(Message $message): array;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
/**
|
||||
* Every extractor the build knows, and which of them a given user has left
|
||||
* on.
|
||||
*
|
||||
* The settings page renders from all() and the harvester runs enabledFor(),
|
||||
* which is what makes a new extractor appear in settings the moment its class
|
||||
* exists — the page lists the registry, not a hand-kept catalogue.
|
||||
*
|
||||
* Everything is ON until switched off, and the preference stores the
|
||||
* DISABLED keys (User::SETTING_INSIGHTS_DISABLED): an extractor added next
|
||||
* release starts working for everyone instead of waiting for each user to
|
||||
* discover a new toggle — the same rule the sidebar's collapse state follows,
|
||||
* for the same reason.
|
||||
*/
|
||||
final readonly class InsightExtractorRegistry
|
||||
{
|
||||
/** @param iterable<InsightExtractorInterface> $extractors */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.insight_extractor')]
|
||||
private iterable $extractors,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<InsightExtractorInterface> 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<InsightExtractorInterface> */
|
||||
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<string> */
|
||||
private function disabledKeys(User $user): array
|
||||
{
|
||||
$stored = $user->getSetting(User::SETTING_INSIGHTS_DISABLED);
|
||||
|
||||
return is_array($stored) ? array_values(array_filter($stored, is_string(...))) : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Insight;
|
||||
|
||||
use App\Entity\Insight\MailInsight;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Insight\MailInsightRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Runs the enabled extractors over a message and lands their drafts as rows —
|
||||
* the one place upsert semantics live.
|
||||
*
|
||||
* Upsert by (account, extractor-scoped dedupe key), because the interesting
|
||||
* mails come in series: shipped, out for delivery, delivered are three mails
|
||||
* and one parcel. A later draft OVERWRITES title, payload and happensAt —
|
||||
* carriers revise their estimates and the newest statement is the card worth
|
||||
* showing — but never clears dismissedAt: the user's "stop showing me this"
|
||||
* outranks the carrier's enthusiasm (see MailInsight's class doc).
|
||||
*
|
||||
* The dedupe key is prefixed with the extractor's key here rather than by
|
||||
* each extractor, so two extractors reading the same number cannot collide
|
||||
* and no extractor can forget the scoping.
|
||||
*/
|
||||
final readonly class InsightHarvester
|
||||
{
|
||||
public function __construct(
|
||||
private InsightExtractorRegistry $registry,
|
||||
private MailInsightRepository $insights,
|
||||
private EntityManagerInterface $em,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return int how many insights were written or refreshed */
|
||||
public function harvest(Message $message): int
|
||||
{
|
||||
$account = $message->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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user