feat: file upload + expense report creation from Discuss attachments
- Discuss bot now reads ir.attachment from incoming messages; file-only messages no longer silently dropped - ZIP files are described (contents listed) and bot asks clarifying question before acting; user's follow-up reply looks back for pending attachments so files don't need to be re-uploaded - receipt_parser: extracts text from ZIP (recursive), JPG/PNG/etc (OCR), PDF (pdfplumber), HTML, TXT - expenses_agent: full rewrite fixing broken method signatures; adds create_expense_sheet / create_expense / attach_receipt flow driven by LLM receipt parsing (Ollama, HIPAA-locked) - master_agent: extra_context threads receipts + user_id into directives - FastAPI /upload multipart endpoint; registered in main.py - Odoo /ai/upload controller proxies files to agent service - ab_ai_bot: dispatch_message_with_files() for multipart uploads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
80
agent_service/routers/upload.py
Normal file
80
agent_service/routers/upload.py
Normal file
@@ -0,0 +1,80 @@
|
||||
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='Create an employee expense report from these receipts.'),
|
||||
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')
|
||||
|
||||
receipts: list[dict] = []
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
filename = f.filename or 'receipt'
|
||||
try:
|
||||
parsed = 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,
|
||||
)
|
||||
Reference in New Issue
Block a user