# Phase 14 — 능동형 에이전트 & 멀티모달 캡처 > 한 줄 요약: 아리가 "대신 실행"하고(생활 심부름 에이전트: 예약·환불·구독해지·고객센터 대행) "먼저 말 거는"(능동 알림 다이제스트·주간 리뷰/패턴 코칭) 단계로 올라서고, phase-4에서 스텁이던 **멀티모달 캡처(음성 STT / 이미지 Vision)** 를 실제로 구현해 분류 파이프라인에 연결한다. 모든 외부 행동(발송·결제·해지)은 **결재함(phase-7) high-risk 승인**을 거치고, 모든 자동 실행은 **되돌리기 + 감사 로그**를 남긴다. > > 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md`(MVP 정본)와 `dev/post-mvp-overview.md`(포스트-MVP 정본)를 읽으세요. 선행 문서: `phase-7-approvals-automation.md`(자율성 코어·event_bus·worker·approval/autonomy_setting), `phase-8-calendar-meetings.md`, `phase-9-mail-notifications.md`, `phase-10-research-travel.md`(agents/·rag/·web_search·trip planner), `phase-11-life-care.md`, `phase-12-daily-narrative.md`(wrap 집계), `phase-13-integrations.md`(RealConnector — 실데이터 이벤트 공급). 다음 문서: `phase-15-production.md`. --- ## 1. 개요 & 목표 ### 1.1 이 phase가 끝나면 무엇이 동작하는가 phase 7~13까지는 아리가 "이미 해뒀어요"를 **결재함**에 쌓고(자동화 매칭·메일/회의 추출), **mock→real 커넥터**로 실데이터를 끌어왔다. Phase 14는 아리를 **행위자(actor)이자 발화자(initiator)** 로 승격한다. 세 가지 능력이 추가된다. 1. **생활 심부름 에이전트(errand agent)** — `backend/app/agents/`(phase-10 도입) 위의 멀티스텝 루프(plan→act(tool)→observe→reflect)가 "Netflix 해지", "호텔 환불 요청", "고객센터 문의"를 **대신 실행**한다. 진행 타임라인을 보여주고, 외부에 영향을 주는 단계(발송·결제·해지)는 **결재함(phase-7) high-risk 승인**으로 올린다. 시드 예: `approve-data.js`의 `a6` *"Netflix 일시정지를 추천해요"*(risk:high, cta:"일시정지", alt:"유지")를 **실제 대행 흐름**으로 확장. 2. **능동 알림(proactive)** — `backend/app/worker/`(phase-7 도입) 스케줄러가 작업·일정·금융·메일을 주기적으로 훑어 상황을 감지하고 **선제 카드**를 만든다: *"내일 미팅 3연속 — 점심 비워뒀어요"*, *"이 청구서가 평소보다 2만원 비싸요"*. 카드는 대시보드 상단/알림 벨에 뜨고, 한 번 탭으로 작업/일정/자동화로 흐른다(또는 결재함으로). 3. **멀티모달 캡처 실구현** — `STTProvider`(음성→텍스트, whisper류)·`VisionProvider`(이미지→캡션/OCR)를 LLM Provider와 **동일한 패턴**(env 주입·폴백)으로 구현해, phase-4 인박스의 voice/image **스텁을 실제로** 만든다. transcript/caption을 기존 분류 파이프라인(`POST /api/inbox/capture`)에 연결한다. 4. **주간 리뷰 / 패턴 코칭** — `하루 마감`(phase-12)의 **주간 버전**: 한 주의 딥워크/회의/완료를 집계해 *"이번 주 딥워크 8h, 회의 12h — 화요일 오전을 보호할까요?"* 같은 패턴 기반 제안을 만들고, 수락 시 자동화 규칙(phase-7)·반복 집중 블록(phase-8)으로 실체화한다. ### 1.2 능동성의 한 줄 정의 (철학 반영) `approve-data.js` 주석을 그대로 인용한다: *"아리는 '할까요?'라고 묻지 않고 미리 해둔다."* Phase 14는 이 정신을 **행동(심부름)** 과 **선제 발화(능동 카드/주간 리뷰)** 로 확장하되, 안전 게이트는 그대로 유지한다 — *"risk: low → 자율성 '혼합' 이상에서 자동 실행(되돌리기 가능), risk: high → 보내기/결제/타인에게 전달 등은 승인 대기."* 외부 행동(예약·환불·구독해지·발송)은 **항상 high-risk** → 사용자가 *읽고 탭 한 번*. ### 1.3 모델 비종속 (CONTRACT A2 상속) 에이전트 루프·STT·Vision은 **모델 비종속**이다. `AGENT_PROVIDER`/`STT_PROVIDER`/`VISION_PROVIDER`(`auto`)와 `*_MODEL`(env 주입)로 구동하고, 미가용·오프라인·CI에서는 **scripted/heuristic 폴백**으로 데모 결정성을 보장한다(post-mvp-overview §10 A2, §11 결정성). 즉 Ollama가 꺼져 있어도 심부름 타임라인·능동 카드·인박스 분류가 동일하게 재현된다. ### 1.4 디자인 기준 (원본 인용) | 원본 | 인용 값 | 본 phase 재현 | |---|---|---| | `approve-data.js` `a6` | `risk:"high"`, `tone:"amber"`, `time:"확인 필요"`, `title:"Netflix 일시정지를 추천해요"`, `detail:"최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요"`, `cta:"일시정지"`, `alt:"유지"` | 심부름 에이전트의 결재함 게이트(`ErrandStep` → `Approval`) | | `approve-data.js` `savedToday:"47분"`, `autoCountNight:7`, `log[]` | "스탠드업 직전 — 어제 진행 요약 노트 생성"(08:55) 등 | 능동 카드/심부름의 `approval_log` 집계 | | `data.js` `magicInbox.captures[]` | `{kind:"voice", raw:"음성 메모 0:14 — 수영장 차광막 부품 알아보기"}`, `{kind:"image", raw:"캡처 사진 — 차광막 클립 부품"}` | STT/Vision 실구현의 골든 입력/기대 transcript·caption | | `sinbox-data.js` `items[].kind` | `kind: "text"\|"voice"\|"image"`, s3 `"음성 메모 0:09 — 엄마 생신 선물 미리 알아보기"` | 멀티모달→분류 파이프라인 연결 | | `wrap-data.js` `stats`/`highlights`/`sleepNote` | `{n:"2.5", unit:"시간", label:"딥 워크"}`, `sleepNote:"알림 음소거 중 · 내일 아침 7:00 브리핑으로 깨워드릴게요"` | 주간 리뷰가 일 단위 wrap을 주(週) 단위로 집계 | | `data.js` `finance.subs[]` | `{name:"Spotify", date:"6월 9일", amt:"10,900"}`, `{name:"ChatGPT Plus", date:"6월 12일", amt:"29,000"}` | 능동 카드 "청구서가 평소보다 비싸요"의 근거 데이터 | | `data.js` `schedule[]` | `14:00 분기 전략 미팅(soon:true)`, 1:1 16:30 | 능동 카드 "미팅 3연속 — 점심 비워뒀어요" 근거 | > 본 phase는 새 페이지를 만들기보다 **기존 표면**(대시보드 상단, 알림 벨, 결재함, 인박스, 하루 마감)에 능동/심부름/멀티모달을 얹는다. 심부름 진행 타임라인 드로어와 주간 리뷰 카드만 신규 컴포넌트다. --- ## 2. 선행 조건(의존 phase) / 산출물(Deliverables) ### 2.1 선행 조건 | 의존 | 문서 | 이 phase가 사용하는 산출물 | |---|---|---| | 자율성 코어 | `phase-7-approvals-automation.md` | `event_bus`(발행/구독 정본, `backend/app/automation/event_bus.py` — `bus`/`publish`), `evaluator`, `approval`/`autonomy_setting`/`approval_log` 모델 + `/api/approvals`, **`backend/app/worker/`(APScheduler + `worker/main.py` `register(scheduler)`)** — 심부름 게이트·능동 잡을 여기에 등록 | | 에이전트/RAG/웹검색 | `phase-10-research-travel.md` | `backend/app/agents/`(plan→act→observe→reflect 루프 + `AGENT_PROVIDER`), 툴 `web_search`/`rag_query`/`http_fetch`/`calendar_write`/`task_create`/`form_fill(stub)`, scripted 폴백 패턴(trip planner 제주/도쿄 결정성) | | 일정·회의 | `phase-8-calendar-meetings.md` | `calendar`/`event`/`focus_block`, `calendar.updated`/`calendar.focus_scheduled` 이벤트, `calendar_write` 도착지 — 능동 카드/주간 리뷰가 집중 블록을 만든다 | | 메일·알림 | `phase-9-mail-notifications.md` | `email.ai{...}`, `notification`/`digest`/`notify_stats` — 능동 카드/다이제스트 배달 표면(알림 벨) | | 라이프 | `phase-11-life-care.md` | `finance`/`finance_tx`(청구서 이상 감지 근거), `health`(주간 코칭 근거), `connector_source` | | 여정·하루 마감 | `phase-12-daily-narrative.md` | `/api/wrap` 일 집계 서비스 — **주간 리뷰가 7일치 wrap을 합산** | | 실연동 | `phase-13-integrations.md` | `RealConnector.write()`(에이전트 tool이 외부에 **쓰기**: `calendar_write`→real 캘린더), 실데이터 이벤트(`mail.received`/`finance.transaction`/`health.sample`) — 능동 감지의 입력 | | 인박스 | `phase-4-inbox.md` | `POST /api/inbox/capture {kind, raw}` 동기 분류, `CaptureComposer`(mic/image 버튼), `inbox_item.kind`(text/voice/image) — STT/Vision이 raw를 채워 넣음 | ### 2.2 산출물(Deliverables) ``` backend/app/ ├─ agents/ (phase-10 확장) │ ├─ loop.py plan→act→observe→reflect 루프 심화(재시도·가드레일·예산) │ ├─ errand.py (+) 생활 심부름 에이전트(예약/환불/구독해지/고객센터) + step 기록 │ ├─ tools/ │ │ ├─ form_fill.py (+) form_fill 실제화(stub→시나리오 스크립트) + http_fetch 연동 │ │ └─ ... web_search/rag_query/http_fetch/calendar_write/task_create (phase-10) │ └─ guardrails.py (+) 외부 영향 단계 판정(external_effect) → 결재함 게이트 ├─ worker/ (phase-7 도입) │ ├─ main.py register(scheduler) — 잡 등록 진입(phase-7 소유, 본 phase 가 잡 추가) │ ├─ jobs/ │ │ ├─ proactive.py (+) 능동 감지 잡 → proactive_card 생성 │ │ ├─ digest.py (+) 능동 다이제스트/브리핑 생성(09:00/13:00/18:30) │ │ ├─ weekly_review.py (+) 주간 리뷰/패턴 코칭(일요일/요청 시) │ │ └─ errand_runner.py (+) 승인된 심부름 step 진행(백그라운드) │ └─ triggers.py (+) 수동 트리거 매핑(WORKER_ENABLED=false 프로토타입) ├─ multimodal/ (+) 멀티모달 캡처 추상화 (LLM Provider 패턴) │ ├─ stt.py STTProvider(ABC) + OllamaWhisperSTT / HeuristicSTT(폴백) │ ├─ vision.py VisionProvider(ABC) + OllamaVisionVision / HeuristicVision(폴백) │ └─ factory.py get_stt() / get_vision() (env 주입 + auto 폴백) ├─ models.py (+) ErrandTask, ErrandStep, ProactiveCard, WeeklyReview ├─ schemas.py (+) ErrandTaskOut/StepOut, ProactiveCardOut, WeeklyReviewOut, TranscribeOut ├─ routers/ │ ├─ agents.py (+) /api/agents/errand* (심부름 시작·조회·승인 후 재개) │ ├─ worker.py (+) /api/worker/run/{job} (수동 트리거), /api/proactive* │ └─ inbox.py (확장) /api/inbox/transcribe, /api/inbox/caption (멀티모달 실구현) └─ seed.py (+) _seed_errands / _seed_proactive / _seed_weekly_review frontend/ ├─ components/ │ ├─ errand/ │ │ ├─ ErrandTimeline.tsx (+) 심부름 진행 타임라인 드로어(step 상태/승인 게이트) │ │ └─ ErrandCard.tsx (+) 심부름 요약 카드(결재함/대시보드) │ ├─ proactive/ │ │ ├─ ProactiveBanner.tsx(+) 대시보드 상단 능동 카드 묶음 │ │ └─ ProactiveCard.tsx (+) 한 장(상황 + 근거 why + 한 번 탭 액션) │ ├─ weekly/ │ │ └─ WeeklyReview.tsx (+) 주간 리뷰(딥워크/회의 집계 + 패턴 제안) │ └─ inbox/ │ └─ CaptureComposer.tsx (확장) mic→실제 녹음→/transcribe, image→업로드→/caption ├─ lib/ │ ├─ api.ts (+) startErrand/getErrand/approveErrandStep, getProactive/dismiss, transcribe/caption, getWeeklyReview, runWorkerJob │ └─ types.ts (+) ErrandTask/ErrandStep/ProactiveCard/WeeklyReview/TranscribeResult ├─ tests/ (Vitest+RTL) · playwright/ (E2E) ``` > 신규 라우터(`agents`/`worker`)도 **내부 prefix 없이** 정의하고 `main.py`에서 `app.include_router(agents.router, prefix="/api", tags=["agents"])`로만 `/api` 부착(phase-2 규약). 시드는 `run_seed(session=None, reset=True)` 안에서 `_seed_errands(s)` 등 **내부 헬퍼**로 호출(공개 진입점 아님). --- ## 3. 상세 구현 (파일별·단계별) ### 3.0 큰 그림 — 능동 레이어 데이터 흐름 ``` ┌─────────────────────── worker/ (스케줄러 또는 수동 트리거) ──────────────────────┐ │ WORKER_ENABLED=true → APScheduler 주기 실행 │ │ WORKER_ENABLED=false → POST /api/worker/run/{job} 수동(프로토타입·CI 결정성) │ │ job: proactive | digest | weekly_review | errand_runner | connector_sync(13) │ └───────┬───────────────────┬───────────────────┬────────────────────┬─────────────┘ ▼ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ proactive.py │ │ digest.py │ │ weekly_review│ │ errand_runner.py │ │ 작업/일정/금융│ │ 09/13/18:30 │ │ 7일 wrap 집계│ │ 승인된 step 진행 │ │ /메일 스캔 │ │ 다이제스트 │ │ → 패턴 제안 │ │ (agents/loop) │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ ProactiveCard │ Digest+event │ WeeklyReview │ ErrandStep 전이 ▼ ▼ ▼ ▼ bus.publish: proactive.detected · digest.generated · weekly.reviewed · errand.updated │ │ ▼ 구독: 대시보드 상단/알림 벨/결재함 ▼ external_effect 단계 ┌──────────────────────────────────────────────┐ ┌─────────────────────────────┐ │ ProactiveBanner / 알림 벨(digest) / │ │ guardrails.external_effect? │ │ 결재함(approval 게이트) / 작업·일정(한 번 탭) │ │ → Approval(risk="high") │ └──────────────────────────────────────────────┘ │ → /api/approvals 승인 대기 │ └─────────────────────────────┘ [멀티모달] CaptureComposer mic 녹음 → POST /api/inbox/transcribe(STTProvider) → raw 텍스트 image 업로드 → POST /api/inbox/caption(VisionProvider) → raw 캡션/OCR → 기존 POST /api/inbox/capture {kind:"voice"|"image", raw} (phase-4 분류 그대로) ``` ### 3.1 데이터 모델 — `backend/app/models.py` (추가) post-mvp-overview §8.1 코어(approval/autonomy_setting/automation_*)를 **상속**하고, 능동 레이어 신규 4테이블을 추가한다. 모든 PK는 **TEXT(str)**, `tone` enum은 `blue|violet|coral|green|amber|ink|faint`, `now()`는 phase-2 헬퍼. ```python # backend/app/models.py (phase-14 추가 — phase-2/7 패턴 동일) from __future__ import annotations from datetime import datetime from enum import Enum from sqlmodel import SQLModel, Field from .models import now # phase-2 헬퍼(datetime.now(timezone.utc)) # ── 심부름 에이전트 ─────────────────────────────────────────────── class ErrandKind(str, Enum): booking = "booking" # 예약(병원/식당/수리) refund = "refund" # 환불 요청 cancel = "cancel" # 구독 해지/일시정지 (a6 Netflix) support = "support" # 고객센터 문의 class ErrandStatus(str, Enum): planning = "planning" # plan 단계 running = "running" # act/observe 진행 중 awaiting_approval = "awaiting_approval" # external_effect → 결재함 대기 done = "done" failed = "failed" cancelled = "cancelled" # 사용자 취소 class ErrandTask(SQLModel, table=True): id: str = Field(primary_key=True) # "er1".."erN" kind: str = ErrandKind.cancel # ErrandKind value title: str # "Netflix 일시정지" (a6 확장) goal: str # 자연어 목표(사용자 입력 또는 능동 제안) target: str | None = None # "Netflix" | "스테이 부산역" 등 대상 tone: str = "amber" # 결재함 카드 tone(a6=amber) status: str = ErrandStatus.planning autonomy_at_start: str = "mixed" # 시작 시점 자율성 레벨 스냅샷 approval_id: str | None = None # 게이트가 만든 Approval(phase-7) FK result_summary: str | None = None # "모레 결제 전 일시정지 완료(되돌리기 가능)" model: str = "" # "agent:" | "scripted" created_at: datetime = Field(default_factory=now) updated_at: datetime = Field(default_factory=now) class StepState(str, Enum): pending = "pending" running = "running" done = "done" blocked = "blocked" # 승인 대기로 막힘 error = "error" class ErrandStep(SQLModel, table=True): """plan→act(tool)→observe→reflect 한 스텝. 타임라인의 1행.""" id: str = Field(primary_key=True) # "er1-s1" ... errand_id: str = Field(foreign_key="errandtask.id") seq: int # 0,1,2,... phase: str # "plan"|"act"|"observe"|"reflect" tool: str | None = None # "web_search"|"http_fetch"|"form_fill"|... label: str # 타임라인 한 줄(한국어): "해지 페이지 접속" detail: str | None = None # observe 결과 요약 external_effect: bool = False # True면 결재함 게이트(발송/결제/해지) state: str = StepState.pending created_at: datetime = Field(default_factory=now) # ── 능동 카드 ──────────────────────────────────────────────────── class ProactiveKind(str, Enum): schedule = "schedule" # 미팅 연속 → 점심 보호 finance = "finance" # 청구서 이상 focus = "focus" # 딥워크 보호 health = "health" # 건강 코칭 digest = "digest" # 다이제스트/브리핑 class ProactiveCard(SQLModel, table=True): # 능동 감지 결과 1장 id: str = Field(primary_key=True) # "pc1".."pcN" kind: str = ProactiveKind.schedule icon: str = "spark" # Icon P 맵 키(cal/wallet/zap/spark...) tone: str = "violet" # blue|violet|coral|green|amber|ink|faint title: str # "내일 미팅 3연속 — 점심 비워뒀어요" why: str # 근거 한 줄: "10:00·11:00·13:00 연속, 12시 공백" cta: str | None = None # "그대로 둘게요" | "작업으로" | "자동화로" action_kind: str | None = None # "focus_block"|"task"|"automation"|"approval"|"none" action_payload: str | None = None # JSON(text): 실행에 필요한 인자 detected_at: str = "방금" # 표시용 상대 시간(원본 톤) status: str = "active" # active|accepted|dismissed created_at: datetime = Field(default_factory=now) # ── 주간 리뷰 ──────────────────────────────────────────────────── class WeeklyReview(SQLModel, table=True): # 하루 마감의 주(週) 집계 id: str = Field(primary_key=True) # "wr-2026-w24" week_label: str # "6/7~6/13" deep_work_h: float = 0.0 # 딥워크 시간 합(wrap stats 합산) meeting_h: float = 0.0 # 회의 시간 합 done_count: int = 0 # 완료 작업 수 auto_count: int = 0 # 아리 자동 처리 수 saved_minutes: int = 0 # 아낀 시간(분) patterns_json: str = "[]" # [{title, suggest, action_kind, payload}] JSON note: str | None = None # 한국어 한 줄 코칭 created_at: datetime = Field(default_factory=now) ``` > **상속 주의.** `from .models import now` 식의 자기참조는 실제로는 같은 파일 내 `now()`를 그대로 사용한다(예시는 추가 위치를 명확히 하려는 표기). `external_effect=True`인 `ErrandStep`은 **반드시** phase-7 `Approval`(risk="high")을 만들고 `ErrandTask.approval_id`/`ErrandTask.status=awaiting_approval`로 전이한다. 이것이 안전 게이트의 데이터 표현이다. ### 3.2 멀티모달 캡처 추상화 — `backend/app/multimodal/` LLM Provider(phase-2)와 **동일한 패턴**: ABC + 실제(Ollama류) + 폴백(heuristic) + `get_*()` 팩토리(env `*_PROVIDER`/`*_MODEL`). 미가용 시 폴백으로 데모 결정성. ```python # backend/app/multimodal/stt.py from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass @dataclass class Transcript: text: str seconds: float = 0.0 model: str = "" # "stt:" | "heuristic" confidence: float = 0.0 class STTProvider(ABC): name: str = "base" @abstractmethod def health(self) -> dict: ... @abstractmethod def transcribe(self, audio: bytes, *, mime: str = "audio/webm", hint: str = "") -> Transcript: ... class OllamaWhisperSTT(STTProvider): """STT_MODEL(whisper류) 주입. Ollama/로컬 whisper 서버에 오디오를 보낸다.""" name = "stt" def __init__(self): from ..config import get_settings self.st = get_settings() def health(self) -> dict: import httpx try: r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0) return {"reachable": r.status_code == 200, "model": self.st.stt_model} except Exception: return {"reachable": False} def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript: # 실제 구현: 로컬 whisper(예: faster-whisper HTTP 또는 ollama 멀티모달)에 전송 # 실패/미가용 시 폴백으로 자동 전환(아래 factory.auto 경로) ... class HeuristicSTT(STTProvider): """오프라인/CI 폴백. 더미 transcript(또는 hint 그대로) — 데모 결정성.""" name = "heuristic" # 골든: data.js magicInbox voice / sinbox s3 음성 메모를 결정적으로 재현 GOLDEN = { "pool": "음성 메모 0:14 — 수영장 차광막 부품 알아보기", "gift": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기", } def health(self) -> dict: return {"reachable": True, "model": "heuristic"} def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript: text = self.GOLDEN.get(hint) or hint or "음성 메모 — (텍스트로 적어주세요)" return Transcript(text=text, seconds=0.0, model="heuristic", confidence=0.4) ``` ```python # backend/app/multimodal/vision.py from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass @dataclass class Caption: text: str # 캡션 또는 OCR 텍스트 ocr: str = "" # OCR 전용(영수증/스크린샷) model: str = "" # "vision:" | "heuristic" confidence: float = 0.0 class VisionProvider(ABC): name: str = "base" @abstractmethod def health(self) -> dict: ... @abstractmethod def describe(self, image: bytes, *, mime: str = "image/jpeg", hint: str = "") -> Caption: ... class OllamaVisionVision(VisionProvider): """VISION_MODEL(멀티모달 비전 모델) 주입. 이미지→캡션/OCR.""" name = "vision" def __init__(self): from ..config import get_settings self.st = get_settings() def health(self) -> dict: import httpx try: r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0) return {"reachable": r.status_code == 200, "model": self.st.vision_model} except Exception: return {"reachable": False} def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption: ... # 실제: ollama 멀티모달 generate(images=[b64]) → 캡션 + OCR class HeuristicVision(VisionProvider): name = "heuristic" GOLDEN = {"clip": "캡처 사진 — 차광막 클립 부품"} def health(self) -> dict: return {"reachable": True, "model": "heuristic"} def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption: text = self.GOLDEN.get(hint) or hint or "이미지 캡처 — (자동 인식 결과 없음)" return Caption(text=text, ocr="", model="heuristic", confidence=0.4) ``` ```python # backend/app/multimodal/factory.py from __future__ import annotations from ..config import get_settings from .stt import STTProvider, OllamaWhisperSTT, HeuristicSTT from .vision import VisionProvider, OllamaVisionVision, HeuristicVision def get_stt(force: str | None = None) -> STTProvider: mode = force or get_settings().stt_provider # auto|ollama|heuristic if mode == "heuristic": return HeuristicSTT() if mode == "ollama": return OllamaWhisperSTT() p = OllamaWhisperSTT() # auto return p if p.health().get("reachable") else HeuristicSTT() def get_vision(force: str | None = None) -> VisionProvider: mode = force or get_settings().vision_provider if mode == "heuristic": return HeuristicVision() if mode == "ollama": return OllamaVisionVision() p = OllamaVisionVision() return p if p.health().get("reachable") else HeuristicVision() ``` `config.py` 확장(post-mvp-overview §8.3 환경변수 상속): ```python # backend/app/config.py (Settings 에 추가) stt_provider: str = "auto" # STT_PROVIDER auto|ollama|heuristic stt_model: str = "whisper" # STT_MODEL (주입 — 모델 비종속) vision_provider: str = "auto" # VISION_PROVIDER auto|ollama|heuristic vision_model: str = "llava" # VISION_MODEL (주입 — placeholder) agent_provider: str = "auto" # AGENT_PROVIDER auto|tool|scripted (phase-10 도입) worker_enabled: bool = False # WORKER_ENABLED 스케줄러 on/off(프로토타입은 수동 트리거) ``` ### 3.3 인박스 멀티모달 실구현 — `routers/inbox.py` 확장 phase-4의 capture 파이프라인은 그대로 두고, **raw 텍스트를 채워 넣는 두 엔드포인트**를 앞단에 추가한다. transcript/caption을 얻은 뒤 클라이언트가 기존 `POST /api/inbox/capture {kind, raw}`를 호출한다(분류 경로 불변 — phase-4 §3.7 "스텁 설계 결정" 그대로, 이제 raw가 실제로 추출됨). ```python # backend/app/routers/inbox.py (추가 — 내부 prefix 없음) from fastapi import APIRouter, UploadFile, File, Form from ..multimodal.factory import get_stt, get_vision from ..schemas import TranscribeOut, CaptionOut router = APIRouter() # main.py 에서 include_router(prefix="/api", tags=["inbox"]) @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) ``` > **연합(phase-4 확장).** 멀티모달은 분류 파이프라인을 새로 만들지 않는다 — 음성/이미지를 *텍스트로 환원*한 뒤 phase-4의 `capture`가 그대로 분류한다. 따라서 `data.js magicInbox`의 `{kind:"voice", raw:"...수영장 차광막 부품..."}`이 STT로 실제 채워지고, `→ {proj:"집 관리 · 수영장", tone:"blue", status:"아이디어"}`로 phase-4가 분류한다(원본 기대값 그대로). ### 3.4 심부름 에이전트 — `backend/app/agents/errand.py` phase-10의 멀티스텝 루프(plan→act→observe→reflect)를 **심부름**에 특화한다. 핵심: (1) 모든 step을 `ErrandStep`으로 기록(타임라인), (2) `external_effect` step은 실행 직전 멈추고 **결재함(phase-7) high-risk 게이트**를 세운다, (3) Ollama/tool 미가용 시 `scripted` 폴백(결정성). ```python # backend/app/agents/errand.py from __future__ import annotations from sqlmodel import Session from ..models import (ErrandTask, ErrandStep, ErrandKind, ErrandStatus, StepState, Approval, AutonomySetting, now) from ..automation.event_bus import bus # phase-7 정본(backend/app/automation/event_bus.py) from .loop import AgentLoop # phase-10 plan→act→observe→reflect from .guardrails import external_effect_of from ..config import get_settings # 데모 결정성: a6 "Netflix 일시정지" scripted 플랜(AGENT_PROVIDER=scripted 또는 폴백) SCRIPTED = { "cancel:Netflix": [ ("plan", None, "해지 절차 계획 수립", "결제일·해지 경로·되돌리기 옵션 확인", False), ("act", "web_search", "Netflix 해지/일시정지 방법 확인", "계정 설정 > 멤버십 일시정지 경로 확인", False), ("observe", None, "최근 시청 기록 확인", "최근 2개월 시청 0건 — 일시정지 적합", False), ("act", "http_fetch", "해지 페이지 접속·세션 준비", "로그인 세션 확보, 일시정지 폼 로드", False), ("reflect", None, "결제 임박 확인", "모레 17,000원 결제 예정 — 그 전에 처리 필요", False), ("act", "form_fill", "일시정지 신청 제출", "‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)", True), # external_effect ], } class ErrandAgent: def __init__(self, session: Session): self.s = session self.cfg = get_settings() def start(self, *, kind: str, title: str, goal: str, target: str | None, tone: str = "amber") -> ErrandTask: autonomy = self.s.get(AutonomySetting, "default") or AutonomySetting(id="default", level="mixed") er = ErrandTask(id=_eid(self.s), kind=kind, title=title, goal=goal, target=target, tone=tone, status=ErrandStatus.planning, autonomy_at_start=autonomy.level, model=self._model_tag()) self.s.add(er); self.s.commit() self._run_until_gate(er) return er def _run_until_gate(self, er: ErrandTask): """external_effect step 직전까지 진행. 만나면 결재함 게이트를 세우고 멈춘다.""" er.status = ErrandStatus.running for seq, (phase, tool, label, detail, ext) in enumerate(self._plan(er)): existing = _step(self.s, er.id, seq) if existing and existing.state == StepState.done: continue step = existing or ErrandStep(id=f"{er.id}-s{seq}", errand_id=er.id, seq=seq, phase=phase, tool=tool, label=label, detail=detail, external_effect=ext) if ext or external_effect_of(phase, tool, label): step.external_effect = True step.state = StepState.blocked self.s.add(step) self._raise_approval(er, step) # ← 안전 게이트 er.status = ErrandStatus.awaiting_approval self.s.add(er); self.s.commit() return # 외부 영향 없는 step은 즉시 실행(되돌리기 불필요) step.state = StepState.running; self.s.add(step); self.s.commit() self._execute(er, step) # tool 호출(또는 scripted observe) step.state = StepState.done; self.s.add(step) bus.publish("errand.updated", {"errand_id": er.id, "seq": seq, "external": False}) self.s.commit() er.status = ErrandStatus.done er.result_summary = self._summary(er) self.s.add(er); self.s.commit() bus.publish("errand.completed", {"errand_id": er.id}) def _raise_approval(self, er: ErrandTask, step: ErrandStep): """external_effect → phase-7 Approval(risk=high) 생성. a6 문구를 그대로 사용.""" ap = Approval( id=f"ap-{er.id}", icon="wallet", tone=er.tone, risk="high", status="pending", source="agent", time="확인 필요", title=f"{er.title}을(를) 추천해요" if er.kind == ErrandKind.cancel else er.title, detail=step.detail or er.goal, cta="일시정지" if er.kind == ErrandKind.cancel else "진행", alt="유지" if er.kind == ErrandKind.cancel else "취소", created_at=now()) self.s.add(ap) er.approval_id = ap.id bus.publish("approval.enqueued", {"id": ap.id, "risk": "high", "source": "agent"}) def resume_after_approval(self, er: ErrandTask): """결재함 승인 후 worker(errand_runner)가 호출 — blocked step 실행 후 계속.""" blocked = _blocked_step(self.s, er.id) if blocked: blocked.state = StepState.running; self.s.add(blocked); self.s.commit() self._execute(er, blocked) # 실제 외부 행동(승인됨) blocked.state = StepState.done; self.s.add(blocked); self.s.commit() bus.publish("errand.updated", {"errand_id": er.id, "seq": blocked.seq, "external": True}) self._run_until_gate(er) # 다음 게이트까지 또는 완료 # ── 플랜 선택: scripted(폴백/결정성) 우선, tool-capable이면 동적 plan ── def _plan(self, er: ErrandTask): key = f"{er.kind}:{er.target}" if self._scripted() or key in SCRIPTED: return SCRIPTED.get(key, SCRIPTED["cancel:Netflix"]) return AgentLoop(self.s).plan_steps(goal=er.goal, tools=[ "web_search", "http_fetch", "form_fill"]) # phase-10 동적 plan def _execute(self, er, step): if self._scripted(): return # scripted는 observe만(부작용 없음) AgentLoop(self.s).run_tool(step.tool, label=step.label) # phase-10 tool 실행 def _scripted(self) -> bool: return self.cfg.agent_provider == "scripted" or not AgentLoop.available() def _model_tag(self) -> str: return "scripted" if self._scripted() else f"agent:{self.cfg.ollama_model}" def _summary(self, er) -> str: return ("모레 결제 전 일시정지 완료(되돌리기 가능)" if er.kind == ErrandKind.cancel else "요청을 처리했어요") ``` > **핵심 안전 불변식.** `external_effect`(발송·결제·해지·전달) step은 **절대 자동 실행되지 않는다** — 항상 `Approval(risk="high")`를 만들고 `awaiting_approval`로 멈춘다(자율성 레벨 `full_auto`여도 high는 항상 대기, post-mvp-overview §10 A6). 결재함에서 사용자가 *cta*(예 "일시정지")를 누르면 phase-7이 `approval.executed`를 발행하고, worker `errand_runner`가 `resume_after_approval`로 이어 실행한다. ### 3.5 가드레일 — `backend/app/agents/guardrails.py` ```python # backend/app/agents/guardrails.py """에이전트 행동의 외부 영향 판정 + 재시도/예산 가드. 외부 영향(발송/결제/해지/전달/공유)은 결재함 high-risk 게이트로 강제한다.""" EXTERNAL_TOOLS = {"form_fill", "calendar_write", "task_create"} # 외부에 쓰는 tool EXTERNAL_KEYWORDS = ("제출", "신청", "발송", "보내", "결제", "해지", "정지", "전달", "공유", "예약 확정") def external_effect_of(phase: str, tool: str | None, label: str) -> bool: if phase != "act": return False if tool in EXTERNAL_TOOLS: # task_create/calendar_write 는 내부 쓰기지만 '타인에게'가 붙으면 high if tool == "form_fill": return True return any(k in label for k in EXTERNAL_KEYWORDS) return any(k in label for k in EXTERNAL_KEYWORDS) # 재시도/예산 가드 — 무한 루프·과도한 tool 호출 방지 MAX_STEPS = 12 MAX_RETRIES = 2 MAX_TOOL_CALLS = 8 class Budget: def __init__(self): self.steps = 0; self.tool_calls = 0 def step(self): self.steps += 1; assert self.steps <= MAX_STEPS, "step budget exceeded" def tool(self): self.tool_calls += 1; assert self.tool_calls <= MAX_TOOL_CALLS, "tool budget exceeded" ``` > 가드레일은 두 가지를 강제한다: **(1) 외부 영향 → 승인 게이트**(external_effect), **(2) 자원 예산**(MAX_STEPS/RETRIES/TOOL_CALLS) — tool-capable 동적 플랜이 폭주하지 않도록. scripted 폴백은 부작용이 없으므로 예산 안에서 결정적으로 끝난다. ### 3.6 능동 감지 잡 — `backend/app/worker/jobs/proactive.py` 작업·일정·금융·메일을 훑어 `ProactiveCard`를 만든다. 입력은 mock(phase 7~12 시드) 또는 real(phase-13 동기화 데이터) — **같은 테이블을 읽으므로 코드 무변경**. ```python # backend/app/worker/jobs/proactive.py from __future__ import annotations from sqlmodel import Session, select from ..._json import dumps # 표준 json.dumps wrapper(한글 ensure_ascii=False) from ...models import (ProactiveCard, ProactiveKind, Event, Finance, FinanceTx, now) from ...automation.event_bus import bus def run_proactive(session: Session) -> list[ProactiveCard]: cards: list[ProactiveCard] = [] cards += _detect_meeting_streak(session) cards += _detect_pricey_bill(session) cards += _detect_deep_work_gap(session) for c in cards: session.add(c) bus.publish("proactive.detected", {"id": c.id, "kind": c.kind}) session.commit() return cards def _detect_meeting_streak(s: Session): """내일 회의가 3연속이면 점심 블록 보호 카드. (data.js schedule + cal-data.js event)""" tomorrow_events = s.exec(select(Event).where(Event.day == _tomorrow())).all() if _max_consecutive(tomorrow_events) >= 3: return [ProactiveCard( id=_pid(s), kind=ProactiveKind.schedule, icon="cal", tone="violet", title="내일 미팅 3연속 — 점심 비워뒀어요", why="10:00·11:00·13:00 연속, 12시 공백을 보호 블록으로 잡았어요", cta="그대로 둘게요", action_kind="focus_block", action_payload=dumps({"day": _tomorrow(), "start": "12:00", "end": "13:00", "type": "light", "title": "점심·재충전 보호"}), detected_at="방금")] return [] def _detect_pricey_bill(s: Session): """구독/청구 금액이 평소보다 높으면 알림. (data.js finance.subs + life finance_tx)""" out = [] for sub in s.exec(select(Finance)).all(): # 라이프 finance 카테고리/구독 anomaly = _bill_anomaly(s, sub) # 직전 N개월 평균 대비 if anomaly and anomaly["delta_won"] >= 20000: out.append(ProactiveCard( id=_pid(s), kind=ProactiveKind.finance, icon="wallet", tone="amber", title=f"{anomaly['name']} 청구서가 평소보다 {anomaly['delta_label']} 비싸요", why=f"평소 {anomaly['avg_label']} → 이번 {anomaly['amt_label']} · 사유 확인 권장", cta="자세히", action_kind="task", action_payload=dumps({"title": f"{anomaly['name']} 청구 사유 확인", "project_id": "life-finance"}), detected_at="방금")) return out def _detect_deep_work_gap(s: Session): """이번 주 딥워크가 목표 미만이면 보호 제안(주간 리뷰와 연동).""" ... ``` > **능동 카드 생성 규칙(결정성).** 감지 규칙은 임계값 기반(연속 3회 이상, 금액 델타 ≥ 20,000원)이라 같은 시드에서 항상 같은 카드를 만든다. real 데이터(phase-13)가 들어오면 자연히 실제 상황을 감지한다. `action_payload`는 JSON(text) — 사용자가 cta를 누르면 `/api/proactive/{id}/accept`가 `action_kind`에 따라 focus_block/task/automation/approval로 실체화한다. ### 3.7 능동 다이제스트 — `backend/app/worker/jobs/digest.py` ```python # backend/app/worker/jobs/digest.py from ...automation.event_bus import bus from ...models import now DIGEST_TIMES = ["09:00", "13:00", "18:30"] # post-mvp-overview §11 / notify-data digests def run_digest(session, slot: str = "09:00"): """예약 시각 다이제스트/브리핑 생성 → 알림 벨 + 능동 브리핑. 18:30은 하루 마감(phase-12) 톤, 09:00은 아침 브리핑(data.js briefingNote 톤).""" digest = _build_digest(session, slot) # 작업/일정/결재 요약(집계 — 새 데이터 아님) session.add(digest); session.commit() bus.publish("digest.generated", {"digest_id": digest.id, "time": slot, "count": digest.count}) return digest ``` > 다이제스트는 **새 사실을 만들지 않고 집계만** 한다(알림 phase-9 `digest`, 하루 마감 phase-12와 같은 데이터). `digest.generated` 이벤트를 알림(벨)과 능동 브리핑이 구독한다. ### 3.8 주간 리뷰 / 패턴 코칭 — `backend/app/worker/jobs/weekly_review.py` `하루 마감`(phase-12)의 일 집계를 **7일치 합산**해 패턴을 찾고, 수락 시 자동화/집중 블록으로 실체화한다. ```python # backend/app/worker/jobs/weekly_review.py from __future__ import annotations from sqlmodel import Session from ..._json import dumps from ...models import WeeklyReview, now from ...automation.event_bus import bus def run_weekly_review(session: Session, *, week_label: str = "6/7~6/13") -> WeeklyReview: agg = _aggregate_week(session, week_label) # /api/wrap 일집계 7회 합산(phase-12) patterns = _find_patterns(agg) # 규칙 기반 패턴 → 제안 wr = WeeklyReview( id=f"wr-{_week_id(week_label)}", week_label=week_label, deep_work_h=agg["deep_work_h"], meeting_h=agg["meeting_h"], done_count=agg["done"], auto_count=agg["auto"], saved_minutes=agg["saved_min"], patterns_json=dumps(patterns), note=_coach_note(agg, patterns)) session.add(wr); session.commit() bus.publish("weekly.reviewed", {"id": wr.id, "patterns": len(patterns)}) return wr def _find_patterns(agg) -> list[dict]: out = [] # 예: 회의 시간이 딥워크보다 크게 많으면 보호 제안 if agg["meeting_h"] >= agg["deep_work_h"] * 1.4: out.append({ "title": f"이번 주 딥워크 {agg['deep_work_h']:.0f}h, 회의 {agg['meeting_h']:.0f}h", "suggest": "화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?", "action_kind": "automation", # 자동화 규칙으로 실체화 "payload": {"name": "화요일 오전 딥워크 보호", "cat": "focus", "trigger": "매주 화요일 08:00", "cond": None, "action": "09:00–11:00 방해 금지 블록 예약"}, }) return out def _coach_note(agg, patterns) -> str: if patterns: return f"이번 주 딥워크 {agg['deep_work_h']:.0f}h, 회의 {agg['meeting_h']:.0f}h — {patterns[0]['suggest']}" return "이번 주는 회의와 딥워크 균형이 좋았어요." ``` > 주간 리뷰 수치는 `wrap-data.js` 톤을 따른다(`{n:"2.5", unit:"시간", label:"딥 워크"}`의 주간 합). 패턴 제안 수락 시 `action_kind`가 `automation`이면 phase-7 `automation_rule`로, `focus_block`이면 phase-8 블록으로 실체화한다 — *제안→자동화/일정* 연합. ### 3.9 worker 잡 등록 & 수동 트리거 — `backend/app/worker/` phase-7이 소유한 `worker/main.py`의 `register(scheduler)`에 본 phase 잡을 추가한다(phase-13 `sync_jobs.register`와 동일 패턴). `WORKER_ENABLED=false`(기본, A4)면 스케줄러 미가동 — 대신 **수동 트리거 엔드포인트**로 데모/CI 결정성을 확보한다. ```python # backend/app/worker/triggers.py — 수동 트리거 매핑(라우터에서 호출) from sqlmodel import Session from .jobs.proactive import run_proactive from .jobs.digest import run_digest from .jobs.weekly_review import run_weekly_review from .jobs.errand_runner import run_errand_queue JOBS = { "proactive": lambda s, p: run_proactive(s), "digest": lambda s, p: run_digest(s, slot=p.get("slot", "09:00")), "weekly_review": lambda s, p: run_weekly_review(s, week_label=p.get("week", "6/7~6/13")), "errand_runner": lambda s, p: run_errand_queue(s), } ``` ```python # backend/app/worker/main.py (phase-7 소유 — 본 phase 가 잡 추가) def register(scheduler): from .jobs.proactive import run_proactive from .jobs.digest import run_digest from .jobs.weekly_review import run_weekly_review from ..db import engine from sqlmodel import Session def _wrap(fn, **kw): def _job(): with Session(engine) as s: fn(s, **kw) return _job scheduler.add_job(_wrap(run_proactive), "interval", minutes=30, id="proactive", replace_existing=True) for t in ["09:00", "13:00", "18:30"]: h, m = t.split(":") scheduler.add_job(_wrap(run_digest, slot=t), "cron", hour=int(h), minute=int(m), id=f"digest-{t}", replace_existing=True) scheduler.add_job(_wrap(run_weekly_review), "cron", day_of_week="sun", hour=20, id="weekly_review", replace_existing=True) # phase-13 sync_jobs.register(scheduler) 도 같은 진입에서 호출됨 ``` ```python # backend/app/worker/jobs/errand_runner.py — 승인된 심부름 이어 실행 from sqlmodel import Session, select from ...models import ErrandTask, ErrandStatus, Approval from ...agents.errand import ErrandAgent def run_errand_queue(session: Session): """approval.executed 후 awaiting_approval 심부름을 이어 실행. (이벤트 구독 핸들러가 직접 호출하거나, 폴링 잡으로 호출)""" ers = session.exec(select(ErrandTask).where( ErrandTask.status == ErrandStatus.awaiting_approval)).all() for er in ers: ap = session.get(Approval, er.approval_id) if er.approval_id else None if ap and ap.status in ("approved", "executed"): ErrandAgent(session).resume_after_approval(er) ``` ### 3.10 라우터 — `routers/agents.py` · `routers/worker.py` ```python # backend/app/routers/agents.py (내부 prefix 없음) from fastapi import APIRouter, HTTPException from sqlmodel import Session, select from ..db import get_session from ..agents.errand import ErrandAgent from ..models import ErrandTask, ErrandStep from ..schemas import ErrandStartIn, ErrandTaskOut router = APIRouter() @router.post("/agents/errand", response_model=ErrandTaskOut) def start_errand(body: ErrandStartIn, s: Session = Depends(get_session)): er = ErrandAgent(s).start(kind=body.kind, title=body.title, goal=body.goal, target=body.target, tone=body.tone or "amber") return _errand_out(s, er) @router.get("/agents/errand", response_model=list[ErrandTaskOut]) def list_errands(s: Session = Depends(get_session)): return [_errand_out(s, e) for e in s.exec(select(ErrandTask)).all()] @router.get("/agents/errand/{eid}", response_model=ErrandTaskOut) def get_errand(eid: str, s: Session = Depends(get_session)): er = s.get(ErrandTask, eid) if not er: raise HTTPException(404) return _errand_out(s, er) # steps 포함 → 타임라인 ``` ```python # backend/app/routers/worker.py (내부 prefix 없음) from fastapi import APIRouter, HTTPException from sqlmodel import Session, select from ..db import get_session from ..worker.triggers import JOBS from ..models import ProactiveCard from ..schemas import WorkerRunOut, ProactiveCardOut, ProactiveAcceptOut router = APIRouter() @router.post("/worker/run/{job}", response_model=WorkerRunOut) def run_job(job: str, payload: dict | None = None, s: Session = Depends(get_session)): if job not in JOBS: raise HTTPException(404, f"unknown job: {job}") result = JOBS[job](s, payload or {}) return WorkerRunOut(job=job, ok=True, detail=str(result)) @router.get("/proactive", response_model=list[ProactiveCardOut]) def list_proactive(s: Session = Depends(get_session)): return [_pc_out(c) for c in s.exec( select(ProactiveCard).where(ProactiveCard.status == "active")).all()] @router.post("/proactive/{pid}/accept", response_model=ProactiveAcceptOut) def accept_proactive(pid: str, s: Session = Depends(get_session)): c = s.get(ProactiveCard, pid) if not c: raise HTTPException(404) out = _materialize(s, c) # action_kind → focus_block/task/automation/approval c.status = "accepted"; s.add(c); s.commit() return out @router.post("/proactive/{pid}/dismiss") def dismiss_proactive(pid: str, s: Session = Depends(get_session)): c = s.get(ProactiveCard, pid) if not c: raise HTTPException(404) c.status = "dismissed"; s.add(c); s.commit() return {"ok": True} ``` > `main.py` 등록: `app.include_router(agents.router, prefix="/api", tags=["agents"])`, `app.include_router(worker.router, prefix="/api", tags=["worker"])`. inbox 라우터는 이미 phase-4에서 등록됨(transcribe/caption은 같은 라우터에 추가). ### 3.11 프런트 — 심부름 타임라인 `components/errand/ErrandTimeline.tsx` ```tsx "use client"; import Icon from "@/components/Icon"; import type { ErrandTask, ErrandStep } from "@/lib/types"; const PHASE_ICON: Record = { plan: "brain", act: "zap", observe: "clock", reflect: "spark", }; const STATE_LABEL: Record = { pending: "대기", running: "진행 중", done: "완료", blocked: "승인 대기", error: "오류", }; export default function ErrandTimeline({ er }: { er: ErrandTask }) { return (

