feat/memo-intake-upgrade #8
+298
-7
@@ -1,22 +1,34 @@
|
||||
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
|
||||
"""메모 CRUD API — 파일 없는 문서(file_type='note') + voice 메모 (file_type='audio', source_channel='voice')"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from models.document import Document
|
||||
from models.event import Event
|
||||
from models.event_history import EventHistory
|
||||
from models.queue import ProcessingQueue, enqueue_stage
|
||||
from models.user import User
|
||||
|
||||
# Voice upload 제한 (plan v9 결정 — 10분 / 50MB)
|
||||
VOICE_MAX_BYTES = 50 * 1024 * 1024
|
||||
VOICE_ALLOWED_EXTS = {".m4a", ".mp3", ".wav", ".webm", ".ogg", ".opus", ".aac"}
|
||||
VOICE_ALLOWED_CONTENT_PREFIXES = ("audio/",)
|
||||
VOICE_NAS_SUBDIR = "PKM/Recordings" # /mnt/nas/Document_Server/PKM/Recordings/{YYYY-MM}/{uuid}.{ext}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -156,6 +168,12 @@ class MemoResponse(BaseModel):
|
||||
archived: bool
|
||||
ask_includable: bool
|
||||
memo_task_state: dict # {"<task_index>": {"checked_at": "<ISO8601>"}}
|
||||
# Memo Intake Upgrade PR-2B — AI 추천 분류 (사용자 1-click promote 의 hint)
|
||||
ai_event_kind: str | None = None
|
||||
ai_event_confidence: float | None = None
|
||||
source_channel: str | None = None # voice/memo 등 진입점 식별 (UI 배지)
|
||||
file_type: str | None = None # audio (voice 메모) vs note (text 메모)
|
||||
file_path: str | None = None # voice 메모의 NAS audio 경로 (audio player 용)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -185,6 +203,11 @@ def _to_memo_response(doc: Document) -> MemoResponse:
|
||||
archived=doc.archived,
|
||||
ask_includable=doc.ask_includable,
|
||||
memo_task_state=dict(doc.memo_task_state or {}),
|
||||
ai_event_kind=doc.ai_event_kind,
|
||||
ai_event_confidence=doc.ai_event_confidence,
|
||||
source_channel=doc.source_channel,
|
||||
file_type=doc.file_type,
|
||||
file_path=doc.file_path,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
@@ -241,10 +264,13 @@ async def list_memos(
|
||||
archived: bool = Query(False, description="true면 아카이브 목록"),
|
||||
pinned: bool | None = Query(None, description="true면 핀 고정된 메모만"),
|
||||
):
|
||||
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)"""
|
||||
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)
|
||||
|
||||
PR-2C: source_channel='voice' (음성 메모) 도 포함. 사용자 의도 = 메모는 모든 입력의 inbox.
|
||||
"""
|
||||
base = select(Document).where(
|
||||
Document.file_type == "note",
|
||||
Document.source_channel == "memo",
|
||||
Document.file_type.in_(("note", "audio")),
|
||||
Document.source_channel.in_(("memo", "voice")),
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
Document.archived == archived,
|
||||
)
|
||||
@@ -483,3 +509,268 @@ async def toggle_ask_includable(
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
# ─── Memo Intake Upgrade PR-2B: promote to event ───
|
||||
|
||||
|
||||
class PromotePayload(BaseModel):
|
||||
"""메모 → events 승급. kind 미지정 시 documents.ai_event_kind 사용.
|
||||
|
||||
AI worker 는 events row 직접 생성 X — 본 endpoint 만이 사용자 의도 channel.
|
||||
"""
|
||||
kind: str | None = None # 'task' | 'calendar_event' | 'activity_log'
|
||||
due_at: datetime | None = None
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
priority: int | None = None
|
||||
project_tag: str | None = None
|
||||
|
||||
|
||||
_PROMOTE_KIND_MAP = {
|
||||
# AI 추천 (event_kind_hint) → events.kind
|
||||
"task": "task",
|
||||
"calendar_event": "calendar_event",
|
||||
"activity_log": "activity_log",
|
||||
# 'note' / 'reference' 는 promote 대상 아님 (사용자가 명시 kind 지정 필요)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{memo_id}/promote-to-event", status_code=201)
|
||||
async def promote_memo_to_event(
|
||||
memo_id: int,
|
||||
body: PromotePayload,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 1건 → events row 1건 생성. memo_document_id 자동 link.
|
||||
|
||||
kind 결정 순서: body.kind > documents.ai_event_kind > 400 거부.
|
||||
한 메모 → N events 가능 (정책: dedup 없음, 사용자 의도 따라).
|
||||
"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if (
|
||||
not doc
|
||||
or doc.deleted_at is not None
|
||||
or doc.source_channel not in ("memo", "voice")
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
# kind 결정
|
||||
requested = (body.kind or "").strip().lower() or None
|
||||
ai_hint = (doc.ai_event_kind or "").strip().lower() or None
|
||||
chosen = requested or ai_hint
|
||||
event_kind = _PROMOTE_KIND_MAP.get(chosen or "")
|
||||
if not event_kind:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="promote 할 kind 가 명확하지 않습니다 (task/calendar_event/activity_log 중 1개 지정 또는 ai_event_kind 필요)",
|
||||
)
|
||||
|
||||
# 시간 필드 default — activity_log 는 빠른 행동 기록 UX 그대로
|
||||
now = datetime.now(timezone.utc)
|
||||
started_at = body.started_at
|
||||
ended_at = body.ended_at
|
||||
completed_at: datetime | None = None
|
||||
status_val = "inbox"
|
||||
if event_kind == "activity_log":
|
||||
ended_at = ended_at or now
|
||||
started_at = started_at or ended_at
|
||||
completed_at = now
|
||||
status_val = "done"
|
||||
elif event_kind == "calendar_event":
|
||||
status_val = "scheduled" if body.start_at else "inbox"
|
||||
|
||||
title = (doc.title or "").strip() or "메모"
|
||||
description = doc.extracted_text
|
||||
|
||||
ev = Event(
|
||||
title=title,
|
||||
description=description,
|
||||
kind=event_kind,
|
||||
status=status_val,
|
||||
due_at=body.due_at,
|
||||
start_at=body.start_at,
|
||||
end_at=body.end_at,
|
||||
started_at=started_at,
|
||||
ended_at=ended_at,
|
||||
completed_at=completed_at,
|
||||
priority=body.priority,
|
||||
project_tag=body.project_tag,
|
||||
source="memo",
|
||||
source_ref=str(doc.id), # 같은 메모 N promote 시 별 row → dedup 의도 X
|
||||
raw_metadata={
|
||||
"memo_id": doc.id,
|
||||
"ai_event_kind": doc.ai_event_kind,
|
||||
"ai_event_confidence": doc.ai_event_confidence,
|
||||
"promoted_at": now.isoformat(),
|
||||
},
|
||||
memo_document_id=doc.id,
|
||||
user_id=user.id,
|
||||
created_by="manual",
|
||||
)
|
||||
session.add(ev)
|
||||
await session.flush()
|
||||
|
||||
# events_history.create row (events 도메인 패턴 — events/api/events.py 의 _record_history 와 동일 형태)
|
||||
history = EventHistory(
|
||||
event_id=ev.id,
|
||||
changed_by="manual",
|
||||
change_kind="create",
|
||||
before=None,
|
||||
after={
|
||||
"id": ev.id,
|
||||
"title": ev.title,
|
||||
"kind": ev.kind,
|
||||
"status": ev.status,
|
||||
"source": ev.source,
|
||||
"memo_document_id": ev.memo_document_id,
|
||||
},
|
||||
)
|
||||
session.add(history)
|
||||
await session.commit()
|
||||
await session.refresh(ev)
|
||||
|
||||
return {
|
||||
"event_id": ev.id,
|
||||
"kind": ev.kind,
|
||||
"status": ev.status,
|
||||
"memo_document_id": ev.memo_document_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{memo_id}/dismiss-event-suggestion", response_model=MemoResponse)
|
||||
async def dismiss_event_suggestion(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""'그냥 메모' — AI 추천 무시 + ai_event_kind='note' 강제. 4 버튼 숨김 신호.
|
||||
|
||||
MVP: AI 추천값과 사용자 확정값을 같은 컬럼에 저장 (정확도 측정 흐려짐 가능).
|
||||
백로그: user_event_kind 별 컬럼 분리 (plan Memo Intake Upgrade 백로그).
|
||||
"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if (
|
||||
not doc
|
||||
or doc.deleted_at is not None
|
||||
or doc.source_channel not in ("memo", "voice")
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
doc.ai_event_kind = "note"
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
# ─── Memo Intake Upgrade PR-2C: voice upload ───
|
||||
|
||||
|
||||
@router.post("/voice", response_model=MemoResponse, status_code=201)
|
||||
async def upload_voice_memo(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
audio: UploadFile = File(...),
|
||||
recorded_at: str | None = Form(None),
|
||||
device_hint: str | None = Form(None),
|
||||
):
|
||||
"""애플워치 / 모바일 / 기타 음성 메모 업로드 → STT 큐 → 자동 분류.
|
||||
|
||||
PR-2C: source_channel='voice' + file_type='audio'. 기존 stt_worker → classify
|
||||
파이프라인 자동 통과. plan 원칙: AI worker 는 events 직접 생성 X.
|
||||
"""
|
||||
# Content-Type 검증
|
||||
if audio.content_type and not audio.content_type.startswith(VOICE_ALLOWED_CONTENT_PREFIXES):
|
||||
raise HTTPException(status_code=415, detail=f"지원되지 않는 Content-Type: {audio.content_type}")
|
||||
|
||||
# 확장자 결정
|
||||
orig_name = audio.filename or ""
|
||||
ext = (Path(orig_name).suffix or "").lower()
|
||||
if ext and ext not in VOICE_ALLOWED_EXTS:
|
||||
raise HTTPException(status_code=415, detail=f"지원되지 않는 확장자: {ext}")
|
||||
if not ext:
|
||||
# content_type 으로 추정 (audio/m4a 등)
|
||||
ext = ".m4a"
|
||||
|
||||
# 본문 읽기 + size 검증
|
||||
payload: bytes = await audio.read()
|
||||
if len(payload) > VOICE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"50MB 초과 ({len(payload)//1024//1024}MB)")
|
||||
if len(payload) == 0:
|
||||
raise HTTPException(status_code=400, detail="빈 audio")
|
||||
|
||||
# 저장 경로 (NAS) — fastapi 컨테이너 안 /documents = NAS mount
|
||||
nas_root = Path(settings.nas_mount_path)
|
||||
yyyy_mm = datetime.now(timezone.utc).astimezone().strftime("%Y-%m")
|
||||
target_dir = nas_root / VOICE_NAS_SUBDIR / yyyy_mm
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_uuid = uuid.uuid4().hex
|
||||
target_path = target_dir / f"{file_uuid}{ext}"
|
||||
|
||||
# fsync + rename(atomic) 패턴 — NAS soft mount 안전 (feedback_nfs_korean_path_normalize 결)
|
||||
tmp_path = target_path.with_suffix(target_path.suffix + ".tmp")
|
||||
try:
|
||||
with open(tmp_path, "wb") as fh:
|
||||
fh.write(payload)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp_path, target_path)
|
||||
except OSError as e:
|
||||
# NAS 쓰기 실패 graceful — DB row 미생성
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
logger.error("voice upload NAS write 실패: %s", e)
|
||||
raise HTTPException(status_code=503, detail="NAS 저장 실패 (재시도 권장)")
|
||||
|
||||
# recorded_at 파싱
|
||||
rec_at: datetime | None = None
|
||||
if recorded_at:
|
||||
try:
|
||||
rec_at = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
rec_at = None
|
||||
|
||||
raw_metadata: dict[str, Any] = {}
|
||||
if device_hint:
|
||||
raw_metadata["device_hint"] = device_hint
|
||||
if rec_at:
|
||||
raw_metadata["recorded_at"] = rec_at.isoformat()
|
||||
|
||||
# file_path 는 NAS root 기준 상대 경로 (다른 documents 컨벤션, /api/documents/{id}/file endpoint 호환)
|
||||
relative_path = target_path.relative_to(nas_root)
|
||||
|
||||
# Document row — file_type='audio', source_channel='voice'
|
||||
title_seed = (orig_name or "음성 메모").rsplit(".", 1)[0]
|
||||
doc = Document(
|
||||
file_path=str(relative_path),
|
||||
file_hash=hashlib.sha256(payload).hexdigest(),
|
||||
file_format=ext.lstrip(".") or "m4a",
|
||||
file_size=len(payload),
|
||||
file_type="audio",
|
||||
title=title_seed[:80] or "음성 메모",
|
||||
extracted_text=None, # STT 후 채움
|
||||
review_status="approved",
|
||||
source_channel="voice",
|
||||
category="audio",
|
||||
ask_includable=True,
|
||||
pinned=False,
|
||||
archived=False,
|
||||
memo_task_state={},
|
||||
extract_meta=raw_metadata or None,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# STT 큐 등록 — 기존 stt_worker → classify → embed → chunk 파이프라인 자동
|
||||
await enqueue_stage(session, doc.id, "stt")
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return _to_memo_response(doc)
|
||||
|
||||
@@ -47,6 +47,15 @@ class Document(Base):
|
||||
importance: Mapped[str | None] = mapped_column(String(20), default="medium")
|
||||
ai_confidence: Mapped[float | None] = mapped_column()
|
||||
|
||||
# Memo Intake Upgrade PR-2B — Gemma 4B triage 가 추론한 메모 의도 분류 hint
|
||||
# ('note' | 'task' | 'calendar_event' | 'activity_log' | 'reference')
|
||||
# AI 자동 events 생성 X — 사용자 1-click promote 시점에만 events row 생성 (안전 boundary).
|
||||
ai_event_kind: Mapped[str | None] = mapped_column(
|
||||
Enum("note", "task", "calendar_event", "activity_log", "reference",
|
||||
name="event_kind_hint")
|
||||
)
|
||||
ai_event_confidence: Mapped[float | None] = mapped_column()
|
||||
|
||||
# 3계층: 벡터 임베딩
|
||||
embedding = mapped_column(Vector(1024), nullable=True)
|
||||
embed_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
@@ -95,6 +104,7 @@ class Document(Base):
|
||||
source_channel: Mapped[str | None] = mapped_column(
|
||||
Enum("law_monitor", "devonagent", "email", "web_clip",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync", "news", "memo",
|
||||
"voice",
|
||||
name="source_channel")
|
||||
)
|
||||
data_origin: Mapped[str | None] = mapped_column(
|
||||
|
||||
@@ -31,9 +31,20 @@ subject_description: {subject_description}
|
||||
"recommend_deep_summary": bool,
|
||||
"recommend_entity_pass": bool,
|
||||
"escalate_to_26b": bool,
|
||||
"risk_flags": ["..."]
|
||||
"risk_flags": ["..."],
|
||||
"event_kind_hint": "note|task|calendar_event|activity_log|reference|null",
|
||||
"event_kind_confidence": 0.0~1.0
|
||||
}}
|
||||
|
||||
event_kind_hint 분류 (사용자 메모 inbox triage 용 — AI 가 events row 직접 생성하지 않고 사용자 1-click promote 의 추천만 제공):
|
||||
- "task": 사용자가 미래에 해야 할 일 (예: "내일 견적 요청", "세무사 전화하기"). due 시각 있어도 task 가능.
|
||||
- "calendar_event": 시간/날짜가 고정된 일정 (예: "5/15 14:00 회의", "내일 2시 세무사 전화"). 본문에 명시적 시간 단서.
|
||||
- "activity_log": 이미 한 행동 기록 (예: "방금 PR 머지 완료", "오늘 GPU 서버 점검함"). 과거형 또는 "방금/오늘/지금" 표지.
|
||||
- "reference": 나중에 참조할 자료/링크/요약 (예: 웹 클립, 외부 자료, "이거 나중에 봐야 함").
|
||||
- "note": 위 4개 어디에도 명확하지 않은 일반 메모/생각 (default).
|
||||
- event_kind_confidence: 0.0–1.0. 명확하지 않으면 낮게 (< 0.5). 사용자가 결정.
|
||||
- 본문이 짧거나 의도 불명이면 "note" + confidence 낮게.
|
||||
|
||||
recommend_deep_summary=true 조건:
|
||||
- 본문 > 40,000 chars
|
||||
- 다수 당사자 또는 시계열 전개가 있는 법령/절차/보고서
|
||||
|
||||
@@ -87,6 +87,11 @@ class TriageOutput(BaseModel):
|
||||
escalate_to_26b: bool = False
|
||||
risk_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
# Memo Intake Upgrade PR-2B — 메모 의도 분류 hint (선택 응답)
|
||||
# 4B 가 출력하지 않으면 None 유지. AI 자동 events 생성 X (사용자 promote 시점만).
|
||||
event_kind_hint: str | None = None # 'note' | 'task' | 'calendar_event' | 'activity_log' | 'reference'
|
||||
event_kind_confidence: float | None = None # 0.0–1.0
|
||||
|
||||
|
||||
# ───────────────────────── legacy classify (primary) ──────────────────
|
||||
|
||||
@@ -656,6 +661,18 @@ async def _apply_triage_result(
|
||||
if not parse_error:
|
||||
doc.ai_tldr = (triage_out.tldr or "").strip() or None
|
||||
doc.ai_bullets = triage_out.bullets or []
|
||||
# Memo Intake Upgrade PR-2B — event kind hint (4B 가 출력했을 때만)
|
||||
# 허용 enum 외 값이면 무시 (DB enum 제약). AI worker 는 events row 직접 생성 X.
|
||||
valid_kinds = {"note", "task", "calendar_event", "activity_log", "reference"}
|
||||
hint = (triage_out.event_kind_hint or "").strip().lower() or None
|
||||
if hint in valid_kinds:
|
||||
doc.ai_event_kind = hint
|
||||
try:
|
||||
conf = triage_out.event_kind_confidence
|
||||
if conf is not None and 0.0 <= float(conf) <= 1.0:
|
||||
doc.ai_event_confidence = float(conf)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
doc.ai_analysis_tier = "triage"
|
||||
|
||||
# R2 — backlog guard (hard 제외 soft escalate 만 억제)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 음성 메모 — iOS Shortcuts 가이드
|
||||
|
||||
**Endpoint**: `POST https://document.hyungi.net/api/memos/voice` (multipart/form-data)
|
||||
**인증**: Bearer JWT (단기 access token, 1시간). 만료 시 web 로그인 후 token 재복사.
|
||||
**제한**: 10분 / 50MB. m4a/mp3/wav/webm/ogg/opus/aac.
|
||||
**플로우**: 업로드 → STT (faster-whisper) → 자동 분류 (4B Gemma triage) → `/memos` inbox 노출.
|
||||
|
||||
장기 service token 발급 endpoint 는 PR-2C 안 포함 X — 1–2주 운영 후 끊김 빈도 보고 별 PR 결정 (plan v9 Memo Intake Upgrade 백로그).
|
||||
|
||||
## 1. JWT access token 복사
|
||||
|
||||
1. 데스크탑/모바일 브라우저에서 `https://document.hyungi.net/login` 로그인 + TOTP (활성 시)
|
||||
2. 개발자 도구 콘솔에서 다음 명령 1줄 — access_token 출력
|
||||
```js
|
||||
// Memo Intake Upgrade 임시 패턴 — service token PR 도입 전까지만
|
||||
localStorage.getItem('access_token') ??
|
||||
// 또는 fetch refresh 후 res.access_token (refresh cookie 유효 시)
|
||||
(await (await fetch('/api/auth/refresh', {method:'POST',credentials:'include'})).json()).access_token
|
||||
```
|
||||
3. 출력된 긴 문자열 (eyJ...) 을 Shortcuts 에 입력 (다음 단계)
|
||||
|
||||
> 1시간 후 만료. iOS Shortcut 사용 중 401 발생 시 token 다시 복사 + Shortcut 의 Text action 값 갱신.
|
||||
|
||||
## 2. iOS Shortcuts 만들기 (Apple Watch + iPhone 공용)
|
||||
|
||||
iPhone 의 단축어 (Shortcuts) 앱에서 새 단축어 생성:
|
||||
|
||||
### 단축어 이름
|
||||
`메모 녹음` (또는 임의)
|
||||
|
||||
### Action 1: Dictate Text (받아쓰기 텍스트) 또는 Record Audio (오디오 녹음)
|
||||
|
||||
**옵션 A — Whisper STT 사용 (권장, 한국어 정확도 ↑)**:
|
||||
- `Record Audio` action 추가
|
||||
- Audio Quality: `Normal` (m4a 4kbps 정도, 충분)
|
||||
- Start Recording: `On Tap`
|
||||
- Stop Recording: `On Tap`
|
||||
|
||||
**옵션 B — iOS native 받아쓰기 (간단, 정확도 보통)**:
|
||||
- `Dictate Text` action 추가 + 결과를 본문에 직접 넣어 `POST /api/memos/` (text endpoint) 호출
|
||||
|
||||
이 가이드는 옵션 A (Whisper STT 경유) 기준.
|
||||
|
||||
### Action 2: Get Contents of URL
|
||||
|
||||
- URL: `https://document.hyungi.net/api/memos/voice`
|
||||
- Method: `POST`
|
||||
- Headers:
|
||||
- `Authorization`: `Bearer <위 1단계 토큰>` (Text action 으로 분리해두면 갱신 편함)
|
||||
- Request Body: `Form`
|
||||
- `audio`: 위 Action 1 의 출력 (Recorded Audio File)
|
||||
- `recorded_at` (옵션): `Current Date` → `Format Date` → ISO 8601
|
||||
- `device_hint` (옵션): `apple_watch` 또는 `iphone`
|
||||
|
||||
### Action 3: Show Result (옵션)
|
||||
|
||||
- 결과 JSON 의 `id` 확인 (메모 inbox 에 들어갔는지)
|
||||
|
||||
### Apple Watch 노출
|
||||
|
||||
- 단축어 이름이 `메모 녹음` 이면 Siri 로 "Hey Siri, 메모 녹음" 호출 시 Watch 에서 실행
|
||||
- 또는 단축어 앱의 별 표시 → Watch 의 단축어 앱 첫 화면에 고정
|
||||
|
||||
## 3. 동작 확인
|
||||
|
||||
녹음 후 1–수분 안에:
|
||||
|
||||
1. `https://document.hyungi.net/memos` 진입
|
||||
2. 최상단에 새 메모 카드 (🎙️ 음성 배지 + audio player)
|
||||
3. STT 완료 후 카드 본문에 변환 텍스트 + AI 분류 배지 (task/calendar/activity/reference/note 중 하나)
|
||||
4. AI 추천이 task/일정/활동이면 4 버튼 표시 — 1-click 으로 events 승급
|
||||
|
||||
## 4. 트러블슈팅
|
||||
|
||||
| 증상 | 원인 / 조치 |
|
||||
|---|---|
|
||||
| 401 응답 | JWT 만료 → web 에서 다시 복사 |
|
||||
| 413 응답 | 50MB 초과 → 녹음 시간 단축 또는 quality 낮춤 |
|
||||
| 415 응답 | Content-Type 또는 확장자 미지원 → m4a/mp3 사용 |
|
||||
| 503 응답 | NAS 쓰기 실패 (드물게) → 1–2분 후 재시도 |
|
||||
| 메모는 생겼는데 본문 비어 있음 | STT 처리 중 (5분 polling). `stt-service` 컨테이너 health 확인 |
|
||||
| AI 분류 배지 안 나타남 | classify worker 가 아직 큐 처리 안 함. `processing_queue` stage=classify pending 확인 |
|
||||
|
||||
## 5. 운영 1–2주 후 결정 사항
|
||||
|
||||
- **service token 발급 endpoint 신설**: 단기 access token 만료가 자주 끊기면 별 PR.
|
||||
- **frontend mic widget**: 브라우저 직접 녹음 진입점 추가 (현재는 iOS Shortcuts 만).
|
||||
- **녹음 자동 정리**: 음성 메모 인입 빈도가 높아지면 archive 정책 또는 NAS 폴더 size 모니터링.
|
||||
- **STT 한국어 정확도 평가**: sample 5건 사용자 평가 후 large-v3 vs 다른 모델 (whisper-v3-turbo 등) 비교.
|
||||
@@ -3,7 +3,8 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
|
||||
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays } from 'lucide-svelte';
|
||||
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte';
|
||||
import { getAccessToken } from '$lib/api';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
@@ -249,6 +250,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PR-2B: 메모 → events 1-click promote ───
|
||||
|
||||
async function promoteMemo(memoId, kind) {
|
||||
const labels = { task: '할 일', calendar_event: '일정', activity_log: '활동 기록' };
|
||||
try {
|
||||
const res = await api(`/memos/${memoId}/promote-to-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind }),
|
||||
});
|
||||
addToast('success', `${labels[kind]} 로 승급 (events #${res.event_id})`);
|
||||
// 로컬 상태 갱신 — promoted 표시를 위해 메모에 임시 마킹 (서버 미반영, UX 힌트만)
|
||||
memos = memos.map((m) => m.id === memoId ? { ...m, _last_promoted: { event_id: res.event_id, kind } } : m);
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '승급 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissEventSuggestion(memoId) {
|
||||
try {
|
||||
const updated = await api(`/memos/${memoId}/dismiss-event-suggestion`, { method: 'POST' });
|
||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||
} catch (err) {
|
||||
addToast('error', '처리 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용
|
||||
function voiceAudioUrl(memoId) {
|
||||
const token = getAccessToken();
|
||||
return `/api/documents/${memoId}/file?token=${encodeURIComponent(token ?? '')}`;
|
||||
}
|
||||
|
||||
// ai_event_kind 별 라벨 / 색상
|
||||
const KIND_LABELS = {
|
||||
note: '메모',
|
||||
task: '할 일',
|
||||
calendar_event: '일정',
|
||||
activity_log: '활동',
|
||||
reference: '참조',
|
||||
};
|
||||
const KIND_BADGE_CLASS = {
|
||||
note: 'bg-surface text-dim',
|
||||
task: 'bg-indigo-100 text-indigo-700',
|
||||
calendar_event: 'bg-blue-100 text-blue-700',
|
||||
activity_log: 'bg-emerald-100 text-emerald-700',
|
||||
reference: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
|
||||
async function handleCheckboxClick(e, memo) {
|
||||
const target = e.target;
|
||||
if (target.tagName !== 'INPUT' || target.type !== 'checkbox') return;
|
||||
@@ -473,9 +522,36 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ═══ 읽기 모드 ═══ -->
|
||||
<!-- PR-2B/2C: 분류 배지 + voice icon + 마지막 promote 결과 -->
|
||||
{#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted}
|
||||
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
|
||||
{#if memo.source_channel === 'voice'}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-rose-100 text-rose-700" title="음성 메모">
|
||||
<Mic size={10} /> 음성
|
||||
</span>
|
||||
{/if}
|
||||
{#if memo.ai_event_kind && memo.ai_event_kind !== 'note'}
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] {KIND_BADGE_CLASS[memo.ai_event_kind] || 'bg-surface text-dim'}">
|
||||
AI 추천: {KIND_LABELS[memo.ai_event_kind] || memo.ai_event_kind}{memo.ai_event_confidence != null ? ` · ${Math.round(memo.ai_event_confidence * 100)}%` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if memo._last_promoted}
|
||||
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
|
||||
<ArrowRight size={10} /> events #{memo._last_promoted.event_id}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)}
|
||||
<p class="text-xs font-semibold text-dim mb-1">{memo.title}</p>
|
||||
{/if}
|
||||
|
||||
<!-- voice 메모 audio player -->
|
||||
{#if memo.source_channel === 'voice' && memo.file_path}
|
||||
<audio controls preload="metadata" src={voiceAudioUrl(memo.id)} class="w-full mb-2 h-9"></audio>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -483,10 +559,14 @@
|
||||
class:show-hidden={showHiddenByMemo[memo.id]}
|
||||
onclick={(e) => handleCheckboxClick(e, memo)}
|
||||
>
|
||||
{@html renderMemoHtml(memo.content || '', {
|
||||
taskStates: memo.memo_task_state ?? {},
|
||||
now: nowTick,
|
||||
})}
|
||||
{#if memo.content}
|
||||
{@html renderMemoHtml(memo.content || '', {
|
||||
taskStates: memo.memo_task_state ?? {},
|
||||
now: nowTick,
|
||||
})}
|
||||
{:else if memo.source_channel === 'voice'}
|
||||
<p class="text-xs text-dim italic">음성 → 텍스트 변환 대기 중…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
|
||||
<button
|
||||
@@ -503,6 +583,24 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- PR-2B: AI triage 결과 → 1-click promote 버튼 (분류 결과 있고 dismissed 아닌 메모) -->
|
||||
{#if editingId !== memo.id && memo.ai_event_kind && memo.ai_event_kind !== 'note' && !memo._last_promoted && !showArchived}
|
||||
<div class="flex flex-wrap gap-1 mt-2 pt-2 border-t border-default/30">
|
||||
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-indigo-500 text-white hover:bg-indigo-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<FileText size={11} /> 할 일로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Calendar size={11} /> 일정으로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Activity size={11} /> 활동으로
|
||||
</button>
|
||||
<button onclick={() => dismissEventSuggestion(memo.id)} class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover hover:text-text transition-colors">
|
||||
<X size={11} /> 그냥 메모
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 태그 + 하단 -->
|
||||
{#if editingId !== memo.id}
|
||||
{#if memo.user_tags?.length || memo.ai_tags?.length}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Memo Intake Upgrade PR-2B (1/4) — event_kind_hint enum
|
||||
-- 메모 본문에서 AI(Gemma 4B triage) 가 추론한 메모 의도 분류.
|
||||
-- events.kind (event_kind) 와 별 type 으로 분리 — 의도 hint 만, 실제 events row 생성은 사용자 promote 시점.
|
||||
|
||||
CREATE TYPE event_kind_hint AS ENUM (
|
||||
'note',
|
||||
'task',
|
||||
'calendar_event',
|
||||
'activity_log',
|
||||
'reference'
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Memo Intake Upgrade PR-2B (2/4) — documents.ai_event_kind 컬럼 추가
|
||||
-- AI 추천값. 사용자 1-click 승급 또는 dismiss 기준이 됨.
|
||||
-- nullable: classify worker 미통과 (extracted_text NULL 등) 행은 비어 있음.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS ai_event_kind event_kind_hint;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Memo Intake Upgrade PR-2B (3/4) — documents.ai_event_confidence 컬럼 추가
|
||||
-- 4B triage 의 ai_event_kind 신뢰도 (0.00–1.00). full-auto promote 결정 임계값에 활용.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS ai_event_confidence NUMERIC(3, 2) CHECK (ai_event_confidence IS NULL OR (ai_event_confidence >= 0 AND ai_event_confidence <= 1));
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Memo Intake Upgrade PR-2B (4/4) — partial index on ai_event_kind
|
||||
-- 메모 list 의 분류 결과 필터 + 사용자 inbox triage 흐름 핵심 인덱스.
|
||||
-- ai_event_kind IS NULL (분류 대기 / 미통과) 행은 size 절감 목적 partial 로 제외.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_ai_event_kind
|
||||
ON documents (ai_event_kind, created_at DESC)
|
||||
WHERE ai_event_kind IS NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Memo Intake Upgrade PR-2C — source_channel enum 에 'voice' 추가
|
||||
-- 음성 메모 진입점 (애플워치 / 기타 mic) 의 source 식별.
|
||||
-- file_type='audio' + category='audio' + source_channel='voice' 조합으로 메모 UI 에 노출.
|
||||
|
||||
ALTER TYPE source_channel ADD VALUE IF NOT EXISTS 'voice';
|
||||
Reference in New Issue
Block a user