Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions models/file.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Backend/models/file.py
from sqlalchemy import Column, Integer, String, ForeignKey, TIMESTAMP, text
from .base import Base

Expand All @@ -8,7 +7,8 @@ class File(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('user.u_id', ondelete='CASCADE'), nullable=False)
folder_id = Column(Integer, ForeignKey('folder.id', ondelete='SET NULL'), nullable=True)
note_id = Column(Integer, ForeignKey('note.id', ondelete='SET NULL'), nullable=True) # ✅ 첨부된 노트 ID
original_name = Column(String(255), nullable=False) # 유저가 업로드한 원본 파일 이름
saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로
content_type = Column(String(100), nullable=False) # MIME 타입
saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로
content_type = Column(String(100), nullable=False) # MIME 타입
created_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
67 changes: 32 additions & 35 deletions routers/file.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# ~/noteflow/Backend/routers/file.py

import os
from datetime import datetime
from typing import Optional, List
Expand Down Expand Up @@ -71,11 +69,12 @@ def run(cmd: list[str]):

@router.post(
"/upload",
summary="폴더에 파일 업로드",
summary="폴더/노트에 파일 업로드 (note_id 있으면 노트 본문에도 삽입)",
status_code=status.HTTP_201_CREATED
)
async def upload_file(
folder_id: Optional[int] = Form(None),
note_id: Optional[int] = Form(None),
upload_file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
Expand All @@ -87,7 +86,7 @@ async def upload_file(
user_dir = os.path.join(BASE_UPLOAD_DIR, str(current_user.u_id))
os.makedirs(user_dir, exist_ok=True)

# 원본 파일명 그대로 저장 (동명이인 방지)
# 원본 파일명 그대로 저장 (중복 시 _1, _2 붙임)
saved_filename = orig_filename
saved_path = os.path.join(user_dir, saved_filename)
if os.path.exists(saved_path):
Expand All @@ -110,10 +109,22 @@ async def upload_file(
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {e}")

# note_id가 있으면 해당 노트 확인
note_obj = None
if note_id is not None:
note_obj = (
db.query(NoteModel)
.filter(NoteModel.id == note_id, NoteModel.user_id == current_user.u_id)
.first()
)
if not note_obj:
raise HTTPException(status_code=404, detail="해당 노트를 찾을 수 없습니다.")

# DB에 메타데이터 기록
new_file = FileModel(
user_id=current_user.u_id,
folder_id=folder_id,
folder_id=None if note_id else folder_id,
note_id=note_id,
original_name=orig_filename,
saved_path=saved_path,
content_type=content_type
Expand All @@ -125,11 +136,25 @@ async def upload_file(
base_url = os.getenv("BASE_API_URL", "http://localhost:8000")
download_url = f"{base_url}/api/v1/files/download/{new_file.id}"

# note_id가 있으면 content에도 삽입
if note_obj:
if content_type.startswith("image/"):
embed = f"\n\n![{new_file.original_name}]({download_url})\n\n"
elif content_type == "application/pdf":
embed = f"\n\n[{new_file.original_name}]({download_url}) (PDF 보기)\n\n"
else:
embed = f"\n\n[{new_file.original_name}]({download_url})\n\n"

note_obj.content = (note_obj.content or "") + embed
db.commit()
db.refresh(note_obj)

return {
"file_id": new_file.id,
"url": download_url,
"original_name": new_file.original_name,
"folder_id": new_file.folder_id,
"note_id": new_file.note_id,
"content_type": new_file.content_type,
"created_at": new_file.created_at
}
Expand Down Expand Up @@ -180,13 +205,6 @@ def download_file(
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="서버에 파일이 존재하지 않습니다.")

# filename_star = file_obj.original_name
# return FileResponse(
# path=file_path,
# media_type=file_obj.content_type,
# headers={"Content-Disposition": f"inline; filename*=UTF-8''{filename_star}"}
# )
# FastAPI가 내부에서 UTF-8로 인코딩된 Content-Disposition 헤더를 생성해 줌
return FileResponse(
path=file_path,
media_type=file_obj.content_type,
Expand All @@ -201,7 +219,6 @@ def download_file(
response_model=OCRResponse
)
async def ocr_and_create_note(
# 변경: 업로드 필드명 'file' 기본 + 과거 호환 'ocr_file' 동시 허용
file: Optional[UploadFile] = File(None, description="기본 업로드 필드명"),
ocr_file: Optional[UploadFile] = File(None, description="과거 호환 업로드 필드명"),
folder_id: Optional[int] = Form(None),
Expand All @@ -210,21 +227,13 @@ async def ocr_and_create_note(
db: Session = Depends(get_db),
current_user = Depends(get_current_user)
):
"""
변경 전: 이미지 전용 EasyOCR/TrOCR로 텍스트 추출 후 노트 생성.
변경 후(추가/변경): 공통 파이프라인(utils.ocr.run_pipeline)으로 이미지/PDF/DOC/DOCX/HWP 처리.
- 예외는 200으로 내려가며, results=[] + warnings에 사유 기입.
- 결과 텍스트를 합쳐 비어있지 않으면 기존과 동일하게 노트를 생성.
"""
# 업로드 파일 결정
upload = file or ocr_file
if upload is None:
raise HTTPException(status_code=400, detail="업로드 파일이 필요합니다. 필드명은 'file' 또는 'ocr_file'을 사용하세요.")

filename = upload.filename or "uploaded"
mime = upload.content_type

# 허용 확장자 확인 (불일치 시 200 + warnings)
_, ext = os.path.splitext(filename)
ext = ext.lower()
if ext and ext not in ALLOWED_ALL_EXTS:
Expand All @@ -238,7 +247,6 @@ async def ocr_and_create_note(
text=None,
)

# 타입 판별 (보조적으로 unknown 방지)
ftype = detect_type(filename, mime)
if ftype == "unknown":
return OCRResponse(
Expand Down Expand Up @@ -268,7 +276,6 @@ async def ocr_and_create_note(
note_id: Optional[int] = None
if merged_text:
try:
# 추가/변경: 노트 제목을 업로드한 파일 이름으로 설정 (확장자 제거)
base_title = os.path.splitext(filename)[0].strip() or "OCR 결과"
new_note = NoteModel(
user_id=current_user.u_id,
Expand Down Expand Up @@ -297,24 +304,21 @@ async def upload_audio_and_transcribe(
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
# 📁 저장 경로 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"user{user.u_id}_{timestamp}_{file.filename}"
save_dir = os.path.join(BASE_UPLOAD_DIR, str(user.u_id))
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, filename)

# 📥 파일 저장
with open(save_path, "wb") as f:
f.write(await file.read())

# ✅ note_id가 있으면 folder_id는 무시
folder_id_to_use = folder_id if note_id is None else None

# 📦 files 테이블에 기록
new_file = FileModel(
user_id=user.u_id,
folder_id=folder_id_to_use,
note_id=note_id,
original_name=filename,
saved_path=save_path,
content_type="audio"
Expand All @@ -323,7 +327,6 @@ async def upload_audio_and_transcribe(
db.commit()
db.refresh(new_file)

# 🧠 STT 처리
try:
import whisper
model = whisper.load_model("base")
Expand All @@ -332,9 +335,7 @@ async def upload_audio_and_transcribe(
except Exception as e:
raise HTTPException(status_code=500, detail=f"STT 처리 실패: {e}")

# 📝 노트 처리
if note_id:
# 기존 노트에 텍스트 추가
note = db.query(NoteModel).filter(
NoteModel.id == note_id,
NoteModel.user_id == user.u_id
Expand All @@ -349,7 +350,6 @@ async def upload_audio_and_transcribe(
db.refresh(note)

else:
# 새 노트 생성
new_note = NoteModel(
user_id=user.u_id,
folder_id=folder_id_to_use,
Expand All @@ -364,10 +364,7 @@ async def upload_audio_and_transcribe(
"message": "STT 및 노트 저장 완료",
"transcript": transcript
}

@router.options("/ocr")
def ocr_cors_preflight() -> Response:
"""CORS preflight용 OPTIONS 응답. 일부 프록시/클라이언트에서 405 회피.
변경 전: 별도 OPTIONS 라우트 없음(미들웨어에 의존)
변경 후(추가): 명시적으로 200을 반환
"""
return Response(status_code=200)
88 changes: 57 additions & 31 deletions routers/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,69 @@

from db import get_db, SessionLocal
from models.note import Note
from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate
from models.file import File as FileModel
from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate, NoteFile
from utils.jwt_utils import get_current_user
from fastapi.responses import StreamingResponse
from utils.llm import stream_summary_with_langchain

load_dotenv()
HF_TOKEN = os.getenv("HF_API_TOKEN")

router = APIRouter(prefix="/api/v1", tags=["Notes"])

# 환경변수에서 BASE_API_URL 가져와 파일 다운로드 URL 구성
BASE_API_URL = os.getenv("BASE_API_URL", "http://localhost:8000")


# ─────────────────────────────────────────────
# 공통: Note → NoteResponse 직렬화 + files 채우기
# ─────────────────────────────────────────────
def serialize_note(db: Session, note: Note) -> NoteResponse:
"""
Note ORM 객체를 NoteResponse로 변환하면서
note_id로 연결된 File들을 찾아 files 배열에 채워 넣는다.
"""
# 파일 목록 조회 (노트 첨부)
files = (
db.query(FileModel)
.filter(FileModel.note_id == note.id, FileModel.user_id == note.user_id)
.order_by(FileModel.created_at.desc())
.all()
)

file_items: List[NoteFile] = []
for f in files:
file_items.append(
NoteFile(
file_id=f.id,
original_name=f.original_name,
content_type=f.content_type,
url=f"{BASE_API_URL}/api/v1/files/download/{f.id}",
created_at=f.created_at,
)
)

# Pydantic 모델 생성 (from_attributes=True로 기본 필드 매핑)
data = NoteResponse.model_validate(note, from_attributes=True)
# files 필드 교체
data.files = file_items
return data


# 1) 모든 노트 조회
@router.get("/notes", response_model=List[NoteResponse])
def list_notes(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
return (
notes = (
db.query(Note)
.filter(Note.user_id == user.u_id)
.order_by(Note.created_at.desc())
.all()
)
# 각 노트의 files도 채워 반환
return [serialize_note(db, n) for n in notes]


# 2) 최근 접근한 노트 조회 (상위 10개)
Expand All @@ -40,13 +80,14 @@ def recent_notes(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
return (
notes = (
db.query(Note)
.filter(Note.user_id == user.u_id, Note.last_accessed.isnot(None))
.order_by(Note.last_accessed.desc())
.limit(10)
.all()
)
return [serialize_note(db, n) for n in notes]


# 3) 노트 생성
Expand All @@ -65,7 +106,7 @@ def create_note(
db.add(note)
db.commit()
db.refresh(note)
return note
return serialize_note(db, note)


# 4) 노트 수정 (제목/내용/폴더)
Expand All @@ -86,11 +127,13 @@ def update_note(
note.content = req.content
if req.folder_id is not None:
note.folder_id = req.folder_id
if req.is_favorite is not None:
note.is_favorite = req.is_favorite

note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return note
return serialize_note(db, note)


# 5) 노트 단일 조회 (마지막 접근 시간 업데이트 포함)
Expand All @@ -107,7 +150,7 @@ def get_note(
note.last_accessed = datetime.utcnow()
db.commit()
db.refresh(note)
return note
return serialize_note(db, note)


# 6) 노트 삭제
Expand Down Expand Up @@ -142,30 +185,13 @@ def toggle_favorite(
note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return note

def save_summary(note_id: int, text: str):
"""Deprecated: kept for reference. No longer overwrites original note."""
db2 = SessionLocal()
try:
tgt = db2.query(Note).filter(Note.id == note_id).first()
if not tgt:
return
# Create a new note in the same folder with title '<original>요약'
title = (tgt.title or "").strip() + "요약"
if len(title) > 255:
title = title[:255]
new_note = Note(
user_id=tgt.user_id,
folder_id=tgt.folder_id,
title=title,
content=text,
)
db2.add(new_note)
db2.commit()
finally:
db2.close()
return serialize_note(db, note)


# ─────────────────────────────────────────────
# (참고) 요약 스트리밍 API - 완료 후에도 serialize_note 사용 안 함
# (요약은 새 노트를 생성하고 SSE로 알림만 보냄)
# ─────────────────────────────────────────────
@router.post("/notes/{note_id}/summarize")
async def summarize_stream_langchain(
note_id: int,
Expand All @@ -183,7 +209,7 @@ async def event_gen():
parts = []
async for sse in stream_summary_with_langchain(note.content, domain=domain, longdoc=longdoc):
parts.append(sse.removeprefix("data: ").strip())
yield sse.encode()
yield sse.encode()
full = "".join(parts).strip()
if full:
# Create a new summary note in the same folder with title '<original>요약'
Expand Down
Loading