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) 로 승격한다. 세 가지 능력이 추가된다.
- 생활 심부름 에이전트(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:"유지")를 실제 대행 흐름으로 확장. - 능동 알림(proactive) —
backend/app/worker/(phase-7 도입) 스케줄러가 작업·일정·금융·메일을 주기적으로 훑어 상황을 감지하고 선제 카드를 만든다: "내일 미팅 3연속 — 점심 비워뒀어요", "이 청구서가 평소보다 2만원 비싸요". 카드는 대시보드 상단/알림 벨에 뜨고, 한 번 탭으로 작업/일정/자동화로 흐른다(또는 결재함으로). - 멀티모달 캡처 실구현 —
STTProvider(음성→텍스트, whisper류)·VisionProvider(이미지→캡션/OCR)를 LLM Provider와 동일한 패턴(env 주입·폴백)으로 구현해, phase-4 인박스의 voice/image 스텁을 실제로 만든다. transcript/caption을 기존 분류 파이프라인(POST /api/inbox/capture)에 연결한다. - 주간 리뷰 / 패턴 코칭 —
하루 마감(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 헬퍼.
# 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-7Approval(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를 발행하고, workererrand_runner가resume_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}/accept가action_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:00–11:00 방해 금지 블록 예약"},
})
return out
def _coach_note(agg, patterns) -> str:
if patterns:
return f"이번 주 딥워크 {agg['deep_work_h']:.0f}h, 회의 {agg['meeting_h']:.0f}h — {patterns[0]['suggest']}"
return "이번 주는 회의와 딥워크 균형이 좋았어요."
주간 리뷰 수치는
wrap-data.js톤을 따른다({n:"2.5", unit:"시간", label:"딥 워크"}의 주간 합). 패턴 제안 수락 시action_kind가automation이면 phase-7automation_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 결정성을 확보한다.
# 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-4sb-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/approve → approval.executed 발행 → worker errand_runner가 resume_after_approval → s5 실행 → status:"done", result_summary:"모레 결제 전 일시정지 완료(되돌리기 가능)".
능동 카드 — 미팅 3연속
POST /api/worker/run/proactive 후 GET /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:00–11: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-detailmax-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-4sb-pulse재사용). - 캡처된 행의 kind 아이콘: voice→
mic, image→image(phase-4CaptureRowcap-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_effectstep은_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 실행 명령
# 백엔드
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, s5blocked(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-13RealConnector.write()와 결합해 실제 외부 시스템에 쓰기(예약 확정/해지)까지 — 프로덕션 권한·rate limit·롤백 정책.
phase 7~14 전체 연합(자율성 코어 → 페이지 → 실연동 → 능동)의 프로덕션 통합·관측성·성능·보안 종합은 phase-15-production.md 에서 마무리한다.