Skip to content

Commit f728283

Browse files
author
Florian Wunderlich
committed
fix: keep preview extraction state consistent
1 parent 00c4dc5 commit f728283

5 files changed

Lines changed: 116 additions & 25 deletions

File tree

models/account_move.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -360,29 +360,32 @@ def _ai_check_prerequisites(self):
360360

361361
return api_key, attachment
362362

363-
def action_ai_extract(self):
364-
"""Button: Digitize with AI."""
365-
self.ensure_one()
363+
def _ai_clear_extraction_cache(self):
364+
"""Drop cached extraction data for this invoice."""
365+
self.ai_last_extraction_data = False
366+
self.ai_last_extraction_attachment_id = False
366367

368+
def _ai_extract_from_button(self, force_refresh=False):
369+
"""Shared implementation for the extract and re-extract buttons."""
367370
api_key, attachment = self._ai_check_prerequisites()
368371
if api_key is None:
369372
return attachment # attachment is the error action dict
370373

371374
# --- Cache check: reuse previous extraction if same attachment ------
372-
if self.ai_last_extraction_data and self.ai_last_extraction_attachment_id == attachment:
375+
if not force_refresh and self.ai_last_extraction_data and self.ai_last_extraction_attachment_id == attachment:
373376
_logger.info('Using cached extraction data for move %s', self.id)
374377
try:
375378
data = json.loads(self.ai_last_extraction_data)
376379
except (json.JSONDecodeError, TypeError):
377380
_logger.warning('Cached extraction data is invalid JSON for move %s', self.id)
378-
self.ai_last_extraction_data = False
381+
self._ai_clear_extraction_cache()
379382
else:
380383
return self._ai_open_preview_wizard(data)
381384

382-
# Invalidate cache if attachment changed
385+
# Invalidate cache if attachment changed. For force-refresh on the same
386+
# attachment, keep the old cache until the new extraction succeeds.
383387
if self.ai_last_extraction_attachment_id and self.ai_last_extraction_attachment_id != attachment:
384-
self.ai_last_extraction_data = False
385-
self.ai_last_extraction_attachment_id = False
388+
self._ai_clear_extraction_cache()
386389

387390
# --- Async mode: queue for background processing -------------------
388391
if self._ai_get_bool_param('ai_async_extraction'):
@@ -399,6 +402,11 @@ def action_ai_extract(self):
399402
# --- Run extraction in preview mode (synchronous) ------------------
400403
return self._ai_extract_sync(api_key, attachment)
401404

405+
def action_ai_extract(self):
406+
"""Button: Digitize with AI."""
407+
self.ensure_one()
408+
return self._ai_extract_from_button()
409+
402410
def _ai_extract_sync(self, api_key, attachment):
403411
"""Run extraction synchronously and open the preview wizard."""
404412
self.ai_extraction_status = 'processing'
@@ -434,6 +442,10 @@ def _ai_extract_sync(self, api_key, attachment):
434442
notif_type='success',
435443
)
436444

445+
# The synchronous preview is ready immediately, so reserve "processing"
446+
# for actual async/background work and leave the move in a re-openable
447+
# preview state.
448+
self.ai_extraction_status = 'pending'
437449
return self._ai_open_preview_wizard(data)
438450

439451
def _ai_open_preview_wizard(self, data):
@@ -468,11 +480,9 @@ def _ai_open_preview_wizard(self, data):
468480
}
469481

470482
def action_ai_re_extract(self):
471-
"""Button: force re-extraction (clear cache and extract again)."""
483+
"""Button: force a fresh extraction without reusing the cached result."""
472484
self.ensure_one()
473-
self.ai_last_extraction_data = False
474-
self.ai_last_extraction_attachment_id = False
475-
return self.action_ai_extract()
485+
return self._ai_extract_from_button(force_refresh=True)
476486

477487
def action_ai_view_results(self):
478488
"""Button: open preview wizard from cached extraction data."""
@@ -484,4 +494,3 @@ def action_ai_view_results(self):
484494
except (json.JSONDecodeError, TypeError):
485495
return False
486496
return self._ai_open_preview_wizard(data)
487-

