Files
odoo-ai/agent_service/routers/upload.py
Carlos Garcia 93f2a101fa refactor: remove scripted file intercept — LLM owns all responses
Previously ab_ai_mail.py intercepted file uploads before reaching the
LLM and responded with a hardcoded clarification template. The LLM had
no involvement in the file upload response.

Changes:
- ab_ai_mail.py: remove _post_file_clarification, _find_pending_attachments,
  _describe_zip, and the two-step pending-attachment lookup. All messages
  (text, files, or both) are dispatched to the agent service immediately.
  Files with no text pass an empty message — the LLM decides what to do.
- upload.py: default message changed from hardcoded receipt instruction
  to '' so the LLM determines intent from file content.
- master_agent._synthesize: always runs through the LLM for both single
  and multi-agent cases — no raw templates reach the user.
- master_system.txt: add FILE UPLOADS routing rule so the LLM knows to
  route receipts to expenses_agent without asking for clarification.

New flow: upload → parse → LLM classifies → agent acts → LLM synthesizes
natural response → user sees it. Zero scripted intercepts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:05:38 -04:00

87 lines
2.9 KiB
Python

from __future__ import annotations
import asyncio
import logging
import uuid
from typing import List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile, status
from ..config import get_settings
from .dispatch import DispatchResponse, _check_rate_limit, _verify_webhook_secret
from ..tools.receipt_parser import parse_upload
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/upload', tags=['upload'])
@router.post('', response_model=DispatchResponse)
async def upload(
request: Request,
user_id: str = Form(...),
message: str = Form(default=''),
session_id: Optional[str] = Form(default=None),
files: List[UploadFile] = File(default=[]),
):
_verify_webhook_secret(request)
_check_rate_limit(user_id)
from ..app_state import get_master_agent
master = get_master_agent()
if master is None:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Agent service not ready')
import asyncio
from concurrent.futures import ThreadPoolExecutor
_ocr_executor = ThreadPoolExecutor(max_workers=2)
receipts: list[dict] = []
loop = asyncio.get_event_loop()
for f in files:
data = await f.read()
filename = f.filename or 'receipt'
try:
# parse_upload may run OCR (CPU-bound) — offload to thread pool
parsed = await loop.run_in_executor(_ocr_executor, parse_upload, filename, data)
receipts.extend(parsed)
logger.info('upload: parsed %s%d receipt(s)', filename, len(parsed))
except Exception as exc:
logger.warning('upload: parse failed for %s: %s', filename, exc)
if not receipts:
logger.warning('upload: no parseable receipts found in upload from user_id=%s', user_id)
directive_id = session_id or uuid.uuid4().hex
extra_context = {'receipts': receipts, 'user_id': user_id}
settings = get_settings()
timeout = settings.directive_timeout_minutes * 60
try:
response = await asyncio.wait_for(
master.handle_message(
user_id=user_id,
channel_id=None,
message=message,
directive_id=directive_id,
extra_context=extra_context,
),
timeout=timeout,
)
except asyncio.TimeoutError:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f'Directive timed out after {settings.directive_timeout_minutes}m',
)
except Exception as exc:
logger.exception('upload error user=%s: %s', user_id, exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
return DispatchResponse(
directive_id=response.directive_id,
reply=response.response,
escalations=response.escalations,
actions_taken=response.actions_taken,
session_id=session_id,
)