{er.title}

{er.goal}
{statusLabel(er.status)}
    {er.steps.map((st) => (
  1. {st.label} {st.detail && {st.detail}}
    {STATE_LABEL[st.state]} {st.external_effect && st.state === "blocked" && ( 결재함에서 승인 필요 )}
  2. ))}
{er.status === "awaiting_approval" && ( 외부 행동(결제/해지)은 결재함에서 한 번 확인하면 이어서 처리할게요. )} {er.status === "done" && er.result_summary && (
{er.result_summary}
)}
); } ``` 스타일(토큰만 사용 — phase-1/4 상속): ```css .er-steps { list-style: none; margin: 12px 0 0; padding: 0; } .er-step { display: grid; grid-template-columns: 28px 1fr auto; gap: 10px; align-items: start; padding: 10px 0; border-top: 1px solid var(--glass-brd); } .er-step.external { background: color-mix(in oklab, var(--amber) 7%, transparent); } .er-dot { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in oklab, var(--violet) 14%, transparent); color: var(--violet); } .er-step.done .er-dot { background: color-mix(in oklab, var(--green) 14%, transparent); color: var(--green); } .er-step.blocked .er-dot { background: color-mix(in oklab, var(--amber) 16%, transparent); color: var(--amber); } .er-label { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; } .er-detail { display: block; font-size: 11.5px; color: var(--muted); max-width: 62ch; } .er-gate { grid-column: 2 / 4; font-size: 11px; font-weight: 700; color: var(--amber); } .er-result { margin-top: 12px; font-size: 12.5px; color: var(--green); display: flex; gap: 6px; } ``` ### 3.12 프런트 — 능동 배너 `components/proactive/ProactiveBanner.tsx` 대시보드 상단(`/dashboard`)과 알림 벨에서 재사용. `aria-live="polite"`로 능동 발화를 스크린리더에 알린다. ```tsx "use client"; import Icon from "@/components/Icon"; import { acceptProactive, dismissProactive } from "@/lib/api"; import type { ProactiveCard } from "@/lib/types"; export default function ProactiveBanner({ cards }: { cards: ProactiveCard[] }) { if (!cards.length) return null; return (
{cards.map((c) => (
{c.title}
{c.why}
{c.cta && ( )}
))}
); } ``` ```css .proactive { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; } .pc { display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center; padding: 12px 14px; border-radius: 15px; background: var(--glass-2); border: 1px solid color-mix(in oklab, var(--tone) 30%, var(--glass-brd)); } .pc-ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in oklab, var(--tone) 14%, transparent); color: var(--tone); } .pc-title { font-size: 13.5px; font-weight: 700; letter-spacing: -0.01em; } .pc-why { font-size: 11.5px; color: var(--muted); margin-top: 2px; } .pc-ok { font-weight: 700; color: var(--lime-ink); background: var(--lime); border-radius: 999px; padding: 6px 13px; } .pc-x { width: 28px; height: 28px; border-radius: 8px; color: var(--muted); } ``` ### 3.13 프런트 — 멀티모달 컴포저 확장 `components/inbox/CaptureComposer.tsx` phase-4의 mic/image **스텁**을 실제 캡처로 교체한다. 미디어 API 미지원/거부 시 **phase-4 더미 폴백 유지**(graceful degrade). ```tsx "use client"; import { useState, useRef } from "react"; import Icon from "@/components/Icon"; import { transcribe, caption } from "@/lib/api"; const VOICE_FALLBACK = "음성 메모 — (텍스트로 적어주세요)"; // STT 미가용 시 phase-4 톤 const IMAGE_FALLBACK = "이미지 캡처 — (자동 인식 결과 없음)"; export default function CaptureComposer({ onSubmit, }: { onSubmit: (raw: string, kind: "text" | "voice" | "image") => void }) { const [input, setInput] = useState(""); const [recording, setRecording] = useState(false); const recRef = useRef(null); const fileRef = useRef(null); const submitText = () => { const t = input.trim(); if (!t) return; onSubmit(t, "text"); setInput(""); }; /* 음성: 실제 녹음 → /inbox/transcribe → raw 텍스트로 capture */ const toggleMic = async () => { if (recording) { recRef.current?.stop(); return; } if (!navigator.mediaDevices?.getUserMedia) { onSubmit(VOICE_FALLBACK, "voice"); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const rec = new MediaRecorder(stream); const chunks: Blob[] = []; rec.ondataavailable = (e) => chunks.push(e.data); rec.onstop = async () => { stream.getTracks().forEach((t) => t.stop()); setRecording(false); const blob = new Blob(chunks, { type: "audio/webm" }); try { const { text } = await transcribe(blob); onSubmit(text || VOICE_FALLBACK, "voice"); } catch { onSubmit(VOICE_FALLBACK, "voice"); } }; recRef.current = rec; rec.start(); setRecording(true); } catch { onSubmit(VOICE_FALLBACK, "voice"); } // 권한 거부 → 폴백 }; /* 이미지: 파일 선택 → /inbox/caption → raw 캡션으로 capture */ const onImage = async (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (!f) return; try { const { text } = await caption(f); onSubmit(text || IMAGE_FALLBACK, "image"); } catch { onSubmit(IMAGE_FALLBACK, "image"); } finally { e.target.value = ""; } }; return (
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitText()} placeholder="갑자기 생각난 것 아무거나…" aria-label="인박스에 빠르게 캡처" />
); } ``` > **graceful degrade(A2/§12).** `getUserMedia` 미지원·권한 거부·STT/Vision 미가용 → phase-4 톤의 폴백 문구로 capture 계속. 백엔드도 `HeuristicSTT`/`HeuristicVision`이 항상 응답하므로 분류 파이프라인은 끊기지 않는다. 녹음 중 상태(`.sb-mode.rec`)는 coral 펄스로 표시(phase-4 `sb-think` 펄스 토큰 재사용). --- ## 4. 데이터/타입/API 계약 ### 4.1 신규 엔드포인트 (prefix `/api`) | 메서드 | 경로 | 요청 | 응답 | 용도 | |---|---|---|---|---| | POST | `/api/agents/errand` | `ErrandStartIn` | `ErrandTaskOut` | 심부름 시작(plan→게이트까지) | | GET | `/api/agents/errand` | — | `ErrandTaskOut[]` | 심부름 목록 | | GET | `/api/agents/errand/{eid}` | — | `ErrandTaskOut`(steps 포함) | 타임라인 조회 | | POST | `/api/worker/run/{job}` | `{slot?, week?}` | `WorkerRunOut` | 수동 트리거(proactive/digest/weekly_review/errand_runner) | | GET | `/api/proactive` | — | `ProactiveCardOut[]` | 능동 카드(active) | | POST | `/api/proactive/{pid}/accept` | — | `ProactiveAcceptOut` | 카드 수락→실체화 | | POST | `/api/proactive/{pid}/dismiss` | — | `{ok:true}` | 카드 닫기 | | GET | `/api/weekly-review?week=` | — | `WeeklyReviewOut` | 주간 리뷰 조회 | | POST | `/api/inbox/transcribe` (multipart) | `audio`,`hint?` | `TranscribeOut` | 음성→텍스트(STT) | | POST | `/api/inbox/caption` (multipart) | `image`,`hint?` | `CaptionOut` | 이미지→캡션/OCR(Vision) | > 결재함 승인/되돌리기는 **phase-7 기존 엔드포인트 재사용**: `POST /api/approvals/{id}/approve`, `POST /api/approvals/{id}/undo`. 심부름 게이트는 새 승인 엔드포인트를 만들지 않는다 — `source:"agent"` Approval로 결재함에 자연히 합류한다. ### 4.2 Pydantic 스키마 (`schemas.py` 추가, `types.ts`와 1:1) ```python # backend/app/schemas.py (추가) from pydantic import BaseModel class ErrandStartIn(BaseModel): kind: str # booking|refund|cancel|support title: str goal: str target: str | None = None tone: str | None = None class ErrandStepOut(BaseModel): id: str; seq: int; phase: str; tool: str | None label: str; detail: str | None external_effect: bool; state: str class ErrandTaskOut(BaseModel): id: str; kind: str; title: str; goal: str; target: str | None tone: str; status: str; approval_id: str | None result_summary: str | None; model: str steps: list[ErrandStepOut] class ProactiveCardOut(BaseModel): id: str; kind: str; icon: str; tone: str title: str; why: str cta: str | None; action_kind: str | None detected_at: str; status: str class ProactiveAcceptOut(BaseModel): id: str; action_kind: str created_id: str | None = None # 실체화된 task/focus_block/automation/approval id message: str class WeeklyReviewOut(BaseModel): id: str; week_label: str deep_work_h: float; meeting_h: float done_count: int; auto_count: int; saved_minutes: int patterns: list[dict] # [{title, suggest, action_kind, payload}] note: str | None class WorkerRunOut(BaseModel): job: str; ok: bool; detail: str class TranscribeOut(BaseModel): text: str; seconds: float; model: str; confidence: float class CaptionOut(BaseModel): text: str; ocr: str; model: str; confidence: float ``` ```ts // frontend/lib/types.ts (추가) export type ErrandKind = "booking" | "refund" | "cancel" | "support"; export type ErrandStatus = "planning" | "running" | "awaiting_approval" | "done" | "failed" | "cancelled"; export type StepState = "pending" | "running" | "done" | "blocked" | "error"; export interface ErrandStep { id: string; seq: number; phase: "plan"|"act"|"observe"|"reflect"; tool: string | null; label: string; detail: string | null; external_effect: boolean; state: StepState; } export interface ErrandTask { id: string; kind: ErrandKind; title: string; goal: string; target: string | null; tone: Tone; status: ErrandStatus; approval_id: string | null; result_summary: string | null; model: string; steps: ErrandStep[]; } export interface ProactiveCard { id: string; kind: "schedule"|"finance"|"focus"|"health"|"digest"; icon: string; tone: Tone; title: string; why: string; cta: string | null; action_kind: "focus_block"|"task"|"automation"|"approval"|"none"|null; detected_at: string; status: "active"|"accepted"|"dismissed"; } export interface WeeklyReview { id: string; week_label: string; deep_work_h: number; meeting_h: number; done_count: number; auto_count: number; saved_minutes: number; patterns: { title: string; suggest: string; action_kind: string; payload: any }[]; note: string | null; } export interface TranscribeResult { text: string; seconds: number; model: string; confidence: number; } export interface CaptionResult { text: string; ocr: string; model: string; confidence: number; } ``` ### 4.3 요청/응답 예시 (JSON) **심부름 시작 — a6 "Netflix 일시정지" 확장** 요청: ```http POST /api/agents/errand Content-Type: application/json { "kind": "cancel", "title": "Netflix 일시정지", "target": "Netflix", "goal": "모레 17,000원 결제 전 일시정지, 되돌리기 가능하게", "tone": "amber" } ``` 응답(게이트에서 멈춤 — external_effect step이 blocked): ```json { "id": "er1", "kind": "cancel", "title": "Netflix 일시정지", "target": "Netflix", "goal": "모레 17,000원 결제 전 일시정지, 되돌리기 가능하게", "tone": "amber", "status": "awaiting_approval", "approval_id": "ap-er1", "result_summary": null, "model": "scripted", "steps": [ { "id":"er1-s0","seq":0,"phase":"plan","tool":null,"label":"해지 절차 계획 수립", "detail":"결제일·해지 경로·되돌리기 옵션 확인","external_effect":false,"state":"done" }, { "id":"er1-s1","seq":1,"phase":"act","tool":"web_search","label":"Netflix 해지/일시정지 방법 확인", "detail":"계정 설정 > 멤버십 일시정지 경로 확인","external_effect":false,"state":"done" }, { "id":"er1-s2","seq":2,"phase":"observe","tool":null,"label":"최근 시청 기록 확인", "detail":"최근 2개월 시청 0건 — 일시정지 적합","external_effect":false,"state":"done" }, { "id":"er1-s3","seq":3,"phase":"act","tool":"http_fetch","label":"해지 페이지 접속·세션 준비", "detail":"로그인 세션 확보, 일시정지 폼 로드","external_effect":false,"state":"done" }, { "id":"er1-s4","seq":4,"phase":"reflect","tool":null,"label":"결제 임박 확인", "detail":"모레 17,000원 결제 예정 — 그 전에 처리 필요","external_effect":false,"state":"done" }, { "id":"er1-s5","seq":5,"phase":"act","tool":"form_fill","label":"일시정지 신청 제출", "detail":"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)","external_effect":true,"state":"blocked" } ] } ``` → 동시에 phase-7 `Approval` 생성(결재함에 등장): ```json { "id":"ap-er1","icon":"wallet","tone":"amber","risk":"high","status":"pending", "source":"agent","time":"확인 필요","title":"Netflix 일시정지을(를) 추천해요", "detail":"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)","cta":"일시정지","alt":"유지" } ``` → 사용자가 결재함에서 `POST /api/approvals/ap-er1/approve` → `approval.executed` 발행 → worker `errand_runner`가 `resume_after_approval` → s5 실행 → `status:"done"`, `result_summary:"모레 결제 전 일시정지 완료(되돌리기 가능)"`. **능동 카드 — 미팅 3연속** `POST /api/worker/run/proactive` 후 `GET /api/proactive`: ```json [ { "id":"pc1","kind":"schedule","icon":"cal","tone":"violet", "title":"내일 미팅 3연속 — 점심 비워뒀어요", "why":"10:00·11:00·13:00 연속, 12시 공백을 보호 블록으로 잡았어요", "cta":"그대로 둘게요","action_kind":"focus_block","detected_at":"방금","status":"active" }, { "id":"pc2","kind":"finance","icon":"wallet","tone":"amber", "title":"ChatGPT Plus 청구서가 평소보다 6천원 비싸요", "why":"평소 29,000원 → 이번 35,000원 · 사유 확인 권장", "cta":"자세히","action_kind":"task","detected_at":"방금","status":"active" } ] ``` **멀티모달 — 음성 transcribe** 요청: `POST /api/inbox/transcribe` (multipart: `audio`=녹음, `hint`="pool") 응답: ```json { "text": "음성 메모 0:14 — 수영장 차광막 부품 알아보기", "seconds": 0.0, "model": "heuristic", "confidence": 0.4 } ``` → 클라이언트가 `POST /api/inbox/capture {kind:"voice", raw:"음성 메모 0:14 — 수영장 차광막 부품 알아보기"}` → phase-4 분류 → `{proj:"집 관리 · 수영장", tone:"blue", status:"아이디어"}`(원본 `data.js magicInbox` 기대값). **주간 리뷰** `GET /api/weekly-review?week=6/7~6/13`: ```json { "id":"wr-2026-w24","week_label":"6/7~6/13", "deep_work_h":8.0,"meeting_h":12.0,"done_count":18,"auto_count":12,"saved_minutes":196, "patterns":[ { "title":"이번 주 딥워크 8h, 회의 12h", "suggest":"화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?", "action_kind":"automation", "payload":{"name":"화요일 오전 딥워크 보호","cat":"focus", "trigger":"매주 화요일 08:00","action":"09:00–11:00 방해 금지 블록 예약"} }], "note":"이번 주 딥워크 8h, 회의 12h — 화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?" } ``` ### 4.4 시드 데이터 (`seed.py` — 내부 헬퍼, `run_seed` 안에서 호출) ```python def _seed_errands(s: Session) -> None: """a6 Netflix 일시정지를 '진행 중 심부름'으로 시드(게이트 직전 상태).""" # ErrandTask er1 + steps s0~s4 done, s5 blocked + Approval ap-er1(pending) ... def _seed_proactive(s: Session) -> None: """능동 카드 2장(미팅 3연속 / 청구서 이상) — 대시보드 상단 데모.""" ... def _seed_weekly_review(s: Session) -> None: """6/7~6/13 주간 리뷰(딥워크 8h/회의 12h 패턴 제안).""" ... # run_seed 안: # _seed_errands(s); _seed_proactive(s); _seed_weekly_review(s) ``` | 테이블 | 시드 | 원본 근거 | |---|---|---| | `errand_task` | er1 "Netflix 일시정지"(cancel, amber, awaiting_approval) | `approve-data.js a6` | | `errand_step` | er1-s0~s5(s5=blocked, external) | a6 detail "정지 절차는 준비해뒀어요" | | `approval` | ap-er1(source:agent, risk:high, cta:"일시정지", alt:"유지") | a6 cta/alt 그대로 | | `proactive_card` | pc1 미팅 3연속, pc2 청구서 이상 | `data.js schedule`/`finance.subs` | | `weekly_review` | wr-2026-w24(8h/12h) | `wrap-data.js stats` 주간 합 | --- ## 5. 디자인 충실도 노트 ### 5.1 결재함 합류 (phase-7 재사용 — 새 카드 디자인 없음) 심부름 게이트는 phase-7 결재함 카드를 **그대로** 쓴다. `a6`의 시각 값을 인용: `icon:"wallet"`, `tone:"amber"`(#e0a23c), `risk:"high"`, `time:"확인 필요"`, `cta:"일시정지"`, `alt:"유지"`. high-risk 카드는 phase-7대로 `cta`/`alt` 버튼이 보이고 `undo_label`은 없다(승인 전이므로). 승인 후 실행되면 phase-7 `undo`(되돌리기)로 전이. ### 5.2 능동 카드 색/토큰 - 미팅 보호(schedule): `--violet`(#8b6fd4), `cal` 아이콘. 데이터 `data.js schedule`. - 청구서 이상(finance): `--amber`(#e0a23c), `wallet` 아이콘. 데이터 `data.js finance.subs`. - 딥워크(focus): `--coral`(#df7256), `zap` 아이콘. - `.pc-ok`는 phase-4 "좋아요" 라임 버튼 토큰 재사용(`--lime`/`--lime-ink`). - 능동 발화는 `aria-live="polite"`(post-mvp-overview §11 접근성 — 능동 알림 라이브 영역). ### 5.3 심부름 타임라인 - phase 아이콘: plan=`brain`(violet), act=`zap`, observe=`clock`, reflect=`spark`(phase-1/4 Icon P 맵). - step 상태색: done=green 14%, blocked=amber 16%, external step 행 배경 amber 7%. - 한국어 가독성: `.er-detail` `max-width:62ch`, `word-break:keep-all`(phase-1 전역 토큰). ### 5.4 멀티모달 컴포저 - mic/image 버튼은 phase-4 `.sb-mode`(34×34, radius 10px, `--muted`) 그대로. 녹음 중 `.sb-mode.rec`만 추가(coral 펄스 — phase-4 `sb-pulse` 재사용). - 캡처된 행의 kind 아이콘: voice→`mic`, image→`image`(phase-4 `CaptureRow` `cap-k` 분기 그대로). 멀티모달 실구현 후에도 **분류 결과 UI는 phase-4와 동일**(raw만 실제로 채워짐). ### 5.5 주간 리뷰 (하루 마감의 주간 변형) `wrap-data.js`의 stat 카드 톤(`{n, unit, label}`, `--font-mono` 숫자)을 주간 합으로 재사용. 딥워크/회의/완료/자동처리 4 스탯 + 패턴 제안 1~2건. 수면 노트 대신 `note`(한 줄 코칭). --- ## 6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스 | 상태/케이스 | 트리거 | UI/동작 | 구현 | |---|---|---|---| | **에이전트 진행 중** | errand running | 타임샙 step 상태가 pending→running→done 순차 갱신, `aria-live` | `ErrandStep.state` 폴링/SSE | | **승인 대기(게이트)** | external_effect step | step `blocked` + "결재함에서 승인 필요" + 결재함 링크 | `status=awaiting_approval` | | **에이전트 미가용(폴백)** | Ollama/tool 다운 | scripted 플랜으로 결정적 진행, `model:"scripted"` 표기 | `_scripted()` 분기 | | **예산 초과** | step/tool 폭주 | 가드레일 abort → `status=failed` + 사유, 부분 step 보존 | `guardrails.Budget` | | **능동 카드 없음** | 감지 0건 | 배너 미표시(`if(!cards.length) return null`) | `ProactiveBanner` | | **STT/Vision 미가용** | provider 폴백 | `HeuristicSTT/Vision`이 hint/더미 반환, `model:"heuristic"` | factory auto 폴백 | | **마이크 권한 거부** | getUserMedia 실패 | phase-4 톤 폴백 문구로 capture 계속("텍스트로 적어주세요") | catch → VOICE_FALLBACK | | **WORKER_ENABLED=false** | 기본 | 스케줄러 미가동, 수동 `POST /api/worker/run/{job}`로 동일 결과 | A4 | | **승인 후 재개 실패** | resume 중 오류 | step `error` + 재시도 버튼, 이미 한 외부 행동은 되돌리기(undo) 유지 | `resume_after_approval` catch | | **자율성 변경 중 진행** | full_auto여도 high | external_effect는 **항상 승인 대기**(A6 불변식) | `_raise_approval` 무조건 | > **핵심 엣지: 자율성 레벨과 무관한 high-risk 게이트.** `full_auto`로 올려도 외부 행동(발송/결제/해지)은 자동 실행되지 않는다 — `external_effect` step은 `_raise_approval`을 무조건 호출한다. 자율성 레벨은 *low* 항목의 자동 실행 여부만 바꾼다(post-mvp-overview §10 A6, §12 자율성 레벨 변경). --- ## 7. 연합 이벤트 (발행/구독) post-mvp-overview §9 매트릭스를 상속하고, 본 phase가 추가/사용하는 이벤트를 명시한다. ### 7.1 이 phase가 **발행**하는 이벤트 | 이벤트 | 발행 위치 | 페이로드 | 구독자(도착지) | |---|---|---|---| | `errand.updated` | `ErrandAgent._run_until_gate`/`resume_after_approval` | `{errand_id, seq, external}` | 타임라인 UI 갱신 · 관측성 로그 | | `errand.completed` | `_run_until_gate` 끝 | `{errand_id}` | 하루 마감(phase-12가 구독해 집계) · 대시보드 | | `approval.enqueued` | `_raise_approval`(external_effect) | `{id, risk:"high", source:"agent"}` | 결재함(phase-7) · 알림 벨 | | `proactive.detected` | `worker/jobs/proactive.run_proactive` | `{id, kind}` | 대시보드 상단 `ProactiveBanner` · 알림 벨 | | `digest.generated` | `worker/jobs/digest.run_digest` | `{digest_id, time, count}` | 알림(다이제스트 배달) · 능동 브리핑 | | `weekly.reviewed` | `worker/jobs/weekly_review.run_weekly_review` | `{id, patterns}` | 하루 마감(주간 카드) · 자동화 제안 | ### 7.2 이 phase가 **구독**하는 이벤트 | 구독 이벤트 | 발행처 | 본 phase의 처리 | |---|---|---| | `approval.executed` | 결재함(phase-7) | `source:"agent"`면 `errand_runner`가 `resume_after_approval` 호출(이어 실행) | | `mail.received` | 메일(phase-9)/RealConnector(phase-13) | proactive 감지 입력(중요 메일·청구 메일) | | `finance.transaction` | 라이프(phase-11)/RealConnector | `_detect_pricey_bill` 입력(청구서 이상) | | `calendar.updated` | 일정(phase-8) | proactive 미팅 보호/딥워크 카드 입력(연속 회의·빈 블록 감지) | | `health.sample` | 라이프(phase-11)/RealConnector | 건강 코칭 능동 카드 입력 | ### 7.3 대표 연합 경로 — 심부름→결재함→되돌리기 (F6 확장) ``` POST /api/agents/errand {kind:"cancel", target:"Netflix"} → ErrandAgent.start → plan/act/observe/reflect (s0~s4 done) → s5(form_fill, external_effect) → guardrails.external_effect_of=True → _raise_approval: Approval(ap-er1, risk=high, source=agent, cta="일시정지") → bus.publish("approval.enqueued") ; ErrandTask.status=awaiting_approval ▼ 결재함(phase-7)에 a6 톤 카드 등장 사용자: POST /api/approvals/ap-er1/approve → phase-7: status approved→executed ; bus.publish("approval.executed", {source:"agent"}) ▼ worker/errand_runner 구독 → ErrandAgent.resume_after_approval: s5 실행(실제 일시정지) → done → ErrandTask.done, result_summary="모레 결제 전 일시정지 완료(되돌리기 가능)" → bus.publish("errand.completed") ▼ 하루 마감(phase-12)이 errand.completed 구독해 집계: "아리 자동 처리"에 합산, "47분 아낌" 류 되돌리기 필요 시: phase-7 POST /api/approvals/ap-er1/undo (undo_label) ``` ### 7.4 대표 연합 경로 — 멀티모달→분류→작업 (F1 확장) ``` [음성] CaptureComposer 녹음 → POST /api/inbox/transcribe (STTProvider) → "음성 메모 0:14 — 수영장 차광막 부품 알아보기" → POST /api/inbox/capture {kind:"voice", raw:...} (phase-4 분류 — 무변경) → capture.classified {type:"idea", proj:"집 관리 · 수영장", tone:"blue"} → confirm → (idea는 보드 보관; task면 task.created → 작업 페이지 등장) ``` --- ## 8. 안전 / 신뢰 (Trust & Safety) CONTRACT의 안전 요구를 데이터·코드 불변식으로 못 박는다. | 원칙 | 구현 | 검증 | |---|---|---| | **외부 행동은 항상 승인** | `external_effect` step → `_raise_approval(risk="high")`, 자율성 레벨 무관 | `test_errand_external_effect_gated` | | **모든 자동 실행은 되돌리기** | low approval은 phase-7 `undo_label`; 심부름 실행 후 `undo`로 전이 | `test_undo_after_errand` | | **모든 행동은 감사 로그** | `ErrandStep`(타임라인) + phase-7 `approval_log`(time/text) + 구조적 로깅(event 발행) | 로그 점검 | | **자율성 레벨 준수** | low 자동 실행은 `autonomy_setting.level`(mixed↑); high는 항상 대기 | `test_autonomy_levels` | | **자원 예산** | `guardrails.Budget`(MAX_STEPS=12/RETRIES=2/TOOL_CALLS=8) | `test_agent_budget` | | **모델 비종속·폴백** | scripted/heuristic 폴백 → 오프라인/CI 동일 동작 | `test_scripted_determinism` | | **프라이버시** | STT/Vision 로컬 처리(Ollama/whisper류), 외부 토큰은 phase-13 암호화 저장 | phase-13/15 | > **신뢰의 핵심 메시지(UI).** 심부름 타임라인은 "무엇을, 왜, 어디까지" 했는지 step 단위로 투명하게 보여준다(approve-data.js 철학: "신뢰 + 투명성"의 log[]와 동일 정신). 외부 영향 단계는 명시적으로 `external` 표시 + 결재함 링크 — 사용자가 *모르는 사이*에 결제/해지되지 않는다. --- ## 9. 테스팅 & 검증 ### 9.1 실행 명령 ```bash # 백엔드 cd backend && uv run pytest -q uv run pytest -k "errand or proactive or weekly or multimodal" # 본 phase uv run pytest -k "agent and budget" # 가드레일 예산 uv run uvicorn app.main:app --reload # 로컬 서버(:8000) # 수동 트리거(데모 결정성, WORKER_ENABLED=false 기본) curl -s -X POST localhost:8000/api/worker/run/proactive | jq . curl -s -X POST localhost:8000/api/worker/run/weekly_review -d '{"week":"6/7~6/13"}' | jq . curl -s -X POST localhost:8000/api/agents/errand \ -d '{"kind":"cancel","title":"Netflix 일시정지","target":"Netflix","goal":"결제 전 정지"}' | jq . # 프런트 cd frontend && pnpm test # Vitest + RTL pnpm test errand proactive composer # 본 phase 관련 pnpm playwright test proactive.spec.ts errand.spec.ts multimodal.spec.ts pnpm playwright test --grep @a11y # axe ``` ### 9.2 백엔드 테스트 (pytest) `backend/tests/test_errand.py` — scripted 폴백으로 LLM 없이도 결정적 통과: ```python from app.agents.errand import ErrandAgent from app.models import ErrandStatus, StepState, Approval def test_errand_external_effect_gated(session): er = ErrandAgent(session).start(kind="cancel", title="Netflix 일시정지", goal="결제 전 정지", target="Netflix") # 외부 영향(form_fill) step 직전에서 멈춤 assert er.status == ErrandStatus.awaiting_approval ext = [s for s in _steps(session, er.id) if s.external_effect] assert len(ext) == 1 and ext[0].state == StepState.blocked # 결재함에 high-risk 승인 생성(a6 문구) ap = session.get(Approval, er.approval_id) assert ap.risk == "high" and ap.source == "agent" assert ap.cta == "일시정지" and ap.alt == "유지" def test_resume_after_approval_completes(session, client): er = ErrandAgent(session).start(kind="cancel", title="Netflix 일시정지", goal="x", target="Netflix") client.post(f"/api/approvals/{er.approval_id}/approve") from app.worker.jobs.errand_runner import run_errand_queue run_errand_queue(session); session.refresh(er) assert er.status == ErrandStatus.done assert "되돌리기 가능" in (er.result_summary or "") def test_full_auto_still_gates_high(session): _set_autonomy(session, "full_auto") er = ErrandAgent(session).start(kind="cancel", title="x", goal="x", target="Netflix") assert er.status == ErrandStatus.awaiting_approval # full_auto여도 high는 대기 def test_agent_budget(session, monkeypatch): # 동적 plan이 MAX_STEPS 초과 시 abort → failed (부분 step 보존) ... ``` `backend/tests/test_proactive.py`: ```python from app.worker.jobs.proactive import run_proactive def test_meeting_streak_card(session): _seed_three_consecutive_meetings(session) # 10/11/13시 cards = run_proactive(session) titles = [c.title for c in cards] assert any("미팅 3연속" in t for t in titles) sched = next(c for c in cards if c.kind == "schedule") assert sched.action_kind == "focus_block" def test_pricey_bill_card(session): _seed_bill_anomaly(session, name="ChatGPT Plus", avg=29000, this=35000) cards = run_proactive(session) assert any("평소보다" in c.title and c.tone == "amber" for c in cards) def test_accept_proactive_materializes(session, client): run_proactive(session) pc = _first_active(session, "schedule") out = client.post(f"/api/proactive/{pc.id}/accept").json() assert out["action_kind"] == "focus_block" and out["created_id"] ``` `backend/tests/test_multimodal.py`: ```python from app.multimodal.factory import get_stt, get_vision def test_stt_heuristic_golden(monkeypatch): monkeypatch.setenv("STT_PROVIDER", "heuristic") t = get_stt().transcribe(b"", hint="pool") assert t.text == "음성 메모 0:14 — 수영장 차광막 부품 알아보기" assert t.model == "heuristic" def test_vision_heuristic_golden(monkeypatch): monkeypatch.setenv("VISION_PROVIDER", "heuristic") c = get_vision().describe(b"", hint="clip") assert c.text == "캡처 사진 — 차광막 클립 부품" def test_transcribe_then_capture_classifies(client): r = client.post("/api/inbox/transcribe", files={"audio": ("a.webm", b"", "audio/webm")}, data={"hint": "pool"}) text = r.json()["text"] cap = client.post("/api/inbox/capture", json={"kind": "voice", "raw": text}).json() assert cap["classification"]["type"] in ("idea", "task") assert "수영장" in cap["classification"]["proj_label"] def test_weekly_review_pattern(session): from app.worker.jobs.weekly_review import run_weekly_review _seed_week(session, deep=8, meeting=12) wr = run_weekly_review(session, week_label="6/7~6/13") assert wr.deep_work_h == 8 and wr.meeting_h == 12 assert any(p["action_kind"] == "automation" for p in _patterns(wr)) ``` ### 9.3 컴포넌트 테스트 (Vitest + RTL) `tests/ErrandTimeline.test.tsx`: ```ts test("blocked external step → 결재함 안내 + 잠금 표시", () => { render(); expect(screen.getByText("일시정지 신청 제출")).toBeInTheDocument(); expect(screen.getByText(/결재함에서 승인 필요/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /결재함/ })).toHaveAttribute("href", "/approvals"); }); test("done → result_summary 표시", () => { render(); expect(screen.getByText(/되돌리기 가능/)).toBeInTheDocument(); }); ``` `tests/ProactiveCard.test.tsx`: ```ts test("카드: title/why/cta 렌더 + tone 색", () => { render(); expect(screen.getByText("내일 미팅 3연속 — 점심 비워뒀어요")).toBeInTheDocument(); expect(screen.getByText(/12시 공백/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "그대로 둘게요" })).toBeInTheDocument(); }); test("카드 없으면 배너 미표시", () => { const { container } = render(); expect(container.querySelector(".proactive")).toBeNull(); }); ``` `tests/CaptureComposer.mm.test.tsx` (멀티모달 폴백): ```ts test("getUserMedia 미지원 → voice 폴백 문구로 제출", async () => { const onSubmit = vi.fn(); // navigator.mediaDevices 미정의 환경 render(); fireEvent.click(screen.getByLabelText("음성으로 캡처")); expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("텍스트로 적어주세요"), "voice"); }); ``` ### 9.4 E2E (Playwright) `playwright/errand.spec.ts`: ```ts test("심부름 → 결재함 게이트 → 승인 → 완료(연합)", async ({ page, request }) => { await request.post("/api/agents/errand", { data: { kind:"cancel", title:"Netflix 일시정지", target:"Netflix", goal:"결제 전 정지" }}); await page.goto("/approvals"); await expect(page.getByText("Netflix 일시정지을(를) 추천해요")).toBeVisible(); await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible(); // high cta await page.getByRole("button", { name: "일시정지" }).click(); // 이어 실행 후 완료 await request.post("/api/worker/run/errand_runner"); await page.goto("/agents/errand/er1"); // 또는 결재함 로그 await expect(page.getByText(/되돌리기 가능/)).toBeVisible(); }); ``` `playwright/proactive.spec.ts`: ```ts test("능동 카드 → 대시보드 상단 노출 → 수락", async ({ page, request }) => { await request.post("/api/worker/run/proactive"); await page.goto("/dashboard"); await expect(page.getByText("내일 미팅 3연속 — 점심 비워뒀어요")).toBeVisible(); await page.getByRole("button", { name: "그대로 둘게요" }).click(); // focus_block 실체화 → 일정/대시보드에 반영 }); ``` `playwright/multimodal.spec.ts`: ```ts test("이미지 캡처 → 캡션 → 분류", async ({ page }) => { await page.goto("/inbox"); await page.setInputFiles('input[type=file]', "fixtures/clip.jpg"); await expect(page.locator(".cap-k.image").first()).toBeVisible(); await expect(page.getByText(/차광막|수영장/)).toBeVisible(); // 캡션→분류 }); test("@a11y 능동 배너 aria-live", async ({ page, request }) => { await request.post("/api/worker/run/proactive"); await page.goto("/dashboard"); await expect(page.locator('[aria-live="polite"]')).toBeVisible(); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); ``` ### 9.5 수동 QA 체크리스트 - [ ] `POST /api/agents/errand`("Netflix 일시정지") → 타임라인 s0~s4 done, s5 `blocked`(external) + "결재함에서 승인 필요". - [ ] 결재함에 `a6` 톤 카드(amber, "일시정지"/"유지", "확인 필요") 등장 — `source:agent`. - [ ] 결재함에서 "일시정지" 승인 → `errand_runner` 후 심부름 `done`, "모레 결제 전 일시정지 완료(되돌리기 가능)". - [ ] 자율성 `full_auto`로 올려도 external step은 여전히 승인 대기(자동 실행 안 됨). - [ ] `POST /api/worker/run/proactive` → 대시보드 상단 능동 배너 2장(미팅 3연속 violet / 청구서 amber). - [ ] 능동 카드 "그대로 둘게요"/"자세히" → focus_block/task 실체화, 카드는 accepted로 사라짐. - [ ] 능동 배너 `aria-live="polite"` — 스크린리더가 선제 제안을 읽음. - [ ] 인박스 mic 버튼 → 실제 녹음(권한 허용 시) → transcribe → raw 채워져 분류. 권한 거부 → "텍스트로 적어주세요" 폴백. - [ ] 인박스 image 버튼 → 파일 선택 → caption → raw 채워져 분류(차광막/수영장 골든). - [ ] STT/Vision 미가용(OLLAMA off): `model:"heuristic"`로 골든 transcript/caption, 분류는 정상. - [ ] `GET /api/weekly-review` → 딥워크 8h/회의 12h + "화요일 오전 보호할까요?" 제안. 수락 시 자동화 규칙 생성. - [ ] 다크 테마 토글: 능동 카드/타임라인/배지 색이 토큰으로 자연스럽다. - [ ] 키보드 only: 능동 카드 cta/닫기, 심부름 결재함 링크, mic/image 버튼 Tab 순회 + Enter/Space. ### 9.6 통과 기준 - 백엔드: errand 게이트/재개/full_auto 불변식, proactive 감지/수락, multimodal 골든, weekly 패턴, agent budget 전부 green — **scripted/heuristic 폴백으로 LLM 없이도 결정적 통과**. - 프런트: 컴포넌트(타임라인/능동 카드/멀티모달 폴백) green. - E2E: 심부름→결재함→승인→완료(연합), 능동 카드→대시보드→수락, 멀티모달→분류, a11y(aria-live + axe 0건) green. - 안전 불변식: external_effect 자동 실행 0건(테스트로 강제), 모든 외부 행동에 approval_log 기록. --- ## 10. 완료 기준 (Definition of Done) - [ ] `backend/app/multimodal/`(STTProvider/VisionProvider + factory, env 주입 + 폴백) 구현, `/api/inbox/transcribe`·`/api/inbox/caption` 동작. - [ ] 인박스 mic/image **실구현**(녹음/업로드 → transcribe/caption → phase-4 분류). 미가용 시 graceful 폴백. - [ ] `backend/app/agents/errand.py` 심부름 에이전트(plan→act→observe→reflect, step 기록) + `guardrails`(external_effect 게이트 + 예산). - [ ] external_effect step → phase-7 `Approval(risk=high, source=agent)` 게이트. 승인 후 `errand_runner`가 이어 실행, 되돌리기 가능. - [ ] `backend/app/worker/jobs/`(proactive/digest/weekly_review/errand_runner) + 수동 트리거 `/api/worker/run/{job}`(WORKER_ENABLED=false 기본). - [ ] `ProactiveCard`/`WeeklyReview` 모델·API, 대시보드 상단 `ProactiveBanner`(aria-live), 주간 리뷰 패턴→자동화/집중 블록 실체화. - [ ] 연합: 심부름→결재함→승인→완료→하루 마감 합산, 능동 카드→작업/일정/자동화, 멀티모달→분류→작업. - [ ] 안전 불변식: external 행동 항상 승인(full_auto여도), 모든 자동 실행 되돌리기+감사 로그. - [ ] 모델 비종속: scripted/heuristic 폴백으로 오프라인/CI 결정적 동작(`model` 필드 표기). - [ ] 신규 테이블/엔드포인트가 phase-2 패턴(PK=TEXT, 라우터 내부 prefix 없음 + `include_router(prefix="/api")`, `run_seed` 내부 `_seed_*` 헬퍼)을 따름. - [ ] 9장 테스트/QA 전부 통과. 디자인은 원본(`approve-data.js a6`/`data.js`/`wrap-data.js`/phase-4) 토큰·문구를 그대로 재현. --- ## 11. 다음 단계 다음 문서는 **`phase-15-production.md`**(프로덕션 하드닝). 본 phase의 산출물이 phase-15에서 다음과 같이 다뤄진다. - **worker/스케줄러 운영화**: `WORKER_ENABLED=true` 프로덕션 가동, 잡 실패 알림·재시도·관측성(능동 잡 메트릭: proactive 생성률, digest 배달, errand 완료율). - **인증/멀티유저**: 심부름·능동 카드·주간 리뷰가 **per-user 스코프**(현재 단일 데모 사용자 지우)로 격리. `AUTH_ENABLED=true`. - **안전/보안 강화**: 외부 행동 승인의 감사 추적(approval_log)을 변조 불가 로그로, STT/Vision/에이전트의 입력·출력 프라이버시 경계(로컬 처리 보장), high-risk 행동의 2차 확인 옵션. - **멀티모달 실모델**: `STT_MODEL`/`VISION_MODEL`을 실제 whisper/비전 모델로 주입, 성능(녹음 길이·이미지 크기 한도)·비용 가드. - **에이전트 tool 실연동**: `form_fill`/`http_fetch`가 phase-13 `RealConnector.write()`와 결합해 실제 외부 시스템에 쓰기(예약 확정/해지)까지 — 프로덕션 권한·rate limit·롤백 정책. phase 7~14 전체 연합(자율성 코어 → 페이지 → 실연동 → 능동)의 프로덕션 통합·관측성·성능·보안 종합은 **`phase-15-production.md`** 에서 마무리한다.