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

1623 lines
92 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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: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_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:0011: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`** 에서 마무리한다.