feat/memo-intake-upgrade #8

Merged
hyungi merged 4 commits from feat/memo-intake-upgrade into main 2026-05-11 12:10:50 +09:00
11 changed files with 563 additions and 13 deletions
+298 -7
View File
@@ -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)
+10
View File
@@ -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(
+12 -1
View File
@@ -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.01.0. 명확하지 않으면 낮게 (< 0.5). 사용자가 결정.
- 본문이 짧거나 의도 불명이면 "note" + confidence 낮게.
recommend_deep_summary=true 조건:
- 본문 > 40,000 chars
- 다수 당사자 또는 시계열 전개가 있는 법령/절차/보고서
+17
View File
@@ -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.01.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 만 억제)
+89
View File
@@ -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 등) 비교.
+103 -5
View File
@@ -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}
+11
View File
@@ -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.001.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;
+5
View File
@@ -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';