static/src/js/extraction_status_widget.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { registry } from "@web/core/registry";
44
import { standardFieldProps } from "@web/views/fields/standard_field_props";
5-
import { Component, onMounted, onWillUnmount, onPatched } from "@odoo/owl";
5+
import { Component, onMounted, onWillUnmount, onPatched, useState } from "@odoo/owl";
66
import { useService } from "@web/core/utils/hooks";
77

88
const POLL_INTERVAL_MS = 5000;
@@ -15,17 +15,25 @@ class AiExtractionStatusWidget extends Component {
1515
this.orm = useService("orm");
1616
this.action = useService("action");
1717
this.notification = useService("notification");
18+
this.state = useState({ terminalStatus: false });
1819
this._intervalId = null;
1920

2021
onMounted(() => this._checkAndStartPolling());
2122
onPatched(() => this._checkAndStartPolling());
2223
onWillUnmount(() => this._stopPolling());
2324
}
2425

25-
get currentStatus() {
26+
get recordStatus() {
2627
return this.props.record.data[this.props.name] || false;
2728
}
2829

30+
get currentStatus() {
31+
if (this.recordStatus === "processing" && this.state.terminalStatus) {
32+
return this.state.terminalStatus;
33+
}
34+
return this.recordStatus;
35+
}
36+
2937
get isProcessing() {
3038
return this.currentStatus === "processing";
3139
}
@@ -39,6 +47,9 @@ class AiExtractionStatusWidget extends Component {
3947
}
4048

4149
_checkAndStartPolling() {
50+
if (this.recordStatus === "processing" && this.state.terminalStatus) {
51+
this.state.terminalStatus = false;
52+
}
4253
if (this.isProcessing && !this._intervalId) {
4354
this._startPolling();
4455
} else if (!this.isProcessing && this._intervalId) {
@@ -57,6 +68,16 @@ class AiExtractionStatusWidget extends Component {
5768
}
5869
}
5970

71+
async _reloadRecordIfSafe() {
72+
// Reload only when the form is clean. In Odoo's relational model,
73+
// reloading a dirty record discards in-progress edits.
74+
if (await this.props.record.isDirty()) {
75+
return false;
76+
}
77+
await this.props.record.load();
78+
return true;
79+
}
80+
6081
async _poll() {
6182
const recordId = this.props.record.resId;
6283
if (!recordId) return;
@@ -71,13 +92,21 @@ class AiExtractionStatusWidget extends Component {
7192

7293
const newStatus = result[0].ai_extraction_status;
7394

95+
if (newStatus === "processing") {
96+
return;
97+
}
98+
99+
this._stopPolling();
100+
this.state.terminalStatus = newStatus || false;
101+
if (await this._reloadRecordIfSafe()) {
102+
this.state.terminalStatus = false;
103+
}
104+
74105
if (newStatus === "done") {
75-
this._stopPolling();
76106
this.notification.add(
77107
"AI extraction complete. Opening results...",
78108
{ type: "success" },
79109
);
80-
// Trigger view results action
81110
await this.action.doActionButton({
82111
type: "object",
83112
name: "action_ai_view_results",
@@ -91,8 +120,6 @@ class AiExtractionStatusWidget extends Component {
91120
"AI extraction failed. Please try again.",
92121
{ type: "danger" },
93122
);
94-
// Reload to show updated status
95-
await this.props.record.load();
96123
}
97124
} catch (error) {
98125
console.warn("Extraction polling error:", error);

tests/test_async_extraction.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ def test_cron_batch_size_limit(self):
175175
self.assertEqual(len(done), 5)
176176
self.assertEqual(len(still_queued), 2)
177177

178-
def test_re_extract_clears_cache(self):
179-
"""action_ai_re_extract should clear cached data and re-trigger."""
178+
def test_re_extract_bypasses_cache(self):
179+
"""action_ai_re_extract should force a fresh extraction instead of reusing cache."""
180180
move = self._create_invoice_with_attachment()
181181
move.ai_last_extraction_data = json.dumps({'old': True})
182182
move.ai_extraction_status = 'done'

tests/test_extraction_cache.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ def _create_invoice_with_attachment(self, pdf_content=b'%PDF-fake'):
5656
)
5757
return move, attachment
5858

59+
def _create_preview_wizard(self, move):
60+
return self.env['ai.preview.wizard'].create(
61+
{
62+
'move_id': move.id,
63+
'preview_data': json.dumps(self.mock_data),
64+
}
65+
)
66+
5967
def test_cache_populated_after_extraction(self):
6068
"""Cache fields should be populated after a successful extraction."""
6169
move, attachment = self._create_invoice_with_attachment()
@@ -127,8 +135,23 @@ def test_cache_invalidated_on_attachment_change(self):
127135
# Cache should now reference the new attachment
128136
self.assertEqual(move.ai_last_extraction_attachment_id, new_attachment)
129137

130-
def test_re_extract_clears_cache(self):
131-
"""action_ai_re_extract should clear cache and trigger fresh extraction."""
138+
def test_sync_preview_returns_move_to_pending(self):
139+
"""Synchronous preview must not leave the move stuck in processing."""
140+
move, attachment = self._create_invoice_with_attachment()
141+
142+
with patch.object(
143+
type(move),
144+
'_ai_trigger_extraction',
145+
return_value=self.mock_data,
146+
):
147+
result = move.action_ai_extract()
148+
149+
self.assertEqual(result.get('res_model'), 'ai.preview.wizard')
150+
self.assertEqual(move.ai_extraction_status, 'pending')
151+
self.assertEqual(move.ai_last_extraction_attachment_id, attachment)
152+
153+
def test_re_extract_bypasses_cache(self):
154+
"""action_ai_re_extract should force a fresh extraction for the same attachment."""
132155
move, attachment = self._create_invoice_with_attachment()
133156

134157
# Populate cache
@@ -143,6 +166,21 @@ def test_re_extract_clears_cache(self):
143166
move.action_ai_re_extract()
144167
mock_trigger.assert_called_once()
145168

169+
def test_re_extract_blocked_keeps_cache(self):
170+
"""A blocked re-extract must not destroy the previously cached result."""
171+
move, attachment = self._create_invoice_with_attachment()
172+
173+
move.ai_last_extraction_data = json.dumps(self.mock_data)
174+
move.ai_last_extraction_attachment_id = attachment.id
175+
move.ai_extraction_status = 'processing'
176+
177+
result = move.action_ai_re_extract()
178+
179+
self.assertEqual(result['tag'], 'display_notification')
180+
self.assertEqual(move.ai_extraction_status, 'processing')
181+
self.assertTrue(move.ai_last_extraction_data)
182+
self.assertEqual(move.ai_last_extraction_attachment_id, attachment)
183+
146184
def test_discard_keeps_cache(self):
147185
"""Discarding the preview wizard should keep the cache intact."""
148186
move, attachment = self._create_invoice_with_attachment()
@@ -155,6 +193,24 @@ def test_discard_keeps_cache(self):
155193
self.assertTrue(move.ai_last_extraction_data)
156194
self.assertEqual(move.ai_last_extraction_attachment_id, attachment)
157195

196+
def test_discard_preserves_pending_status(self):
197+
"""Discard should not mutate the sync-preview pending state."""
198+
move, _attachment = self._create_invoice_with_attachment()
199+
move.ai_extraction_status = 'pending'
200+
201+
self._create_preview_wizard(move).action_discard()
202+
203+
self.assertEqual(move.ai_extraction_status, 'pending')
204+
205+
def test_discard_preserves_done_status(self):
206+
"""Discarding an already-applied preview must not downgrade done."""
207+
move, _attachment = self._create_invoice_with_attachment()
208+
move.ai_extraction_status = 'done'
209+
210+
self._create_preview_wizard(move).action_discard()
211+
212+
self.assertEqual(move.ai_extraction_status, 'done')
213+
158214
def test_cache_not_populated_on_failure(self):
159215
"""Cache should not be populated when extraction fails."""
160216
move, attachment = self._create_invoice_with_attachment()

wizards/ai_preview_wizard.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ def action_apply(self):
164164
}
165165

166166
def action_discard(self):
167-
"""Discard the extraction and close the wizard."""
167+
"""Close the preview without mutating the invoice state."""
168168
self.ensure_one()
169-
self.move_id.ai_extraction_status = 'pending'
170169
return {'type': 'ir.actions.act_window_close'}

0 commit comments

Comments
 (0)