You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
# backend/app/routers/inbox.py
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
from sqlmodel import Session, select
|
|
|
|
from ..auth.deps import current_user
|
|
from ..auth.scope import scoped
|
|
from ..db import get_session
|
|
from ..llm.provider import LLMProvider, get_provider
|
|
from ..models import (
|
|
InboxClassification,
|
|
InboxItem,
|
|
InboxStatus,
|
|
Person,
|
|
Prio,
|
|
Project,
|
|
Task,
|
|
TaskStatus,
|
|
)
|
|
from ..multimodal.factory import get_stt, get_vision
|
|
from ..routers.tasks import to_node
|
|
from ..schemas import (
|
|
CaptionOut,
|
|
CaptureRequest,
|
|
CaptureResponse,
|
|
ClassificationOut,
|
|
ConfirmResponse,
|
|
InboxItemOut,
|
|
ReclassifyRequest,
|
|
TranscribeOut,
|
|
)
|
|
from ..services.classification import classify, persist_classification
|
|
|
|
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
|
|
|
|
|
|
# ── phase-14: 멀티모달 캡처(음성→텍스트 / 이미지→캡션). 분류 경로는 phase-4 capture 재사용. ──
|
|
@router.post("/inbox/transcribe", response_model=TranscribeOut)
|
|
async def transcribe(audio: UploadFile = File(...), hint: str = Form("")):
|
|
data = await audio.read()
|
|
t = get_stt().transcribe(data, mime=audio.content_type or "audio/webm", hint=hint)
|
|
return TranscribeOut(text=t.text, seconds=t.seconds, model=t.model, confidence=t.confidence)
|
|
|
|
|
|
@router.post("/inbox/caption", response_model=CaptionOut)
|
|
async def caption(image: UploadFile = File(...), hint: str = Form("")):
|
|
data = await image.read()
|
|
c = get_vision().describe(data, mime=image.content_type or "image/jpeg", hint=hint)
|
|
return CaptionOut(text=c.text, ocr=c.ocr, model=c.model, confidence=c.confidence)
|
|
|
|
|
|
def latest_cls(s: Session, item_id: str) -> InboxClassification | None:
|
|
# id 가 TEXT PK(UUID)라 정렬 키로 부적합 → created_at 기준 최신.
|
|
rows = s.exec(
|
|
select(InboxClassification)
|
|
.where(InboxClassification.inbox_item_id == item_id)
|
|
.order_by(InboxClassification.created_at.desc())
|
|
).all()
|
|
return rows[0] if rows else None
|
|
|
|
|
|
def item_out(s: Session, item: InboxItem) -> InboxItemOut:
|
|
c = latest_cls(s, item.id)
|
|
cls = ClassificationOut.model_validate(c, from_attributes=True) if c else None
|
|
return InboxItemOut(
|
|
id=item.id,
|
|
kind=item.kind,
|
|
raw=item.raw,
|
|
status=item.status,
|
|
created_at=item.created_at,
|
|
materialized_task_id=item.materialized_task_id,
|
|
classification=cls,
|
|
)
|
|
|
|
|
|
@router.get("/inbox", response_model=list[InboxItemOut])
|
|
def list_inbox(s: Session = Depends(get_session), user: Person = Depends(current_user)):
|
|
items = s.exec(
|
|
scoped(select(InboxItem), InboxItem, user.id).order_by(InboxItem.created_at.desc())
|
|
).all()
|
|
return [item_out(s, it) for it in items]
|
|
|
|
|
|
@router.post("/inbox/capture", response_model=CaptureResponse)
|
|
def capture(
|
|
body: CaptureRequest,
|
|
s: Session = Depends(get_session),
|
|
provider: LLMProvider = Depends(get_provider),
|
|
user: Person = Depends(current_user),
|
|
):
|
|
iid = "s-" + uuid.uuid4().hex[:8]
|
|
item = InboxItem(id=iid, user_id=user.id, kind=body.kind, raw=body.raw, status=InboxStatus.new)
|
|
s.add(item)
|
|
s.commit()
|
|
s.refresh(item)
|
|
c = classify(s, body.raw, provider) # 동기 분류 (LLM or 폴백)
|
|
row = persist_classification(s, iid, c)
|
|
item.status = InboxStatus.classified
|
|
s.add(item)
|
|
s.commit()
|
|
return CaptureResponse(
|
|
item=item_out(s, item),
|
|
classification=ClassificationOut.model_validate(row, from_attributes=True),
|
|
)
|
|
|
|
|
|
@router.post("/inbox/{iid}/reclassify", response_model=ClassificationOut)
|
|
def reclassify(
|
|
iid: str,
|
|
body: ReclassifyRequest,
|
|
s: Session = Depends(get_session),
|
|
provider: LLMProvider = Depends(get_provider),
|
|
):
|
|
item = s.get(InboxItem, iid)
|
|
if not item:
|
|
raise HTTPException(404, "inbox item not found")
|
|
c = classify(s, item.raw, provider, force_type=body.type.value if body.type else None)
|
|
row = persist_classification(s, iid, c) # 새 row → latest 가 최신
|
|
item.status = InboxStatus.classified
|
|
s.add(item)
|
|
s.commit()
|
|
return ClassificationOut.model_validate(row, from_attributes=True)
|
|
|
|
|
|
@router.post("/inbox/{iid}/confirm", response_model=ConfirmResponse)
|
|
def confirm(iid: str, s: Session = Depends(get_session)):
|
|
item = s.get(InboxItem, iid)
|
|
if not item:
|
|
raise HTTPException(404, "inbox item not found")
|
|
# 멱등: 이미 confirmed 면 중복 실체화하지 않고 현재 상태만 반환
|
|
if item.status == InboxStatus.confirmed:
|
|
return ConfirmResponse(item=item_out(s, item), task=None)
|
|
c = latest_cls(s, iid)
|
|
if not c:
|
|
raise HTTPException(400, "no classification to confirm")
|
|
|
|
created: Task | None = None
|
|
ctype = c.type.value if hasattr(c.type, "value") else c.type
|
|
csphere = c.sphere.value if hasattr(c.sphere, "value") else c.sphere
|
|
if ctype == "task":
|
|
# 프로젝트 결정: classification.project_id 있으면 그대로, 없으면 sphere 기본
|
|
pid = c.project_id
|
|
if not pid or not s.get(Project, pid):
|
|
pid = "me" if csphere == "life" else _fallback_work_project(s)
|
|
# confirm task 필드 정본(R10)
|
|
notes = "스마트 인박스에서 실체화된 작업이에요."
|
|
if c.extra:
|
|
notes += " " + c.extra # 예: 비행기 티켓 → "가격 추적 알림 켜둠"
|
|
tid = "t-" + uuid.uuid4().hex[:8]
|
|
created = Task(
|
|
id=tid,
|
|
title=item.raw,
|
|
project_id=pid,
|
|
status=TaskStatus.todo,
|
|
assignee_id="jiwoo",
|
|
prio=Prio.normal,
|
|
due=None,
|
|
notes=notes,
|
|
)
|
|
s.add(created)
|
|
s.commit()
|
|
s.refresh(created)
|
|
item.materialized_task_id = tid
|
|
|
|
item.status = InboxStatus.confirmed
|
|
s.add(item)
|
|
s.commit()
|
|
node = to_node(s, created, s.exec(select(Task)).all()) if created else None
|
|
return ConfirmResponse(item=item_out(s, item), task=node)
|
|
|
|
|
|
@router.post("/inbox/{iid}/dismiss", response_model=InboxItemOut)
|
|
def dismiss(iid: str, s: Session = Depends(get_session)):
|
|
item = s.get(InboxItem, iid)
|
|
if not item:
|
|
raise HTTPException(404, "inbox item not found")
|
|
item.status = InboxStatus.dismissed
|
|
s.add(item)
|
|
s.commit()
|
|
return item_out(s, item)
|
|
|
|
|
|
def _fallback_work_project(s: Session) -> str:
|
|
p = s.exec(select(Project).where(Project.folder_id == "work")).first()
|
|
return p.id if p else "me"
|