fix: vision OCR receipt extraction — skip second LLM call, fix total truncation
receipt_parser: change _ocr_image_vision() to extract structured JSON
{vendor,amount,date,time,category} directly from the image instead of
transcribing raw text, so the downstream LLM extraction step is
unnecessary and the two-step error-compounding is eliminated.
expenses_agent: add _match_category() helper to map vision category
labels to expense product names via substring/fuzzy match; add fast
path in _parse_receipt_text() that detects pre-extracted vision JSON
(text starts with '{') and skips the second LLM submit call entirely.
Fix text[:2000] truncation that discarded receipt totals — now keeps
first 1500 + last 1500 chars of long receipts so the grand total at
the bottom is always included.
tests: fix stale test_act_enters_awaiting_confirmation_on_first_pass
(confirmation gate was removed); add TestMatchCategory and three new
tests for the vision JSON fast path and LLM fallthrough.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -289,8 +289,13 @@ async def test_plan_task_field_also_checked():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_enters_awaiting_confirmation_on_first_pass():
|
||||
"""First call with receipts and no confirm → mode becomes awaiting_confirmation."""
|
||||
async def test_act_creates_expenses_immediately():
|
||||
"""Expenses are created in draft immediately — no confirmation gate.
|
||||
|
||||
The old two-step confirm flow was removed because receipts are only
|
||||
available in the initial /upload request, making a follow-up confirmation
|
||||
turn impossible. _act() now creates draft expenses straight away.
|
||||
"""
|
||||
agent = _make_agent()
|
||||
|
||||
fake_receipt = {
|
||||
@@ -308,18 +313,20 @@ async def test_act_enters_awaiting_confirmation_on_first_pass():
|
||||
parsed_result = {'vendor': 'Acme', 'amount': 10.00, 'date': '2026-05-09',
|
||||
'time': None, 'product_name': ''}
|
||||
|
||||
sheet_result = MagicMock(success=True, record_id=42)
|
||||
expense_result = MagicMock(success=True, record_id=99)
|
||||
|
||||
agent._et.get_employee_id_for_user = AsyncMock(return_value=1)
|
||||
agent._et.get_expense_products = AsyncMock(return_value=[
|
||||
{'id': 1, 'name': 'Meals'}
|
||||
])
|
||||
agent._et.get_expense_products = AsyncMock(return_value=[{'id': 1, 'name': 'Meals'}])
|
||||
agent._et.create_expense_sheet = AsyncMock(return_value=sheet_result)
|
||||
agent._et.create_expense = AsyncMock(return_value=expense_result)
|
||||
|
||||
with patch.object(agent, '_parse_receipt_text', new=AsyncMock(return_value=parsed_result)):
|
||||
result = await agent._act({})
|
||||
actions = await agent._act({})
|
||||
|
||||
assert result == []
|
||||
assert agent._gathered_data['mode'] == 'awaiting_confirmation'
|
||||
assert len(agent._confirmation_items) == 1
|
||||
vendor, parsed, is_dup = agent._confirmation_items[0]
|
||||
assert any('Created expense sheet' in a for a in actions)
|
||||
agent._et.create_expense_sheet.assert_called_once()
|
||||
agent._et.create_expense.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -415,6 +422,103 @@ async def test_act_no_employee_returns_empty_and_escalates():
|
||||
assert any('No employee record' in e for e in agent._escalations_list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _match_category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatchCategory:
|
||||
PRODUCTS = [
|
||||
{'id': 1, 'name': 'Meals'},
|
||||
{'id': 2, 'name': 'Fuel'},
|
||||
{'id': 3, 'name': 'Hotel'},
|
||||
{'id': 4, 'name': 'Office Supplies'},
|
||||
{'id': 5, 'name': 'Transport'},
|
||||
{'id': 6, 'name': 'Other'},
|
||||
]
|
||||
|
||||
def test_exact_match(self):
|
||||
assert ExpensesAgent._match_category('Meals', self.PRODUCTS) == 'Meals'
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert ExpensesAgent._match_category('meals', self.PRODUCTS) == 'Meals'
|
||||
assert ExpensesAgent._match_category('FUEL', self.PRODUCTS) == 'Fuel'
|
||||
|
||||
def test_substring_match(self):
|
||||
# 'office' is a substring of 'Office Supplies'
|
||||
assert ExpensesAgent._match_category('office', self.PRODUCTS) == 'Office Supplies'
|
||||
|
||||
def test_fuzzy_match(self):
|
||||
# 'transport' is close to 'Transport'
|
||||
assert ExpensesAgent._match_category('transport', self.PRODUCTS) == 'Transport'
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
assert ExpensesAgent._match_category('zxqwerty', self.PRODUCTS) == ''
|
||||
|
||||
def test_empty_category(self):
|
||||
assert ExpensesAgent._match_category('', self.PRODUCTS) == ''
|
||||
|
||||
def test_empty_products(self):
|
||||
assert ExpensesAgent._match_category('meals', []) == ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_receipt_text — vision JSON fast path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_vision_json_fast_path():
|
||||
"""When text is pre-extracted JSON from vision model, skip LLM call."""
|
||||
agent = _make_agent()
|
||||
agent._llm.submit = AsyncMock() # should NOT be called
|
||||
|
||||
vision_json = ('{"vendor":"McDonald\'s","amount":12.50,'
|
||||
'"date":"2026-05-09","time":"13:30","category":"meals"}')
|
||||
products = [{'id': 1, 'name': 'Meals'}, {'id': 2, 'name': 'Fuel'}]
|
||||
|
||||
result = await agent._parse_receipt_text(vision_json, 'receipt.jpg',
|
||||
expense_products=products)
|
||||
|
||||
assert result['vendor'] == "McDonald's"
|
||||
assert result['amount'] == 12.50
|
||||
assert result['date'] == '2026-05-09'
|
||||
assert result['time'] == '13:30'
|
||||
assert result['product_name'] == 'Meals'
|
||||
agent._llm.submit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_vision_json_null_time():
|
||||
"""Vision model may return the string 'null' for time — normalise to None."""
|
||||
agent = _make_agent()
|
||||
agent._llm.submit = AsyncMock()
|
||||
|
||||
vision_json = '{"vendor":"Shell","amount":45.00,"date":"2026-05-09","time":"null","category":"fuel"}'
|
||||
products = [{'id': 1, 'name': 'Meals'}, {'id': 2, 'name': 'Fuel'}]
|
||||
|
||||
result = await agent._parse_receipt_text(vision_json, 'shell.jpg',
|
||||
expense_products=products)
|
||||
assert result['time'] is None
|
||||
assert result['product_name'] == 'Fuel'
|
||||
agent._llm.submit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_non_json_text_falls_through_to_llm():
|
||||
"""Plain OCR text (not JSON) should go through the LLM extraction path."""
|
||||
agent = _make_agent()
|
||||
llm_resp = MagicMock()
|
||||
llm_resp.content = '{"vendor":"Acme","amount":9.99,"date":"2026-05-09","time":null,"product_name":"Meals"}'
|
||||
agent._llm.submit = AsyncMock(return_value=llm_resp)
|
||||
|
||||
result = await agent._parse_receipt_text(
|
||||
'Acme Store\nTotal: $9.99', 'receipt.jpg',
|
||||
expense_products=[{'id': 1, 'name': 'Meals'}],
|
||||
)
|
||||
assert result['vendor'] == 'Acme'
|
||||
assert result['amount'] == 9.99
|
||||
agent._llm.submit.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_upload — receipt_parser.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user