You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ari_assistant/dev/phase-14-proactive-agent.md

92 KiB

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.jsa6 "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:"유지" 심부름 에이전트의 결재함 게이트(ErrandStepApproval)
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.pybus/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 헬퍼.

# 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=TrueErrandStep반드시 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). 미가용 시 폴백으로 데모 결정성.

# 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)
# 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)
# 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 환경변수 상속):

# 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가 실제로 추출됨).

# 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 폴백(결정성).

# 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_runnerresume_after_approval로 이어 실행한다.

3.5 가드레일 — backend/app/agents/guardrails.py

# 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 동기화 데이터) — 같은 테이블을 읽으므로 코드 무변경.

# 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}/acceptaction_kind에 따라 focus_block/task/automation/approval로 실체화한다.

3.7 능동 다이제스트 — backend/app/worker/jobs/digest.py

# 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일치 합산해 패턴을 찾고, 수락 시 자동화/집중 블록으로 실체화한다.

# 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:0011: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_kindautomation이면 phase-7 automation_rule로, focus_block이면 phase-8 블록으로 실체화한다 — 제안→자동화/일정 연합.

3.9 worker 잡 등록 & 수동 트리거 — backend/app/worker/

phase-7이 소유한 worker/main.pyregister(scheduler)에 본 phase 잡을 추가한다(phase-13 sync_jobs.register와 동일 패턴). WORKER_ENABLED=false(기본, A4)면 스케줄러 미가동 — 대신 수동 트리거 엔드포인트로 데모/CI 결정성을 확보한다.

# 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),
}
# 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) 도 같은 진입에서 호출됨
# 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

# 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 포함 → 타임라인
# 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

"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 상속):

.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"로 능동 발화를 스크린리더에 알린다.

"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>
  );
}
.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).

"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)

# 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
// 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 일시정지" 확장

요청:

POST /api/agents/errand
Content-Type: application/json

{ "kind": "cancel", "title": "Netflix 일시정지", "target": "Netflix",
  "goal": "모레 17,000원 결제 전 일시정지, 되돌리기 가능하게", "tone": "amber" }

응답(게이트에서 멈춤 — external_effect step이 blocked):

{
  "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 생성(결재함에 등장):

{ "id":"ap-er1","icon":"wallet","tone":"amber","risk":"high","status":"pending",
  "source":"agent","time":"확인 필요","title":"Netflix 일시정지을(를) 추천해요",
  "detail":"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)","cta":"일시정지","alt":"유지" }

→ 사용자가 결재함에서 POST /api/approvals/ap-er1/approveapproval.executed 발행 → worker errand_runnerresume_after_approval → s5 실행 → status:"done", result_summary:"모레 결제 전 일시정지 완료(되돌리기 가능)".

능동 카드 — 미팅 3연속

POST /api/worker/run/proactiveGET /api/proactive:

[
  { "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") 응답:

{ "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:

{ "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:0011:00 방해 금지 블록 예약"} }],
  "note":"이번 주 딥워크 8h, 회의 12h — 화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?" }

4.4 시드 데이터 (seed.py — 내부 헬퍼, run_seed 안에서 호출)

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_runnerresume_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 실행 명령

# 백엔드
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 없이도 결정적 통과:

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:

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:

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:

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:

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 (멀티모달 폴백):

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:

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:

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:

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 에서 마무리한다.