# 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:<model>" | "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:<model>" | "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:<model>" | "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<string, string> = {
  plan: "brain", act: "zap", observe: "clock", reflect: "spark",
};
const STATE_LABEL: Record<string, string> = {
  pending: "대기", running: "진행 중", done: "완료", blocked: "승인 대기", error: "오류",
};

export default function ErrandTimeline({ er }: { er: ErrandTask }) {
  return (
    <section className="card errand">
      <div className="ch">
        <div className="ico"><Icon name="wallet" /></div>
        <div className="htext">
          <h3>{er.title}</h3>
          <div className="sub">{er.goal}</div>
        </div>
        <span className={"er-badge " + er.status}>{statusLabel(er.status)}</span>
      </div>

      <ol className="er-steps">
        {er.steps.map((st) => (
          <li key={st.id} className={"er-step " + st.state +
              (st.external_effect ? " external" : "")}>
            <span className="er-dot"><Icon name={PHASE_ICON[st.phase] ?? "spark"} /></span>
            <div className="er-line">
              <span className="er-label">{st.label}</span>
              {st.detail && <span className="er-detail">{st.detail}</span>}
            </div>
            <span className={"er-state " + st.state}>{STATE_LABEL[st.state]}</span>
            {st.external_effect && st.state === "blocked" && (
              <span className="er-gate"><Icon name="lock" />결재함에서 승인 필요</span>
            )}
          </li>
        ))}
      </ol>

      {er.status === "awaiting_approval" && (
        <a className="auton-note" href="/approvals">
          <Icon name="arrow" />외부 행동(결제/해지)은 결재함에서 한 번 확인하면 이어서 처리할게요.
        </a>
      )}
      {er.status === "done" && er.result_summary && (
        <div className="er-result"><Icon name="tick" w={3} />{er.result_summary}</div>
      )}
    </section>
  );
}
```

스타일(토큰만 사용 — 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 (
    <div className="proactive" role="region" aria-live="polite" aria-label="아리의 선제 제안">
      {cards.map((c) => (
        <div key={c.id} className="pc" style={{ ["--tone" as any]: `var(--${c.tone})` }}>
          <span className="pc-ic"><Icon name={c.icon} /></span>
          <div className="pc-body">
            <div className="pc-title">{c.title}</div>
            <div className="pc-why">{c.why}</div>
          </div>
          <div className="pc-acts">
            {c.cta && (
              <button className="pc-ok" onClick={() => acceptProactive(c.id)}>{c.cta}</button>
            )}
            <button className="pc-x" aria-label="이 제안 닫기"
              onClick={() => dismissProactive(c.id)}><Icon name="x" /></button>
          </div>
        </div>
      ))}
    </div>
  );
}
```

```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<MediaRecorder | null>(null);
  const fileRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
    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 (
    <div className="sb-cmd">
      <Icon name="spark" />
      <input value={input} onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && submitText()}
        placeholder="갑자기 생각난 것 아무거나…" aria-label="인박스에 빠르게 캡처" />
      <button className={"sb-mode" + (recording ? " rec" : "")} onClick={toggleMic}
        aria-label={recording ? "녹음 중지" : "음성으로 캡처"} aria-pressed={recording}>
        <Icon name="mic" />
      </button>
      <button className="sb-mode" onClick={() => fileRef.current?.click()}
        aria-label="이미지로 캡처"><Icon name="image" /></button>
      <input ref={fileRef} type="file" accept="image/*" hidden onChange={onImage} />
      <button className="sb-send" onClick={submitText} aria-label="보내기"><Icon name="arrow" /></button>
    </div>
  );
}
```

> **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(<ErrandTimeline er={ERR_AWAITING} />);
  expect(screen.getByText("일시정지 신청 제출")).toBeInTheDocument();
  expect(screen.getByText(/결재함에서 승인 필요/)).toBeInTheDocument();
  expect(screen.getByRole("link", { name: /결재함/ })).toHaveAttribute("href", "/approvals");
});
test("done → result_summary 표시", () => {
  render(<ErrandTimeline er={ERR_DONE} />);
  expect(screen.getByText(/되돌리기 가능/)).toBeInTheDocument();
});
```

`tests/ProactiveCard.test.tsx`:
```ts
test("카드: title/why/cta 렌더 + tone 색", () => {
  render(<ProactiveBanner cards={[PC_MEETING]} />);
  expect(screen.getByText("내일 미팅 3연속 — 점심 비워뒀어요")).toBeInTheDocument();
  expect(screen.getByText(/12시 공백/)).toBeInTheDocument();
  expect(screen.getByRole("button", { name: "그대로 둘게요" })).toBeInTheDocument();
});
test("카드 없으면 배너 미표시", () => {
  const { container } = render(<ProactiveBanner cards={[]} />);
  expect(container.querySelector(".proactive")).toBeNull();
});
```

`tests/CaptureComposer.mm.test.tsx` (멀티모달 폴백):
```ts
test("getUserMedia 미지원 → voice 폴백 문구로 제출", async () => {
  const onSubmit = vi.fn();
  // navigator.mediaDevices 미정의 환경
  render(<CaptureComposer onSubmit={onSubmit} />);
  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`** 에서 마무리한다.
