Add AI receipt parsing with provider API key settings
- Settings → AI: configure Anthropic or OpenAI provider with encrypted API key - Sparkle button on each attachment in transaction drawer sends image/PDF to AI - AI extracts merchant, amount, date, description, category hint - "Apply to transaction" button patches the transaction with parsed fields - Anthropic supports images and PDFs; OpenAI supports images only - API key stored AES-256-GCM encrypted in users table (migration 0003) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe4e69b9ad
commit
22fc1ce2f1
8 changed files with 507 additions and 31 deletions
69
backend/app/api/v1/settings.py
Normal file
69
backend/app/api/v1/settings.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
User-level settings: AI provider configuration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decrypt_field, encrypt_field
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.db.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
SUPPORTED_PROVIDERS = {"anthropic", "openai"}
|
||||
|
||||
|
||||
class AiSettingsResponse(BaseModel):
|
||||
provider: str | None
|
||||
has_api_key: bool
|
||||
|
||||
|
||||
class AiSettingsSave(BaseModel):
|
||||
provider: str
|
||||
api_key: str
|
||||
|
||||
|
||||
@router.get("/ai", response_model=AiSettingsResponse)
|
||||
async def get_ai_settings(user: User = Depends(get_current_user)):
|
||||
return AiSettingsResponse(
|
||||
provider=user.ai_provider,
|
||||
has_api_key=bool(user.ai_api_key_enc),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/ai", response_model=AiSettingsResponse)
|
||||
async def save_ai_settings(
|
||||
body: AiSettingsSave,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported provider. Choose: {', '.join(SUPPORTED_PROVIDERS)}")
|
||||
if not body.api_key.strip():
|
||||
raise HTTPException(status_code=400, detail="api_key must not be empty")
|
||||
|
||||
encrypted = encrypt_field(body.api_key.strip())
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(ai_provider=body.provider, ai_api_key_enc=encrypted)
|
||||
)
|
||||
await db.commit()
|
||||
return AiSettingsResponse(provider=body.provider, has_api_key=True)
|
||||
|
||||
|
||||
@router.delete("/ai", status_code=204)
|
||||
async def clear_ai_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(ai_provider=None, ai_api_key_enc=None)
|
||||
)
|
||||
await db.commit()
|
||||
Loading…
Add table
Add a link
Reference in a new issue