diff --git a/app/api/memos.py b/app/api/memos.py index f5fa749..8444b88 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -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 # {"": {"checked_at": ""}} + # 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) diff --git a/app/models/document.py b/app/models/document.py index 7de81ee..a7cd6df 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -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( diff --git a/app/prompts/policy/p3a_short_summary.txt b/app/prompts/policy/p3a_short_summary.txt index 076030f..4b401f0 100644 --- a/app/prompts/policy/p3a_short_summary.txt +++ b/app/prompts/policy/p3a_short_summary.txt @@ -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 - 다수 당사자 또는 시계열 전개가 있는 법령/절차/보고서 diff --git a/app/workers/classify_worker.py b/app/workers/classify_worker.py index 3e46f3c..fe5dbf4 100644 --- a/app/workers/classify_worker.py +++ b/app/workers/classify_worker.py @@ -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 만 억제) diff --git a/docs/voice_memo_shortcuts.md b/docs/voice_memo_shortcuts.md new file mode 100644 index 0000000..571812f --- /dev/null +++ b/docs/voice_memo_shortcuts.md @@ -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 등) 비교. diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte index 249f335..c17a0c5 100644 --- a/frontend/src/routes/memos/+page.svelte +++ b/frontend/src/routes/memos/+page.svelte @@ -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 @@ {:else} + + {#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted} +
+ {#if memo.source_channel === 'voice'} + + 음성 + + {/if} + {#if memo.ai_event_kind && memo.ai_event_kind !== 'note'} + + AI 추천: {KIND_LABELS[memo.ai_event_kind] || memo.ai_event_kind}{memo.ai_event_confidence != null ? ` · ${Math.round(memo.ai_event_confidence * 100)}%` : ''} + + {/if} + {#if memo._last_promoted} + + events #{memo._last_promoted.event_id} + + {/if} +
+ {/if} + {#if memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)}

{memo.title}

{/if} + + + {#if memo.source_channel === 'voice' && memo.file_path} + + {/if} +
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'} +

음성 → 텍스트 변환 대기 중…

+ {/if}
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]} + + + + + {/if} + {#if editingId !== memo.id} {#if memo.user_tags?.length || memo.ai_tags?.length} diff --git a/migrations/250_event_kind_hint_enum.sql b/migrations/250_event_kind_hint_enum.sql new file mode 100644 index 0000000..d0f51c2 --- /dev/null +++ b/migrations/250_event_kind_hint_enum.sql @@ -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' +); diff --git a/migrations/251_documents_ai_event_kind.sql b/migrations/251_documents_ai_event_kind.sql new file mode 100644 index 0000000..349fe9f --- /dev/null +++ b/migrations/251_documents_ai_event_kind.sql @@ -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; diff --git a/migrations/252_documents_ai_event_confidence.sql b/migrations/252_documents_ai_event_confidence.sql new file mode 100644 index 0000000..72d1d3a --- /dev/null +++ b/migrations/252_documents_ai_event_confidence.sql @@ -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)); diff --git a/migrations/253_documents_ai_event_kind_idx.sql b/migrations/253_documents_ai_event_kind_idx.sql new file mode 100644 index 0000000..45a658c --- /dev/null +++ b/migrations/253_documents_ai_event_kind_idx.sql @@ -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; diff --git a/migrations/254_source_channel_voice.sql b/migrations/254_source_channel_voice.sql new file mode 100644 index 0000000..e479ab0 --- /dev/null +++ b/migrations/254_source_channel_voice.sql @@ -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';