|
|
# Phase 14 — 능동형 에이전트 & 멀티모달 캡처
|
|
|
|
|
|
> 한 줄 요약: 아리가 "대신 실행"하고(생활 심부름 에이전트: 예약·환불·구독해지·고객센터 대행) "먼저 말 거는"(능동 알림 다이제스트·주간 리뷰/패턴 코칭) 단계로 올라서고, phase-4에서 스텁이던 **멀티모달 캡처(음성 STT / 이미지 Vision)** 를 실제로 구현해 분류 파이프라인에 연결한다. 모든 외부 행동(발송·결제·해지)은 **결재함(phase-7) high-risk 승인**을 거치고, 모든 자동 실행은 **되돌리기 + 감사 로그**를 남긴다.
|
|
|
>
|
|
|
> 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md`(MVP 정본)와 `dev/post-mvp-overview.md`(포스트-MVP 정본)를 읽으세요. 선행 문서: `phase-7-approvals-automation.md`(자율성 코어·event_bus·worker·approval/autonomy_setting), `phase-8-calendar-meetings.md`, `phase-9-mail-notifications.md`, `phase-10-research-travel.md`(agents/·rag/·web_search·trip planner), `phase-11-life-care.md`, `phase-12-daily-narrative.md`(wrap 집계), `phase-13-integrations.md`(RealConnector — 실데이터 이벤트 공급). 다음 문서: `phase-15-production.md`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
### 1.1 이 phase가 끝나면 무엇이 동작하는가
|
|
|
|
|
|
phase 7~13까지는 아리가 "이미 해뒀어요"를 **결재함**에 쌓고(자동화 매칭·메일/회의 추출), **mock→real 커넥터**로 실데이터를 끌어왔다. Phase 14는 아리를 **행위자(actor)이자 발화자(initiator)** 로 승격한다. 세 가지 능력이 추가된다.
|
|
|
|
|
|
1. **생활 심부름 에이전트(errand agent)** — `backend/app/agents/`(phase-10 도입) 위의 멀티스텝 루프(plan→act(tool)→observe→reflect)가 "Netflix 해지", "호텔 환불 요청", "고객센터 문의"를 **대신 실행**한다. 진행 타임라인을 보여주고, 외부에 영향을 주는 단계(발송·결제·해지)는 **결재함(phase-7) high-risk 승인**으로 올린다. 시드 예: `approve-data.js`의 `a6` *"Netflix 일시정지를 추천해요"*(risk:high, cta:"일시정지", alt:"유지")를 **실제 대행 흐름**으로 확장.
|
|
|
2. **능동 알림(proactive)** — `backend/app/worker/`(phase-7 도입) 스케줄러가 작업·일정·금융·메일을 주기적으로 훑어 상황을 감지하고 **선제 카드**를 만든다: *"내일 미팅 3연속 — 점심 비워뒀어요"*, *"이 청구서가 평소보다 2만원 비싸요"*. 카드는 대시보드 상단/알림 벨에 뜨고, 한 번 탭으로 작업/일정/자동화로 흐른다(또는 결재함으로).
|
|
|
3. **멀티모달 캡처 실구현** — `STTProvider`(음성→텍스트, whisper류)·`VisionProvider`(이미지→캡션/OCR)를 LLM Provider와 **동일한 패턴**(env 주입·폴백)으로 구현해, phase-4 인박스의 voice/image **스텁을 실제로** 만든다. transcript/caption을 기존 분류 파이프라인(`POST /api/inbox/capture`)에 연결한다.
|
|
|
4. **주간 리뷰 / 패턴 코칭** — `하루 마감`(phase-12)의 **주간 버전**: 한 주의 딥워크/회의/완료를 집계해 *"이번 주 딥워크 8h, 회의 12h — 화요일 오전을 보호할까요?"* 같은 패턴 기반 제안을 만들고, 수락 시 자동화 규칙(phase-7)·반복 집중 블록(phase-8)으로 실체화한다.
|
|
|
|
|
|
### 1.2 능동성의 한 줄 정의 (철학 반영)
|
|
|
|
|
|
`approve-data.js` 주석을 그대로 인용한다: *"아리는 '할까요?'라고 묻지 않고 미리 해둔다."* Phase 14는 이 정신을 **행동(심부름)** 과 **선제 발화(능동 카드/주간 리뷰)** 로 확장하되, 안전 게이트는 그대로 유지한다 — *"risk: low → 자율성 '혼합' 이상에서 자동 실행(되돌리기 가능), risk: high → 보내기/결제/타인에게 전달 등은 승인 대기."* 외부 행동(예약·환불·구독해지·발송)은 **항상 high-risk** → 사용자가 *읽고 탭 한 번*.
|
|
|
|
|
|
### 1.3 모델 비종속 (CONTRACT A2 상속)
|
|
|
|
|
|
에이전트 루프·STT·Vision은 **모델 비종속**이다. `AGENT_PROVIDER`/`STT_PROVIDER`/`VISION_PROVIDER`(`auto`)와 `*_MODEL`(env 주입)로 구동하고, 미가용·오프라인·CI에서는 **scripted/heuristic 폴백**으로 데모 결정성을 보장한다(post-mvp-overview §10 A2, §11 결정성). 즉 Ollama가 꺼져 있어도 심부름 타임라인·능동 카드·인박스 분류가 동일하게 재현된다.
|
|
|
|
|
|
### 1.4 디자인 기준 (원본 인용)
|
|
|
|
|
|
| 원본 | 인용 값 | 본 phase 재현 |
|
|
|
|---|---|---|
|
|
|
| `approve-data.js` `a6` | `risk:"high"`, `tone:"amber"`, `time:"확인 필요"`, `title:"Netflix 일시정지를 추천해요"`, `detail:"최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요"`, `cta:"일시정지"`, `alt:"유지"` | 심부름 에이전트의 결재함 게이트(`ErrandStep` → `Approval`) |
|
|
|
| `approve-data.js` `savedToday:"47분"`, `autoCountNight:7`, `log[]` | "스탠드업 직전 — 어제 진행 요약 노트 생성"(08:55) 등 | 능동 카드/심부름의 `approval_log` 집계 |
|
|
|
| `data.js` `magicInbox.captures[]` | `{kind:"voice", raw:"음성 메모 0:14 — 수영장 차광막 부품 알아보기"}`, `{kind:"image", raw:"캡처 사진 — 차광막 클립 부품"}` | STT/Vision 실구현의 골든 입력/기대 transcript·caption |
|
|
|
| `sinbox-data.js` `items[].kind` | `kind: "text"\|"voice"\|"image"`, s3 `"음성 메모 0:09 — 엄마 생신 선물 미리 알아보기"` | 멀티모달→분류 파이프라인 연결 |
|
|
|
| `wrap-data.js` `stats`/`highlights`/`sleepNote` | `{n:"2.5", unit:"시간", label:"딥 워크"}`, `sleepNote:"알림 음소거 중 · 내일 아침 7:00 브리핑으로 깨워드릴게요"` | 주간 리뷰가 일 단위 wrap을 주(週) 단위로 집계 |
|
|
|
| `data.js` `finance.subs[]` | `{name:"Spotify", date:"6월 9일", amt:"10,900"}`, `{name:"ChatGPT Plus", date:"6월 12일", amt:"29,000"}` | 능동 카드 "청구서가 평소보다 비싸요"의 근거 데이터 |
|
|
|
| `data.js` `schedule[]` | `14:00 분기 전략 미팅(soon:true)`, 1:1 16:30 | 능동 카드 "미팅 3연속 — 점심 비워뒀어요" 근거 |
|
|
|
|
|
|
> 본 phase는 새 페이지를 만들기보다 **기존 표면**(대시보드 상단, 알림 벨, 결재함, 인박스, 하루 마감)에 능동/심부름/멀티모달을 얹는다. 심부름 진행 타임라인 드로어와 주간 리뷰 카드만 신규 컴포넌트다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건(의존 phase) / 산출물(Deliverables)
|
|
|
|
|
|
### 2.1 선행 조건
|
|
|
|
|
|
| 의존 | 문서 | 이 phase가 사용하는 산출물 |
|
|
|
|---|---|---|
|
|
|
| 자율성 코어 | `phase-7-approvals-automation.md` | `event_bus`(발행/구독 정본, `backend/app/automation/event_bus.py` — `bus`/`publish`), `evaluator`, `approval`/`autonomy_setting`/`approval_log` 모델 + `/api/approvals`, **`backend/app/worker/`(APScheduler + `worker/main.py` `register(scheduler)`)** — 심부름 게이트·능동 잡을 여기에 등록 |
|
|
|
| 에이전트/RAG/웹검색 | `phase-10-research-travel.md` | `backend/app/agents/`(plan→act→observe→reflect 루프 + `AGENT_PROVIDER`), 툴 `web_search`/`rag_query`/`http_fetch`/`calendar_write`/`task_create`/`form_fill(stub)`, scripted 폴백 패턴(trip planner 제주/도쿄 결정성) |
|
|
|
| 일정·회의 | `phase-8-calendar-meetings.md` | `calendar`/`event`/`focus_block`, `calendar.updated`/`calendar.focus_scheduled` 이벤트, `calendar_write` 도착지 — 능동 카드/주간 리뷰가 집중 블록을 만든다 |
|
|
|
| 메일·알림 | `phase-9-mail-notifications.md` | `email.ai{...}`, `notification`/`digest`/`notify_stats` — 능동 카드/다이제스트 배달 표면(알림 벨) |
|
|
|
| 라이프 | `phase-11-life-care.md` | `finance`/`finance_tx`(청구서 이상 감지 근거), `health`(주간 코칭 근거), `connector_source` |
|
|
|
| 여정·하루 마감 | `phase-12-daily-narrative.md` | `/api/wrap` 일 집계 서비스 — **주간 리뷰가 7일치 wrap을 합산** |
|
|
|
| 실연동 | `phase-13-integrations.md` | `RealConnector.write()`(에이전트 tool이 외부에 **쓰기**: `calendar_write`→real 캘린더), 실데이터 이벤트(`mail.received`/`finance.transaction`/`health.sample`) — 능동 감지의 입력 |
|
|
|
| 인박스 | `phase-4-inbox.md` | `POST /api/inbox/capture {kind, raw}` 동기 분류, `CaptureComposer`(mic/image 버튼), `inbox_item.kind`(text/voice/image) — STT/Vision이 raw를 채워 넣음 |
|
|
|
|
|
|
### 2.2 산출물(Deliverables)
|
|
|
|
|
|
```
|
|
|
backend/app/
|
|
|
├─ agents/ (phase-10 확장)
|
|
|
│ ├─ loop.py plan→act→observe→reflect 루프 심화(재시도·가드레일·예산)
|
|
|
│ ├─ errand.py (+) 생활 심부름 에이전트(예약/환불/구독해지/고객센터) + step 기록
|
|
|
│ ├─ tools/
|
|
|
│ │ ├─ form_fill.py (+) form_fill 실제화(stub→시나리오 스크립트) + http_fetch 연동
|
|
|
│ │ └─ ... web_search/rag_query/http_fetch/calendar_write/task_create (phase-10)
|
|
|
│ └─ guardrails.py (+) 외부 영향 단계 판정(external_effect) → 결재함 게이트
|
|
|
├─ worker/ (phase-7 도입)
|
|
|
│ ├─ main.py register(scheduler) — 잡 등록 진입(phase-7 소유, 본 phase 가 잡 추가)
|
|
|
│ ├─ jobs/
|
|
|
│ │ ├─ proactive.py (+) 능동 감지 잡 → proactive_card 생성
|
|
|
│ │ ├─ digest.py (+) 능동 다이제스트/브리핑 생성(09:00/13:00/18:30)
|
|
|
│ │ ├─ weekly_review.py (+) 주간 리뷰/패턴 코칭(일요일/요청 시)
|
|
|
│ │ └─ errand_runner.py (+) 승인된 심부름 step 진행(백그라운드)
|
|
|
│ └─ triggers.py (+) 수동 트리거 매핑(WORKER_ENABLED=false 프로토타입)
|
|
|
├─ multimodal/ (+) 멀티모달 캡처 추상화 (LLM Provider 패턴)
|
|
|
│ ├─ stt.py STTProvider(ABC) + OllamaWhisperSTT / HeuristicSTT(폴백)
|
|
|
│ ├─ vision.py VisionProvider(ABC) + OllamaVisionVision / HeuristicVision(폴백)
|
|
|
│ └─ factory.py get_stt() / get_vision() (env 주입 + auto 폴백)
|
|
|
├─ models.py (+) ErrandTask, ErrandStep, ProactiveCard, WeeklyReview
|
|
|
├─ schemas.py (+) ErrandTaskOut/StepOut, ProactiveCardOut, WeeklyReviewOut, TranscribeOut
|
|
|
├─ routers/
|
|
|
│ ├─ agents.py (+) /api/agents/errand* (심부름 시작·조회·승인 후 재개)
|
|
|
│ ├─ worker.py (+) /api/worker/run/{job} (수동 트리거), /api/proactive*
|
|
|
│ └─ inbox.py (확장) /api/inbox/transcribe, /api/inbox/caption (멀티모달 실구현)
|
|
|
└─ seed.py (+) _seed_errands / _seed_proactive / _seed_weekly_review
|
|
|
|
|
|
frontend/
|
|
|
├─ components/
|
|
|
│ ├─ errand/
|
|
|
│ │ ├─ ErrandTimeline.tsx (+) 심부름 진행 타임라인 드로어(step 상태/승인 게이트)
|
|
|
│ │ └─ ErrandCard.tsx (+) 심부름 요약 카드(결재함/대시보드)
|
|
|
│ ├─ proactive/
|
|
|
│ │ ├─ ProactiveBanner.tsx(+) 대시보드 상단 능동 카드 묶음
|
|
|
│ │ └─ ProactiveCard.tsx (+) 한 장(상황 + 근거 why + 한 번 탭 액션)
|
|
|
│ ├─ weekly/
|
|
|
│ │ └─ WeeklyReview.tsx (+) 주간 리뷰(딥워크/회의 집계 + 패턴 제안)
|
|
|
│ └─ inbox/
|
|
|
│ └─ CaptureComposer.tsx (확장) mic→실제 녹음→/transcribe, image→업로드→/caption
|
|
|
├─ lib/
|
|
|
│ ├─ api.ts (+) startErrand/getErrand/approveErrandStep, getProactive/dismiss, transcribe/caption, getWeeklyReview, runWorkerJob
|
|
|
│ └─ types.ts (+) ErrandTask/ErrandStep/ProactiveCard/WeeklyReview/TranscribeResult
|
|
|
├─ tests/ (Vitest+RTL) · playwright/ (E2E)
|
|
|
```
|
|
|
|
|
|
> 신규 라우터(`agents`/`worker`)도 **내부 prefix 없이** 정의하고 `main.py`에서 `app.include_router(agents.router, prefix="/api", tags=["agents"])`로만 `/api` 부착(phase-2 규약). 시드는 `run_seed(session=None, reset=True)` 안에서 `_seed_errands(s)` 등 **내부 헬퍼**로 호출(공개 진입점 아님).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현 (파일별·단계별)
|
|
|
|
|
|
### 3.0 큰 그림 — 능동 레이어 데이터 흐름
|
|
|
|
|
|
```
|
|
|
┌─────────────────────── worker/ (스케줄러 또는 수동 트리거) ──────────────────────┐
|
|
|
│ WORKER_ENABLED=true → APScheduler 주기 실행 │
|
|
|
│ WORKER_ENABLED=false → POST /api/worker/run/{job} 수동(프로토타입·CI 결정성) │
|
|
|
│ job: proactive | digest | weekly_review | errand_runner | connector_sync(13) │
|
|
|
└───────┬───────────────────┬───────────────────┬────────────────────┬─────────────┘
|
|
|
▼ ▼ ▼ ▼
|
|
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
|
|
│ proactive.py │ │ digest.py │ │ weekly_review│ │ errand_runner.py │
|
|
|
│ 작업/일정/금융│ │ 09/13/18:30 │ │ 7일 wrap 집계│ │ 승인된 step 진행 │
|
|
|
│ /메일 스캔 │ │ 다이제스트 │ │ → 패턴 제안 │ │ (agents/loop) │
|
|
|
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘
|
|
|
│ ProactiveCard │ Digest+event │ WeeklyReview │ ErrandStep 전이
|
|
|
▼ ▼ ▼ ▼
|
|
|
bus.publish: proactive.detected · digest.generated · weekly.reviewed · errand.updated
|
|
|
│ │
|
|
|
▼ 구독: 대시보드 상단/알림 벨/결재함 ▼ external_effect 단계
|
|
|
┌──────────────────────────────────────────────┐ ┌─────────────────────────────┐
|
|
|
│ ProactiveBanner / 알림 벨(digest) / │ │ guardrails.external_effect? │
|
|
|
│ 결재함(approval 게이트) / 작업·일정(한 번 탭) │ │ → Approval(risk="high") │
|
|
|
└──────────────────────────────────────────────┘ │ → /api/approvals 승인 대기 │
|
|
|
└─────────────────────────────┘
|
|
|
|
|
|
[멀티모달] CaptureComposer mic 녹음 → POST /api/inbox/transcribe(STTProvider) → raw 텍스트
|
|
|
image 업로드 → POST /api/inbox/caption(VisionProvider) → raw 캡션/OCR
|
|
|
→ 기존 POST /api/inbox/capture {kind:"voice"|"image", raw} (phase-4 분류 그대로)
|
|
|
```
|
|
|
|
|
|
### 3.1 데이터 모델 — `backend/app/models.py` (추가)
|
|
|
|
|
|
post-mvp-overview §8.1 코어(approval/autonomy_setting/automation_*)를 **상속**하고, 능동 레이어 신규 4테이블을 추가한다. 모든 PK는 **TEXT(str)**, `tone` enum은 `blue|violet|coral|green|amber|ink|faint`, `now()`는 phase-2 헬퍼.
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (phase-14 추가 — phase-2/7 패턴 동일)
|
|
|
from __future__ import annotations
|
|
|
from datetime import datetime
|
|
|
from enum import Enum
|
|
|
from sqlmodel import SQLModel, Field
|
|
|
from .models import now # phase-2 헬퍼(datetime.now(timezone.utc))
|
|
|
|
|
|
|
|
|
# ── 심부름 에이전트 ───────────────────────────────────────────────
|
|
|
class ErrandKind(str, Enum):
|
|
|
booking = "booking" # 예약(병원/식당/수리)
|
|
|
refund = "refund" # 환불 요청
|
|
|
cancel = "cancel" # 구독 해지/일시정지 (a6 Netflix)
|
|
|
support = "support" # 고객센터 문의
|
|
|
|
|
|
class ErrandStatus(str, Enum):
|
|
|
planning = "planning" # plan 단계
|
|
|
running = "running" # act/observe 진행 중
|
|
|
awaiting_approval = "awaiting_approval" # external_effect → 결재함 대기
|
|
|
done = "done"
|
|
|
failed = "failed"
|
|
|
cancelled = "cancelled" # 사용자 취소
|
|
|
|
|
|
class ErrandTask(SQLModel, table=True):
|
|
|
id: str = Field(primary_key=True) # "er1".."erN"
|
|
|
kind: str = ErrandKind.cancel # ErrandKind value
|
|
|
title: str # "Netflix 일시정지" (a6 확장)
|
|
|
goal: str # 자연어 목표(사용자 입력 또는 능동 제안)
|
|
|
target: str | None = None # "Netflix" | "스테이 부산역" 등 대상
|
|
|
tone: str = "amber" # 결재함 카드 tone(a6=amber)
|
|
|
status: str = ErrandStatus.planning
|
|
|
autonomy_at_start: str = "mixed" # 시작 시점 자율성 레벨 스냅샷
|
|
|
approval_id: str | None = None # 게이트가 만든 Approval(phase-7) FK
|
|
|
result_summary: str | None = None # "모레 결제 전 일시정지 완료(되돌리기 가능)"
|
|
|
model: str = "" # "agent:<model>" | "scripted"
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
updated_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
class StepState(str, Enum):
|
|
|
pending = "pending"
|
|
|
running = "running"
|
|
|
done = "done"
|
|
|
blocked = "blocked" # 승인 대기로 막힘
|
|
|
error = "error"
|
|
|
|
|
|
class ErrandStep(SQLModel, table=True):
|
|
|
"""plan→act(tool)→observe→reflect 한 스텝. 타임라인의 1행."""
|
|
|
id: str = Field(primary_key=True) # "er1-s1" ...
|
|
|
errand_id: str = Field(foreign_key="errandtask.id")
|
|
|
seq: int # 0,1,2,...
|
|
|
phase: str # "plan"|"act"|"observe"|"reflect"
|
|
|
tool: str | None = None # "web_search"|"http_fetch"|"form_fill"|...
|
|
|
label: str # 타임라인 한 줄(한국어): "해지 페이지 접속"
|
|
|
detail: str | None = None # observe 결과 요약
|
|
|
external_effect: bool = False # True면 결재함 게이트(발송/결제/해지)
|
|
|
state: str = StepState.pending
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ── 능동 카드 ────────────────────────────────────────────────────
|
|
|
class ProactiveKind(str, Enum):
|
|
|
schedule = "schedule" # 미팅 연속 → 점심 보호
|
|
|
finance = "finance" # 청구서 이상
|
|
|
focus = "focus" # 딥워크 보호
|
|
|
health = "health" # 건강 코칭
|
|
|
digest = "digest" # 다이제스트/브리핑
|
|
|
|
|
|
class ProactiveCard(SQLModel, table=True): # 능동 감지 결과 1장
|
|
|
id: str = Field(primary_key=True) # "pc1".."pcN"
|
|
|
kind: str = ProactiveKind.schedule
|
|
|
icon: str = "spark" # Icon P 맵 키(cal/wallet/zap/spark...)
|
|
|
tone: str = "violet" # blue|violet|coral|green|amber|ink|faint
|
|
|
title: str # "내일 미팅 3연속 — 점심 비워뒀어요"
|
|
|
why: str # 근거 한 줄: "10:00·11:00·13:00 연속, 12시 공백"
|
|
|
cta: str | None = None # "그대로 둘게요" | "작업으로" | "자동화로"
|
|
|
action_kind: str | None = None # "focus_block"|"task"|"automation"|"approval"|"none"
|
|
|
action_payload: str | None = None # JSON(text): 실행에 필요한 인자
|
|
|
detected_at: str = "방금" # 표시용 상대 시간(원본 톤)
|
|
|
status: str = "active" # active|accepted|dismissed
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
# ── 주간 리뷰 ────────────────────────────────────────────────────
|
|
|
class WeeklyReview(SQLModel, table=True): # 하루 마감의 주(週) 집계
|
|
|
id: str = Field(primary_key=True) # "wr-2026-w24"
|
|
|
week_label: str # "6/7~6/13"
|
|
|
deep_work_h: float = 0.0 # 딥워크 시간 합(wrap stats 합산)
|
|
|
meeting_h: float = 0.0 # 회의 시간 합
|
|
|
done_count: int = 0 # 완료 작업 수
|
|
|
auto_count: int = 0 # 아리 자동 처리 수
|
|
|
saved_minutes: int = 0 # 아낀 시간(분)
|
|
|
patterns_json: str = "[]" # [{title, suggest, action_kind, payload}] JSON
|
|
|
note: str | None = None # 한국어 한 줄 코칭
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
```
|
|
|
|
|
|
> **상속 주의.** `from .models import now` 식의 자기참조는 실제로는 같은 파일 내 `now()`를 그대로 사용한다(예시는 추가 위치를 명확히 하려는 표기). `external_effect=True`인 `ErrandStep`은 **반드시** phase-7 `Approval`(risk="high")을 만들고 `ErrandTask.approval_id`/`ErrandTask.status=awaiting_approval`로 전이한다. 이것이 안전 게이트의 데이터 표현이다.
|
|
|
|
|
|
### 3.2 멀티모달 캡처 추상화 — `backend/app/multimodal/`
|
|
|
|
|
|
LLM Provider(phase-2)와 **동일한 패턴**: ABC + 실제(Ollama류) + 폴백(heuristic) + `get_*()` 팩토리(env `*_PROVIDER`/`*_MODEL`). 미가용 시 폴백으로 데모 결정성.
|
|
|
|
|
|
```python
|
|
|
# backend/app/multimodal/stt.py
|
|
|
from __future__ import annotations
|
|
|
from abc import ABC, abstractmethod
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
|
class Transcript:
|
|
|
text: str
|
|
|
seconds: float = 0.0
|
|
|
model: str = "" # "stt:<model>" | "heuristic"
|
|
|
confidence: float = 0.0
|
|
|
|
|
|
class STTProvider(ABC):
|
|
|
name: str = "base"
|
|
|
@abstractmethod
|
|
|
def health(self) -> dict: ...
|
|
|
@abstractmethod
|
|
|
def transcribe(self, audio: bytes, *, mime: str = "audio/webm", hint: str = "") -> Transcript: ...
|
|
|
|
|
|
|
|
|
class OllamaWhisperSTT(STTProvider):
|
|
|
"""STT_MODEL(whisper류) 주입. Ollama/로컬 whisper 서버에 오디오를 보낸다."""
|
|
|
name = "stt"
|
|
|
def __init__(self):
|
|
|
from ..config import get_settings
|
|
|
self.st = get_settings()
|
|
|
def health(self) -> dict:
|
|
|
import httpx
|
|
|
try:
|
|
|
r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0)
|
|
|
return {"reachable": r.status_code == 200, "model": self.st.stt_model}
|
|
|
except Exception:
|
|
|
return {"reachable": False}
|
|
|
def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript:
|
|
|
# 실제 구현: 로컬 whisper(예: faster-whisper HTTP 또는 ollama 멀티모달)에 전송
|
|
|
# 실패/미가용 시 폴백으로 자동 전환(아래 factory.auto 경로)
|
|
|
...
|
|
|
|
|
|
|
|
|
class HeuristicSTT(STTProvider):
|
|
|
"""오프라인/CI 폴백. 더미 transcript(또는 hint 그대로) — 데모 결정성."""
|
|
|
name = "heuristic"
|
|
|
# 골든: data.js magicInbox voice / sinbox s3 음성 메모를 결정적으로 재현
|
|
|
GOLDEN = {
|
|
|
"pool": "음성 메모 0:14 — 수영장 차광막 부품 알아보기",
|
|
|
"gift": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기",
|
|
|
}
|
|
|
def health(self) -> dict: return {"reachable": True, "model": "heuristic"}
|
|
|
def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript:
|
|
|
text = self.GOLDEN.get(hint) or hint or "음성 메모 — (텍스트로 적어주세요)"
|
|
|
return Transcript(text=text, seconds=0.0, model="heuristic", confidence=0.4)
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/multimodal/vision.py
|
|
|
from __future__ import annotations
|
|
|
from abc import ABC, abstractmethod
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
|
class Caption:
|
|
|
text: str # 캡션 또는 OCR 텍스트
|
|
|
ocr: str = "" # OCR 전용(영수증/스크린샷)
|
|
|
model: str = "" # "vision:<model>" | "heuristic"
|
|
|
confidence: float = 0.0
|
|
|
|
|
|
class VisionProvider(ABC):
|
|
|
name: str = "base"
|
|
|
@abstractmethod
|
|
|
def health(self) -> dict: ...
|
|
|
@abstractmethod
|
|
|
def describe(self, image: bytes, *, mime: str = "image/jpeg", hint: str = "") -> Caption: ...
|
|
|
|
|
|
|
|
|
class OllamaVisionVision(VisionProvider):
|
|
|
"""VISION_MODEL(멀티모달 비전 모델) 주입. 이미지→캡션/OCR."""
|
|
|
name = "vision"
|
|
|
def __init__(self):
|
|
|
from ..config import get_settings
|
|
|
self.st = get_settings()
|
|
|
def health(self) -> dict:
|
|
|
import httpx
|
|
|
try:
|
|
|
r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0)
|
|
|
return {"reachable": r.status_code == 200, "model": self.st.vision_model}
|
|
|
except Exception:
|
|
|
return {"reachable": False}
|
|
|
def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption:
|
|
|
... # 실제: ollama 멀티모달 generate(images=[b64]) → 캡션 + OCR
|
|
|
|
|
|
|
|
|
class HeuristicVision(VisionProvider):
|
|
|
name = "heuristic"
|
|
|
GOLDEN = {"clip": "캡처 사진 — 차광막 클립 부품"}
|
|
|
def health(self) -> dict: return {"reachable": True, "model": "heuristic"}
|
|
|
def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption:
|
|
|
text = self.GOLDEN.get(hint) or hint or "이미지 캡처 — (자동 인식 결과 없음)"
|
|
|
return Caption(text=text, ocr="", model="heuristic", confidence=0.4)
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/multimodal/factory.py
|
|
|
from __future__ import annotations
|
|
|
from ..config import get_settings
|
|
|
from .stt import STTProvider, OllamaWhisperSTT, HeuristicSTT
|
|
|
from .vision import VisionProvider, OllamaVisionVision, HeuristicVision
|
|
|
|
|
|
def get_stt(force: str | None = None) -> STTProvider:
|
|
|
mode = force or get_settings().stt_provider # auto|ollama|heuristic
|
|
|
if mode == "heuristic": return HeuristicSTT()
|
|
|
if mode == "ollama": return OllamaWhisperSTT()
|
|
|
p = OllamaWhisperSTT() # auto
|
|
|
return p if p.health().get("reachable") else HeuristicSTT()
|
|
|
|
|
|
def get_vision(force: str | None = None) -> VisionProvider:
|
|
|
mode = force or get_settings().vision_provider
|
|
|
if mode == "heuristic": return HeuristicVision()
|
|
|
if mode == "ollama": return OllamaVisionVision()
|
|
|
p = OllamaVisionVision()
|
|
|
return p if p.health().get("reachable") else HeuristicVision()
|
|
|
```
|
|
|
|
|
|
`config.py` 확장(post-mvp-overview §8.3 환경변수 상속):
|
|
|
|
|
|
```python
|
|
|
# backend/app/config.py (Settings 에 추가)
|
|
|
stt_provider: str = "auto" # STT_PROVIDER auto|ollama|heuristic
|
|
|
stt_model: str = "whisper" # STT_MODEL (주입 — 모델 비종속)
|
|
|
vision_provider: str = "auto" # VISION_PROVIDER auto|ollama|heuristic
|
|
|
vision_model: str = "llava" # VISION_MODEL (주입 — placeholder)
|
|
|
agent_provider: str = "auto" # AGENT_PROVIDER auto|tool|scripted (phase-10 도입)
|
|
|
worker_enabled: bool = False # WORKER_ENABLED 스케줄러 on/off(프로토타입은 수동 트리거)
|
|
|
```
|
|
|
|
|
|
### 3.3 인박스 멀티모달 실구현 — `routers/inbox.py` 확장
|
|
|
|
|
|
phase-4의 capture 파이프라인은 그대로 두고, **raw 텍스트를 채워 넣는 두 엔드포인트**를 앞단에 추가한다. transcript/caption을 얻은 뒤 클라이언트가 기존 `POST /api/inbox/capture {kind, raw}`를 호출한다(분류 경로 불변 — phase-4 §3.7 "스텁 설계 결정" 그대로, 이제 raw가 실제로 추출됨).
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/inbox.py (추가 — 내부 prefix 없음)
|
|
|
from fastapi import APIRouter, UploadFile, File, Form
|
|
|
from ..multimodal.factory import get_stt, get_vision
|
|
|
from ..schemas import TranscribeOut, CaptionOut
|
|
|
|
|
|
router = APIRouter() # main.py 에서 include_router(prefix="/api", tags=["inbox"])
|
|
|
|
|
|
@router.post("/inbox/transcribe", response_model=TranscribeOut)
|
|
|
async def transcribe(audio: UploadFile = File(...), hint: str = Form("")):
|
|
|
data = await audio.read()
|
|
|
t = get_stt().transcribe(data, mime=audio.content_type or "audio/webm", hint=hint)
|
|
|
return TranscribeOut(text=t.text, seconds=t.seconds, model=t.model, confidence=t.confidence)
|
|
|
|
|
|
@router.post("/inbox/caption", response_model=CaptionOut)
|
|
|
async def caption(image: UploadFile = File(...), hint: str = Form("")):
|
|
|
data = await image.read()
|
|
|
c = get_vision().describe(data, mime=image.content_type or "image/jpeg", hint=hint)
|
|
|
return CaptionOut(text=c.text, ocr=c.ocr, model=c.model, confidence=c.confidence)
|
|
|
```
|
|
|
|
|
|
> **연합(phase-4 확장).** 멀티모달은 분류 파이프라인을 새로 만들지 않는다 — 음성/이미지를 *텍스트로 환원*한 뒤 phase-4의 `capture`가 그대로 분류한다. 따라서 `data.js magicInbox`의 `{kind:"voice", raw:"...수영장 차광막 부품..."}`이 STT로 실제 채워지고, `→ {proj:"집 관리 · 수영장", tone:"blue", status:"아이디어"}`로 phase-4가 분류한다(원본 기대값 그대로).
|
|
|
|
|
|
### 3.4 심부름 에이전트 — `backend/app/agents/errand.py`
|
|
|
|
|
|
phase-10의 멀티스텝 루프(plan→act→observe→reflect)를 **심부름**에 특화한다. 핵심: (1) 모든 step을 `ErrandStep`으로 기록(타임라인), (2) `external_effect` step은 실행 직전 멈추고 **결재함(phase-7) high-risk 게이트**를 세운다, (3) Ollama/tool 미가용 시 `scripted` 폴백(결정성).
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/errand.py
|
|
|
from __future__ import annotations
|
|
|
from sqlmodel import Session
|
|
|
from ..models import (ErrandTask, ErrandStep, ErrandKind, ErrandStatus, StepState,
|
|
|
Approval, AutonomySetting, now)
|
|
|
from ..automation.event_bus import bus # phase-7 정본(backend/app/automation/event_bus.py)
|
|
|
from .loop import AgentLoop # phase-10 plan→act→observe→reflect
|
|
|
from .guardrails import external_effect_of
|
|
|
from ..config import get_settings
|
|
|
|
|
|
|
|
|
# 데모 결정성: a6 "Netflix 일시정지" scripted 플랜(AGENT_PROVIDER=scripted 또는 폴백)
|
|
|
SCRIPTED = {
|
|
|
"cancel:Netflix": [
|
|
|
("plan", None, "해지 절차 계획 수립", "결제일·해지 경로·되돌리기 옵션 확인", False),
|
|
|
("act", "web_search", "Netflix 해지/일시정지 방법 확인", "계정 설정 > 멤버십 일시정지 경로 확인", False),
|
|
|
("observe", None, "최근 시청 기록 확인", "최근 2개월 시청 0건 — 일시정지 적합", False),
|
|
|
("act", "http_fetch", "해지 페이지 접속·세션 준비", "로그인 세션 확보, 일시정지 폼 로드", False),
|
|
|
("reflect", None, "결제 임박 확인", "모레 17,000원 결제 예정 — 그 전에 처리 필요", False),
|
|
|
("act", "form_fill", "일시정지 신청 제출", "‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)", True), # external_effect
|
|
|
],
|
|
|
}
|
|
|
|
|
|
class ErrandAgent:
|
|
|
def __init__(self, session: Session):
|
|
|
self.s = session
|
|
|
self.cfg = get_settings()
|
|
|
|
|
|
def start(self, *, kind: str, title: str, goal: str, target: str | None,
|
|
|
tone: str = "amber") -> ErrandTask:
|
|
|
autonomy = self.s.get(AutonomySetting, "default") or AutonomySetting(id="default", level="mixed")
|
|
|
er = ErrandTask(id=_eid(self.s), kind=kind, title=title, goal=goal, target=target,
|
|
|
tone=tone, status=ErrandStatus.planning,
|
|
|
autonomy_at_start=autonomy.level, model=self._model_tag())
|
|
|
self.s.add(er); self.s.commit()
|
|
|
self._run_until_gate(er)
|
|
|
return er
|
|
|
|
|
|
def _run_until_gate(self, er: ErrandTask):
|
|
|
"""external_effect step 직전까지 진행. 만나면 결재함 게이트를 세우고 멈춘다."""
|
|
|
er.status = ErrandStatus.running
|
|
|
for seq, (phase, tool, label, detail, ext) in enumerate(self._plan(er)):
|
|
|
existing = _step(self.s, er.id, seq)
|
|
|
if existing and existing.state == StepState.done:
|
|
|
continue
|
|
|
step = existing or ErrandStep(id=f"{er.id}-s{seq}", errand_id=er.id, seq=seq,
|
|
|
phase=phase, tool=tool, label=label, detail=detail,
|
|
|
external_effect=ext)
|
|
|
if ext or external_effect_of(phase, tool, label):
|
|
|
step.external_effect = True
|
|
|
step.state = StepState.blocked
|
|
|
self.s.add(step)
|
|
|
self._raise_approval(er, step) # ← 안전 게이트
|
|
|
er.status = ErrandStatus.awaiting_approval
|
|
|
self.s.add(er); self.s.commit()
|
|
|
return
|
|
|
# 외부 영향 없는 step은 즉시 실행(되돌리기 불필요)
|
|
|
step.state = StepState.running; self.s.add(step); self.s.commit()
|
|
|
self._execute(er, step) # tool 호출(또는 scripted observe)
|
|
|
step.state = StepState.done; self.s.add(step)
|
|
|
bus.publish("errand.updated",
|
|
|
{"errand_id": er.id, "seq": seq, "external": False})
|
|
|
self.s.commit()
|
|
|
er.status = ErrandStatus.done
|
|
|
er.result_summary = self._summary(er)
|
|
|
self.s.add(er); self.s.commit()
|
|
|
bus.publish("errand.completed", {"errand_id": er.id})
|
|
|
|
|
|
def _raise_approval(self, er: ErrandTask, step: ErrandStep):
|
|
|
"""external_effect → phase-7 Approval(risk=high) 생성. a6 문구를 그대로 사용."""
|
|
|
ap = Approval(
|
|
|
id=f"ap-{er.id}", icon="wallet", tone=er.tone, risk="high", status="pending",
|
|
|
source="agent", time="확인 필요",
|
|
|
title=f"{er.title}을(를) 추천해요" if er.kind == ErrandKind.cancel else er.title,
|
|
|
detail=step.detail or er.goal,
|
|
|
cta="일시정지" if er.kind == ErrandKind.cancel else "진행",
|
|
|
alt="유지" if er.kind == ErrandKind.cancel else "취소",
|
|
|
created_at=now())
|
|
|
self.s.add(ap)
|
|
|
er.approval_id = ap.id
|
|
|
bus.publish("approval.enqueued", {"id": ap.id, "risk": "high", "source": "agent"})
|
|
|
|
|
|
def resume_after_approval(self, er: ErrandTask):
|
|
|
"""결재함 승인 후 worker(errand_runner)가 호출 — blocked step 실행 후 계속."""
|
|
|
blocked = _blocked_step(self.s, er.id)
|
|
|
if blocked:
|
|
|
blocked.state = StepState.running; self.s.add(blocked); self.s.commit()
|
|
|
self._execute(er, blocked) # 실제 외부 행동(승인됨)
|
|
|
blocked.state = StepState.done; self.s.add(blocked); self.s.commit()
|
|
|
bus.publish("errand.updated",
|
|
|
{"errand_id": er.id, "seq": blocked.seq, "external": True})
|
|
|
self._run_until_gate(er) # 다음 게이트까지 또는 완료
|
|
|
|
|
|
# ── 플랜 선택: scripted(폴백/결정성) 우선, tool-capable이면 동적 plan ──
|
|
|
def _plan(self, er: ErrandTask):
|
|
|
key = f"{er.kind}:{er.target}"
|
|
|
if self._scripted() or key in SCRIPTED:
|
|
|
return SCRIPTED.get(key, SCRIPTED["cancel:Netflix"])
|
|
|
return AgentLoop(self.s).plan_steps(goal=er.goal, tools=[
|
|
|
"web_search", "http_fetch", "form_fill"]) # phase-10 동적 plan
|
|
|
def _execute(self, er, step):
|
|
|
if self._scripted(): return # scripted는 observe만(부작용 없음)
|
|
|
AgentLoop(self.s).run_tool(step.tool, label=step.label) # phase-10 tool 실행
|
|
|
def _scripted(self) -> bool:
|
|
|
return self.cfg.agent_provider == "scripted" or not AgentLoop.available()
|
|
|
def _model_tag(self) -> str:
|
|
|
return "scripted" if self._scripted() else f"agent:{self.cfg.ollama_model}"
|
|
|
def _summary(self, er) -> str:
|
|
|
return ("모레 결제 전 일시정지 완료(되돌리기 가능)" if er.kind == ErrandKind.cancel
|
|
|
else "요청을 처리했어요")
|
|
|
```
|
|
|
|
|
|
> **핵심 안전 불변식.** `external_effect`(발송·결제·해지·전달) step은 **절대 자동 실행되지 않는다** — 항상 `Approval(risk="high")`를 만들고 `awaiting_approval`로 멈춘다(자율성 레벨 `full_auto`여도 high는 항상 대기, post-mvp-overview §10 A6). 결재함에서 사용자가 *cta*(예 "일시정지")를 누르면 phase-7이 `approval.executed`를 발행하고, worker `errand_runner`가 `resume_after_approval`로 이어 실행한다.
|
|
|
|
|
|
### 3.5 가드레일 — `backend/app/agents/guardrails.py`
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/guardrails.py
|
|
|
"""에이전트 행동의 외부 영향 판정 + 재시도/예산 가드.
|
|
|
외부 영향(발송/결제/해지/전달/공유)은 결재함 high-risk 게이트로 강제한다."""
|
|
|
|
|
|
EXTERNAL_TOOLS = {"form_fill", "calendar_write", "task_create"} # 외부에 쓰는 tool
|
|
|
EXTERNAL_KEYWORDS = ("제출", "신청", "발송", "보내", "결제", "해지", "정지", "전달", "공유", "예약 확정")
|
|
|
|
|
|
def external_effect_of(phase: str, tool: str | None, label: str) -> bool:
|
|
|
if phase != "act":
|
|
|
return False
|
|
|
if tool in EXTERNAL_TOOLS:
|
|
|
# task_create/calendar_write 는 내부 쓰기지만 '타인에게'가 붙으면 high
|
|
|
if tool == "form_fill":
|
|
|
return True
|
|
|
return any(k in label for k in EXTERNAL_KEYWORDS)
|
|
|
return any(k in label for k in EXTERNAL_KEYWORDS)
|
|
|
|
|
|
|
|
|
# 재시도/예산 가드 — 무한 루프·과도한 tool 호출 방지
|
|
|
MAX_STEPS = 12
|
|
|
MAX_RETRIES = 2
|
|
|
MAX_TOOL_CALLS = 8
|
|
|
|
|
|
class Budget:
|
|
|
def __init__(self):
|
|
|
self.steps = 0; self.tool_calls = 0
|
|
|
def step(self): self.steps += 1; assert self.steps <= MAX_STEPS, "step budget exceeded"
|
|
|
def tool(self): self.tool_calls += 1; assert self.tool_calls <= MAX_TOOL_CALLS, "tool budget exceeded"
|
|
|
```
|
|
|
|
|
|
> 가드레일은 두 가지를 강제한다: **(1) 외부 영향 → 승인 게이트**(external_effect), **(2) 자원 예산**(MAX_STEPS/RETRIES/TOOL_CALLS) — tool-capable 동적 플랜이 폭주하지 않도록. scripted 폴백은 부작용이 없으므로 예산 안에서 결정적으로 끝난다.
|
|
|
|
|
|
### 3.6 능동 감지 잡 — `backend/app/worker/jobs/proactive.py`
|
|
|
|
|
|
작업·일정·금융·메일을 훑어 `ProactiveCard`를 만든다. 입력은 mock(phase 7~12 시드) 또는 real(phase-13 동기화 데이터) — **같은 테이블을 읽으므로 코드 무변경**.
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/jobs/proactive.py
|
|
|
from __future__ import annotations
|
|
|
from sqlmodel import Session, select
|
|
|
from ..._json import dumps # 표준 json.dumps wrapper(한글 ensure_ascii=False)
|
|
|
from ...models import (ProactiveCard, ProactiveKind, Event, Finance, FinanceTx, now)
|
|
|
from ...automation.event_bus import bus
|
|
|
|
|
|
def run_proactive(session: Session) -> list[ProactiveCard]:
|
|
|
cards: list[ProactiveCard] = []
|
|
|
cards += _detect_meeting_streak(session)
|
|
|
cards += _detect_pricey_bill(session)
|
|
|
cards += _detect_deep_work_gap(session)
|
|
|
for c in cards:
|
|
|
session.add(c)
|
|
|
bus.publish("proactive.detected", {"id": c.id, "kind": c.kind})
|
|
|
session.commit()
|
|
|
return cards
|
|
|
|
|
|
def _detect_meeting_streak(s: Session):
|
|
|
"""내일 회의가 3연속이면 점심 블록 보호 카드. (data.js schedule + cal-data.js event)"""
|
|
|
tomorrow_events = s.exec(select(Event).where(Event.day == _tomorrow())).all()
|
|
|
if _max_consecutive(tomorrow_events) >= 3:
|
|
|
return [ProactiveCard(
|
|
|
id=_pid(s), kind=ProactiveKind.schedule, icon="cal", tone="violet",
|
|
|
title="내일 미팅 3연속 — 점심 비워뒀어요",
|
|
|
why="10:00·11:00·13:00 연속, 12시 공백을 보호 블록으로 잡았어요",
|
|
|
cta="그대로 둘게요", action_kind="focus_block",
|
|
|
action_payload=dumps({"day": _tomorrow(), "start": "12:00", "end": "13:00",
|
|
|
"type": "light", "title": "점심·재충전 보호"}),
|
|
|
detected_at="방금")]
|
|
|
return []
|
|
|
|
|
|
def _detect_pricey_bill(s: Session):
|
|
|
"""구독/청구 금액이 평소보다 높으면 알림. (data.js finance.subs + life finance_tx)"""
|
|
|
out = []
|
|
|
for sub in s.exec(select(Finance)).all(): # 라이프 finance 카테고리/구독
|
|
|
anomaly = _bill_anomaly(s, sub) # 직전 N개월 평균 대비
|
|
|
if anomaly and anomaly["delta_won"] >= 20000:
|
|
|
out.append(ProactiveCard(
|
|
|
id=_pid(s), kind=ProactiveKind.finance, icon="wallet", tone="amber",
|
|
|
title=f"{anomaly['name']} 청구서가 평소보다 {anomaly['delta_label']} 비싸요",
|
|
|
why=f"평소 {anomaly['avg_label']} → 이번 {anomaly['amt_label']} · 사유 확인 권장",
|
|
|
cta="자세히", action_kind="task",
|
|
|
action_payload=dumps({"title": f"{anomaly['name']} 청구 사유 확인",
|
|
|
"project_id": "life-finance"}),
|
|
|
detected_at="방금"))
|
|
|
return out
|
|
|
|
|
|
def _detect_deep_work_gap(s: Session):
|
|
|
"""이번 주 딥워크가 목표 미만이면 보호 제안(주간 리뷰와 연동)."""
|
|
|
...
|
|
|
```
|
|
|
|
|
|
> **능동 카드 생성 규칙(결정성).** 감지 규칙은 임계값 기반(연속 3회 이상, 금액 델타 ≥ 20,000원)이라 같은 시드에서 항상 같은 카드를 만든다. real 데이터(phase-13)가 들어오면 자연히 실제 상황을 감지한다. `action_payload`는 JSON(text) — 사용자가 cta를 누르면 `/api/proactive/{id}/accept`가 `action_kind`에 따라 focus_block/task/automation/approval로 실체화한다.
|
|
|
|
|
|
### 3.7 능동 다이제스트 — `backend/app/worker/jobs/digest.py`
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/jobs/digest.py
|
|
|
from ...automation.event_bus import bus
|
|
|
from ...models import now
|
|
|
|
|
|
DIGEST_TIMES = ["09:00", "13:00", "18:30"] # post-mvp-overview §11 / notify-data digests
|
|
|
|
|
|
def run_digest(session, slot: str = "09:00"):
|
|
|
"""예약 시각 다이제스트/브리핑 생성 → 알림 벨 + 능동 브리핑.
|
|
|
18:30은 하루 마감(phase-12) 톤, 09:00은 아침 브리핑(data.js briefingNote 톤)."""
|
|
|
digest = _build_digest(session, slot) # 작업/일정/결재 요약(집계 — 새 데이터 아님)
|
|
|
session.add(digest); session.commit()
|
|
|
bus.publish("digest.generated", {"digest_id": digest.id, "time": slot,
|
|
|
"count": digest.count})
|
|
|
return digest
|
|
|
```
|
|
|
|
|
|
> 다이제스트는 **새 사실을 만들지 않고 집계만** 한다(알림 phase-9 `digest`, 하루 마감 phase-12와 같은 데이터). `digest.generated` 이벤트를 알림(벨)과 능동 브리핑이 구독한다.
|
|
|
|
|
|
### 3.8 주간 리뷰 / 패턴 코칭 — `backend/app/worker/jobs/weekly_review.py`
|
|
|
|
|
|
`하루 마감`(phase-12)의 일 집계를 **7일치 합산**해 패턴을 찾고, 수락 시 자동화/집중 블록으로 실체화한다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/jobs/weekly_review.py
|
|
|
from __future__ import annotations
|
|
|
from sqlmodel import Session
|
|
|
from ..._json import dumps
|
|
|
from ...models import WeeklyReview, now
|
|
|
from ...automation.event_bus import bus
|
|
|
|
|
|
def run_weekly_review(session: Session, *, week_label: str = "6/7~6/13") -> WeeklyReview:
|
|
|
agg = _aggregate_week(session, week_label) # /api/wrap 일집계 7회 합산(phase-12)
|
|
|
patterns = _find_patterns(agg) # 규칙 기반 패턴 → 제안
|
|
|
wr = WeeklyReview(
|
|
|
id=f"wr-{_week_id(week_label)}", week_label=week_label,
|
|
|
deep_work_h=agg["deep_work_h"], meeting_h=agg["meeting_h"],
|
|
|
done_count=agg["done"], auto_count=agg["auto"], saved_minutes=agg["saved_min"],
|
|
|
patterns_json=dumps(patterns),
|
|
|
note=_coach_note(agg, patterns))
|
|
|
session.add(wr); session.commit()
|
|
|
bus.publish("weekly.reviewed", {"id": wr.id, "patterns": len(patterns)})
|
|
|
return wr
|
|
|
|
|
|
def _find_patterns(agg) -> list[dict]:
|
|
|
out = []
|
|
|
# 예: 회의 시간이 딥워크보다 크게 많으면 보호 제안
|
|
|
if agg["meeting_h"] >= agg["deep_work_h"] * 1.4:
|
|
|
out.append({
|
|
|
"title": f"이번 주 딥워크 {agg['deep_work_h']:.0f}h, 회의 {agg['meeting_h']:.0f}h",
|
|
|
"suggest": "화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?",
|
|
|
"action_kind": "automation", # 자동화 규칙으로 실체화
|
|
|
"payload": {"name": "화요일 오전 딥워크 보호", "cat": "focus",
|
|
|
"trigger": "매주 화요일 08:00", "cond": None,
|
|
|
"action": "09:00–11:00 방해 금지 블록 예약"},
|
|
|
})
|
|
|
return out
|
|
|
|
|
|
def _coach_note(agg, patterns) -> str:
|
|
|
if patterns:
|
|
|
return f"이번 주 딥워크 {agg['deep_work_h']:.0f}h, 회의 {agg['meeting_h']:.0f}h — {patterns[0]['suggest']}"
|
|
|
return "이번 주는 회의와 딥워크 균형이 좋았어요."
|
|
|
```
|
|
|
|
|
|
> 주간 리뷰 수치는 `wrap-data.js` 톤을 따른다(`{n:"2.5", unit:"시간", label:"딥 워크"}`의 주간 합). 패턴 제안 수락 시 `action_kind`가 `automation`이면 phase-7 `automation_rule`로, `focus_block`이면 phase-8 블록으로 실체화한다 — *제안→자동화/일정* 연합.
|
|
|
|
|
|
### 3.9 worker 잡 등록 & 수동 트리거 — `backend/app/worker/`
|
|
|
|
|
|
phase-7이 소유한 `worker/main.py`의 `register(scheduler)`에 본 phase 잡을 추가한다(phase-13 `sync_jobs.register`와 동일 패턴). `WORKER_ENABLED=false`(기본, A4)면 스케줄러 미가동 — 대신 **수동 트리거 엔드포인트**로 데모/CI 결정성을 확보한다.
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/triggers.py — 수동 트리거 매핑(라우터에서 호출)
|
|
|
from sqlmodel import Session
|
|
|
from .jobs.proactive import run_proactive
|
|
|
from .jobs.digest import run_digest
|
|
|
from .jobs.weekly_review import run_weekly_review
|
|
|
from .jobs.errand_runner import run_errand_queue
|
|
|
|
|
|
JOBS = {
|
|
|
"proactive": lambda s, p: run_proactive(s),
|
|
|
"digest": lambda s, p: run_digest(s, slot=p.get("slot", "09:00")),
|
|
|
"weekly_review": lambda s, p: run_weekly_review(s, week_label=p.get("week", "6/7~6/13")),
|
|
|
"errand_runner": lambda s, p: run_errand_queue(s),
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/main.py (phase-7 소유 — 본 phase 가 잡 추가)
|
|
|
def register(scheduler):
|
|
|
from .jobs.proactive import run_proactive
|
|
|
from .jobs.digest import run_digest
|
|
|
from .jobs.weekly_review import run_weekly_review
|
|
|
from ..db import engine
|
|
|
from sqlmodel import Session
|
|
|
def _wrap(fn, **kw):
|
|
|
def _job():
|
|
|
with Session(engine) as s: fn(s, **kw)
|
|
|
return _job
|
|
|
scheduler.add_job(_wrap(run_proactive), "interval", minutes=30, id="proactive", replace_existing=True)
|
|
|
for t in ["09:00", "13:00", "18:30"]:
|
|
|
h, m = t.split(":")
|
|
|
scheduler.add_job(_wrap(run_digest, slot=t), "cron", hour=int(h), minute=int(m),
|
|
|
id=f"digest-{t}", replace_existing=True)
|
|
|
scheduler.add_job(_wrap(run_weekly_review), "cron", day_of_week="sun", hour=20,
|
|
|
id="weekly_review", replace_existing=True)
|
|
|
# phase-13 sync_jobs.register(scheduler) 도 같은 진입에서 호출됨
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/worker/jobs/errand_runner.py — 승인된 심부름 이어 실행
|
|
|
from sqlmodel import Session, select
|
|
|
from ...models import ErrandTask, ErrandStatus, Approval
|
|
|
from ...agents.errand import ErrandAgent
|
|
|
|
|
|
def run_errand_queue(session: Session):
|
|
|
"""approval.executed 후 awaiting_approval 심부름을 이어 실행.
|
|
|
(이벤트 구독 핸들러가 직접 호출하거나, 폴링 잡으로 호출)"""
|
|
|
ers = session.exec(select(ErrandTask).where(
|
|
|
ErrandTask.status == ErrandStatus.awaiting_approval)).all()
|
|
|
for er in ers:
|
|
|
ap = session.get(Approval, er.approval_id) if er.approval_id else None
|
|
|
if ap and ap.status in ("approved", "executed"):
|
|
|
ErrandAgent(session).resume_after_approval(er)
|
|
|
```
|
|
|
|
|
|
### 3.10 라우터 — `routers/agents.py` · `routers/worker.py`
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/agents.py (내부 prefix 없음)
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
from sqlmodel import Session, select
|
|
|
from ..db import get_session
|
|
|
from ..agents.errand import ErrandAgent
|
|
|
from ..models import ErrandTask, ErrandStep
|
|
|
from ..schemas import ErrandStartIn, ErrandTaskOut
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post("/agents/errand", response_model=ErrandTaskOut)
|
|
|
def start_errand(body: ErrandStartIn, s: Session = Depends(get_session)):
|
|
|
er = ErrandAgent(s).start(kind=body.kind, title=body.title, goal=body.goal,
|
|
|
target=body.target, tone=body.tone or "amber")
|
|
|
return _errand_out(s, er)
|
|
|
|
|
|
@router.get("/agents/errand", response_model=list[ErrandTaskOut])
|
|
|
def list_errands(s: Session = Depends(get_session)):
|
|
|
return [_errand_out(s, e) for e in s.exec(select(ErrandTask)).all()]
|
|
|
|
|
|
@router.get("/agents/errand/{eid}", response_model=ErrandTaskOut)
|
|
|
def get_errand(eid: str, s: Session = Depends(get_session)):
|
|
|
er = s.get(ErrandTask, eid)
|
|
|
if not er: raise HTTPException(404)
|
|
|
return _errand_out(s, er) # steps 포함 → 타임라인
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/worker.py (내부 prefix 없음)
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
from sqlmodel import Session, select
|
|
|
from ..db import get_session
|
|
|
from ..worker.triggers import JOBS
|
|
|
from ..models import ProactiveCard
|
|
|
from ..schemas import WorkerRunOut, ProactiveCardOut, ProactiveAcceptOut
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post("/worker/run/{job}", response_model=WorkerRunOut)
|
|
|
def run_job(job: str, payload: dict | None = None, s: Session = Depends(get_session)):
|
|
|
if job not in JOBS: raise HTTPException(404, f"unknown job: {job}")
|
|
|
result = JOBS[job](s, payload or {})
|
|
|
return WorkerRunOut(job=job, ok=True, detail=str(result))
|
|
|
|
|
|
@router.get("/proactive", response_model=list[ProactiveCardOut])
|
|
|
def list_proactive(s: Session = Depends(get_session)):
|
|
|
return [_pc_out(c) for c in s.exec(
|
|
|
select(ProactiveCard).where(ProactiveCard.status == "active")).all()]
|
|
|
|
|
|
@router.post("/proactive/{pid}/accept", response_model=ProactiveAcceptOut)
|
|
|
def accept_proactive(pid: str, s: Session = Depends(get_session)):
|
|
|
c = s.get(ProactiveCard, pid)
|
|
|
if not c: raise HTTPException(404)
|
|
|
out = _materialize(s, c) # action_kind → focus_block/task/automation/approval
|
|
|
c.status = "accepted"; s.add(c); s.commit()
|
|
|
return out
|
|
|
|
|
|
@router.post("/proactive/{pid}/dismiss")
|
|
|
def dismiss_proactive(pid: str, s: Session = Depends(get_session)):
|
|
|
c = s.get(ProactiveCard, pid)
|
|
|
if not c: raise HTTPException(404)
|
|
|
c.status = "dismissed"; s.add(c); s.commit()
|
|
|
return {"ok": True}
|
|
|
```
|
|
|
|
|
|
> `main.py` 등록: `app.include_router(agents.router, prefix="/api", tags=["agents"])`, `app.include_router(worker.router, prefix="/api", tags=["worker"])`. inbox 라우터는 이미 phase-4에서 등록됨(transcribe/caption은 같은 라우터에 추가).
|
|
|
|
|
|
### 3.11 프런트 — 심부름 타임라인 `components/errand/ErrandTimeline.tsx`
|
|
|
|
|
|
```tsx
|
|
|
"use client";
|
|
|
import Icon from "@/components/Icon";
|
|
|
import type { ErrandTask, ErrandStep } from "@/lib/types";
|
|
|
|
|
|
const PHASE_ICON: Record<string, string> = {
|
|
|
plan: "brain", act: "zap", observe: "clock", reflect: "spark",
|
|
|
};
|
|
|
const STATE_LABEL: Record<string, string> = {
|
|
|
pending: "대기", running: "진행 중", done: "완료", blocked: "승인 대기", error: "오류",
|
|
|
};
|
|
|
|
|
|
export default function ErrandTimeline({ er }: { er: ErrandTask }) {
|
|
|
return (
|
|
|
<section className="card errand">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><Icon name="wallet" /></div>
|
|
|
<div className="htext">
|
|
|
<h3>{er.title}</h3>
|
|
|
<div className="sub">{er.goal}</div>
|
|
|
</div>
|
|
|
<span className={"er-badge " + er.status}>{statusLabel(er.status)}</span>
|
|
|
</div>
|
|
|
|
|
|
<ol className="er-steps">
|
|
|
{er.steps.map((st) => (
|
|
|
<li key={st.id} className={"er-step " + st.state +
|
|
|
(st.external_effect ? " external" : "")}>
|
|
|
<span className="er-dot"><Icon name={PHASE_ICON[st.phase] ?? "spark"} /></span>
|
|
|
<div className="er-line">
|
|
|
<span className="er-label">{st.label}</span>
|
|
|
{st.detail && <span className="er-detail">{st.detail}</span>}
|
|
|
</div>
|
|
|
<span className={"er-state " + st.state}>{STATE_LABEL[st.state]}</span>
|
|
|
{st.external_effect && st.state === "blocked" && (
|
|
|
<span className="er-gate"><Icon name="lock" />결재함에서 승인 필요</span>
|
|
|
)}
|
|
|
</li>
|
|
|
))}
|
|
|
</ol>
|
|
|
|
|
|
{er.status === "awaiting_approval" && (
|
|
|
<a className="auton-note" href="/approvals">
|
|
|
<Icon name="arrow" />외부 행동(결제/해지)은 결재함에서 한 번 확인하면 이어서 처리할게요.
|
|
|
</a>
|
|
|
)}
|
|
|
{er.status === "done" && er.result_summary && (
|
|
|
<div className="er-result"><Icon name="tick" w={3} />{er.result_summary}</div>
|
|
|
)}
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
스타일(토큰만 사용 — phase-1/4 상속):
|
|
|
|
|
|
```css
|
|
|
.er-steps { list-style: none; margin: 12px 0 0; padding: 0; }
|
|
|
.er-step { display: grid; grid-template-columns: 28px 1fr auto; gap: 10px;
|
|
|
align-items: start; padding: 10px 0; border-top: 1px solid var(--glass-brd); }
|
|
|
.er-step.external { background: color-mix(in oklab, var(--amber) 7%, transparent); }
|
|
|
.er-dot { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center;
|
|
|
background: color-mix(in oklab, var(--violet) 14%, transparent); color: var(--violet); }
|
|
|
.er-step.done .er-dot { background: color-mix(in oklab, var(--green) 14%, transparent); color: var(--green); }
|
|
|
.er-step.blocked .er-dot { background: color-mix(in oklab, var(--amber) 16%, transparent); color: var(--amber); }
|
|
|
.er-label { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
|
|
|
.er-detail { display: block; font-size: 11.5px; color: var(--muted); max-width: 62ch; }
|
|
|
.er-gate { grid-column: 2 / 4; font-size: 11px; font-weight: 700; color: var(--amber); }
|
|
|
.er-result { margin-top: 12px; font-size: 12.5px; color: var(--green); display: flex; gap: 6px; }
|
|
|
```
|
|
|
|
|
|
### 3.12 프런트 — 능동 배너 `components/proactive/ProactiveBanner.tsx`
|
|
|
|
|
|
대시보드 상단(`/dashboard`)과 알림 벨에서 재사용. `aria-live="polite"`로 능동 발화를 스크린리더에 알린다.
|
|
|
|
|
|
```tsx
|
|
|
"use client";
|
|
|
import Icon from "@/components/Icon";
|
|
|
import { acceptProactive, dismissProactive } from "@/lib/api";
|
|
|
import type { ProactiveCard } from "@/lib/types";
|
|
|
|
|
|
export default function ProactiveBanner({ cards }: { cards: ProactiveCard[] }) {
|
|
|
if (!cards.length) return null;
|
|
|
return (
|
|
|
<div className="proactive" role="region" aria-live="polite" aria-label="아리의 선제 제안">
|
|
|
{cards.map((c) => (
|
|
|
<div key={c.id} className="pc" style={{ ["--tone" as any]: `var(--${c.tone})` }}>
|
|
|
<span className="pc-ic"><Icon name={c.icon} /></span>
|
|
|
<div className="pc-body">
|
|
|
<div className="pc-title">{c.title}</div>
|
|
|
<div className="pc-why">{c.why}</div>
|
|
|
</div>
|
|
|
<div className="pc-acts">
|
|
|
{c.cta && (
|
|
|
<button className="pc-ok" onClick={() => acceptProactive(c.id)}>{c.cta}</button>
|
|
|
)}
|
|
|
<button className="pc-x" aria-label="이 제안 닫기"
|
|
|
onClick={() => dismissProactive(c.id)}><Icon name="x" /></button>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```css
|
|
|
.proactive { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
|
|
|
.pc { display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
|
|
|
padding: 12px 14px; border-radius: 15px; background: var(--glass-2);
|
|
|
border: 1px solid color-mix(in oklab, var(--tone) 30%, var(--glass-brd)); }
|
|
|
.pc-ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center;
|
|
|
background: color-mix(in oklab, var(--tone) 14%, transparent); color: var(--tone); }
|
|
|
.pc-title { font-size: 13.5px; font-weight: 700; letter-spacing: -0.01em; }
|
|
|
.pc-why { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
|
|
.pc-ok { font-weight: 700; color: var(--lime-ink); background: var(--lime);
|
|
|
border-radius: 999px; padding: 6px 13px; }
|
|
|
.pc-x { width: 28px; height: 28px; border-radius: 8px; color: var(--muted); }
|
|
|
```
|
|
|
|
|
|
### 3.13 프런트 — 멀티모달 컴포저 확장 `components/inbox/CaptureComposer.tsx`
|
|
|
|
|
|
phase-4의 mic/image **스텁**을 실제 캡처로 교체한다. 미디어 API 미지원/거부 시 **phase-4 더미 폴백 유지**(graceful degrade).
|
|
|
|
|
|
```tsx
|
|
|
"use client";
|
|
|
import { useState, useRef } from "react";
|
|
|
import Icon from "@/components/Icon";
|
|
|
import { transcribe, caption } from "@/lib/api";
|
|
|
|
|
|
const VOICE_FALLBACK = "음성 메모 — (텍스트로 적어주세요)"; // STT 미가용 시 phase-4 톤
|
|
|
const IMAGE_FALLBACK = "이미지 캡처 — (자동 인식 결과 없음)";
|
|
|
|
|
|
export default function CaptureComposer({
|
|
|
onSubmit,
|
|
|
}: { onSubmit: (raw: string, kind: "text" | "voice" | "image") => void }) {
|
|
|
const [input, setInput] = useState("");
|
|
|
const [recording, setRecording] = useState(false);
|
|
|
const recRef = useRef<MediaRecorder | null>(null);
|
|
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
|
|
const submitText = () => {
|
|
|
const t = input.trim(); if (!t) return;
|
|
|
onSubmit(t, "text"); setInput("");
|
|
|
};
|
|
|
|
|
|
/* 음성: 실제 녹음 → /inbox/transcribe → raw 텍스트로 capture */
|
|
|
const toggleMic = async () => {
|
|
|
if (recording) { recRef.current?.stop(); return; }
|
|
|
if (!navigator.mediaDevices?.getUserMedia) { onSubmit(VOICE_FALLBACK, "voice"); return; }
|
|
|
try {
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
const rec = new MediaRecorder(stream); const chunks: Blob[] = [];
|
|
|
rec.ondataavailable = (e) => chunks.push(e.data);
|
|
|
rec.onstop = async () => {
|
|
|
stream.getTracks().forEach((t) => t.stop()); setRecording(false);
|
|
|
const blob = new Blob(chunks, { type: "audio/webm" });
|
|
|
try {
|
|
|
const { text } = await transcribe(blob);
|
|
|
onSubmit(text || VOICE_FALLBACK, "voice");
|
|
|
} catch { onSubmit(VOICE_FALLBACK, "voice"); }
|
|
|
};
|
|
|
recRef.current = rec; rec.start(); setRecording(true);
|
|
|
} catch { onSubmit(VOICE_FALLBACK, "voice"); } // 권한 거부 → 폴백
|
|
|
};
|
|
|
|
|
|
/* 이미지: 파일 선택 → /inbox/caption → raw 캡션으로 capture */
|
|
|
const onImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
const f = e.target.files?.[0]; if (!f) return;
|
|
|
try { const { text } = await caption(f); onSubmit(text || IMAGE_FALLBACK, "image"); }
|
|
|
catch { onSubmit(IMAGE_FALLBACK, "image"); }
|
|
|
finally { e.target.value = ""; }
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<div className="sb-cmd">
|
|
|
<Icon name="spark" />
|
|
|
<input value={input} onChange={(e) => setInput(e.target.value)}
|
|
|
onKeyDown={(e) => e.key === "Enter" && submitText()}
|
|
|
placeholder="갑자기 생각난 것 아무거나…" aria-label="인박스에 빠르게 캡처" />
|
|
|
<button className={"sb-mode" + (recording ? " rec" : "")} onClick={toggleMic}
|
|
|
aria-label={recording ? "녹음 중지" : "음성으로 캡처"} aria-pressed={recording}>
|
|
|
<Icon name="mic" />
|
|
|
</button>
|
|
|
<button className="sb-mode" onClick={() => fileRef.current?.click()}
|
|
|
aria-label="이미지로 캡처"><Icon name="image" /></button>
|
|
|
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onImage} />
|
|
|
<button className="sb-send" onClick={submitText} aria-label="보내기"><Icon name="arrow" /></button>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **graceful degrade(A2/§12).** `getUserMedia` 미지원·권한 거부·STT/Vision 미가용 → phase-4 톤의 폴백 문구로 capture 계속. 백엔드도 `HeuristicSTT`/`HeuristicVision`이 항상 응답하므로 분류 파이프라인은 끊기지 않는다. 녹음 중 상태(`.sb-mode.rec`)는 coral 펄스로 표시(phase-4 `sb-think` 펄스 토큰 재사용).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 데이터/타입/API 계약
|
|
|
|
|
|
### 4.1 신규 엔드포인트 (prefix `/api`)
|
|
|
|
|
|
| 메서드 | 경로 | 요청 | 응답 | 용도 |
|
|
|
|---|---|---|---|---|
|
|
|
| POST | `/api/agents/errand` | `ErrandStartIn` | `ErrandTaskOut` | 심부름 시작(plan→게이트까지) |
|
|
|
| GET | `/api/agents/errand` | — | `ErrandTaskOut[]` | 심부름 목록 |
|
|
|
| GET | `/api/agents/errand/{eid}` | — | `ErrandTaskOut`(steps 포함) | 타임라인 조회 |
|
|
|
| POST | `/api/worker/run/{job}` | `{slot?, week?}` | `WorkerRunOut` | 수동 트리거(proactive/digest/weekly_review/errand_runner) |
|
|
|
| GET | `/api/proactive` | — | `ProactiveCardOut[]` | 능동 카드(active) |
|
|
|
| POST | `/api/proactive/{pid}/accept` | — | `ProactiveAcceptOut` | 카드 수락→실체화 |
|
|
|
| POST | `/api/proactive/{pid}/dismiss` | — | `{ok:true}` | 카드 닫기 |
|
|
|
| GET | `/api/weekly-review?week=` | — | `WeeklyReviewOut` | 주간 리뷰 조회 |
|
|
|
| POST | `/api/inbox/transcribe` (multipart) | `audio`,`hint?` | `TranscribeOut` | 음성→텍스트(STT) |
|
|
|
| POST | `/api/inbox/caption` (multipart) | `image`,`hint?` | `CaptionOut` | 이미지→캡션/OCR(Vision) |
|
|
|
|
|
|
> 결재함 승인/되돌리기는 **phase-7 기존 엔드포인트 재사용**: `POST /api/approvals/{id}/approve`, `POST /api/approvals/{id}/undo`. 심부름 게이트는 새 승인 엔드포인트를 만들지 않는다 — `source:"agent"` Approval로 결재함에 자연히 합류한다.
|
|
|
|
|
|
### 4.2 Pydantic 스키마 (`schemas.py` 추가, `types.ts`와 1:1)
|
|
|
|
|
|
```python
|
|
|
# backend/app/schemas.py (추가)
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class ErrandStartIn(BaseModel):
|
|
|
kind: str # booking|refund|cancel|support
|
|
|
title: str
|
|
|
goal: str
|
|
|
target: str | None = None
|
|
|
tone: str | None = None
|
|
|
|
|
|
class ErrandStepOut(BaseModel):
|
|
|
id: str; seq: int; phase: str; tool: str | None
|
|
|
label: str; detail: str | None
|
|
|
external_effect: bool; state: str
|
|
|
|
|
|
class ErrandTaskOut(BaseModel):
|
|
|
id: str; kind: str; title: str; goal: str; target: str | None
|
|
|
tone: str; status: str; approval_id: str | None
|
|
|
result_summary: str | None; model: str
|
|
|
steps: list[ErrandStepOut]
|
|
|
|
|
|
class ProactiveCardOut(BaseModel):
|
|
|
id: str; kind: str; icon: str; tone: str
|
|
|
title: str; why: str
|
|
|
cta: str | None; action_kind: str | None
|
|
|
detected_at: str; status: str
|
|
|
|
|
|
class ProactiveAcceptOut(BaseModel):
|
|
|
id: str; action_kind: str
|
|
|
created_id: str | None = None # 실체화된 task/focus_block/automation/approval id
|
|
|
message: str
|
|
|
|
|
|
class WeeklyReviewOut(BaseModel):
|
|
|
id: str; week_label: str
|
|
|
deep_work_h: float; meeting_h: float
|
|
|
done_count: int; auto_count: int; saved_minutes: int
|
|
|
patterns: list[dict] # [{title, suggest, action_kind, payload}]
|
|
|
note: str | None
|
|
|
|
|
|
class WorkerRunOut(BaseModel):
|
|
|
job: str; ok: bool; detail: str
|
|
|
|
|
|
class TranscribeOut(BaseModel):
|
|
|
text: str; seconds: float; model: str; confidence: float
|
|
|
|
|
|
class CaptionOut(BaseModel):
|
|
|
text: str; ocr: str; model: str; confidence: float
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
// frontend/lib/types.ts (추가)
|
|
|
export type ErrandKind = "booking" | "refund" | "cancel" | "support";
|
|
|
export type ErrandStatus =
|
|
|
"planning" | "running" | "awaiting_approval" | "done" | "failed" | "cancelled";
|
|
|
export type StepState = "pending" | "running" | "done" | "blocked" | "error";
|
|
|
|
|
|
export interface ErrandStep {
|
|
|
id: string; seq: number; phase: "plan"|"act"|"observe"|"reflect";
|
|
|
tool: string | null; label: string; detail: string | null;
|
|
|
external_effect: boolean; state: StepState;
|
|
|
}
|
|
|
export interface ErrandTask {
|
|
|
id: string; kind: ErrandKind; title: string; goal: string; target: string | null;
|
|
|
tone: Tone; status: ErrandStatus; approval_id: string | null;
|
|
|
result_summary: string | null; model: string; steps: ErrandStep[];
|
|
|
}
|
|
|
export interface ProactiveCard {
|
|
|
id: string; kind: "schedule"|"finance"|"focus"|"health"|"digest";
|
|
|
icon: string; tone: Tone; title: string; why: string;
|
|
|
cta: string | null; action_kind: "focus_block"|"task"|"automation"|"approval"|"none"|null;
|
|
|
detected_at: string; status: "active"|"accepted"|"dismissed";
|
|
|
}
|
|
|
export interface WeeklyReview {
|
|
|
id: string; week_label: string;
|
|
|
deep_work_h: number; meeting_h: number;
|
|
|
done_count: number; auto_count: number; saved_minutes: number;
|
|
|
patterns: { title: string; suggest: string; action_kind: string; payload: any }[];
|
|
|
note: string | null;
|
|
|
}
|
|
|
export interface TranscribeResult { text: string; seconds: number; model: string; confidence: number; }
|
|
|
export interface CaptionResult { text: string; ocr: string; model: string; confidence: number; }
|
|
|
```
|
|
|
|
|
|
### 4.3 요청/응답 예시 (JSON)
|
|
|
|
|
|
**심부름 시작 — a6 "Netflix 일시정지" 확장**
|
|
|
|
|
|
요청:
|
|
|
```http
|
|
|
POST /api/agents/errand
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{ "kind": "cancel", "title": "Netflix 일시정지", "target": "Netflix",
|
|
|
"goal": "모레 17,000원 결제 전 일시정지, 되돌리기 가능하게", "tone": "amber" }
|
|
|
```
|
|
|
응답(게이트에서 멈춤 — external_effect step이 blocked):
|
|
|
```json
|
|
|
{
|
|
|
"id": "er1", "kind": "cancel", "title": "Netflix 일시정지", "target": "Netflix",
|
|
|
"goal": "모레 17,000원 결제 전 일시정지, 되돌리기 가능하게",
|
|
|
"tone": "amber", "status": "awaiting_approval", "approval_id": "ap-er1",
|
|
|
"result_summary": null, "model": "scripted",
|
|
|
"steps": [
|
|
|
{ "id":"er1-s0","seq":0,"phase":"plan","tool":null,"label":"해지 절차 계획 수립",
|
|
|
"detail":"결제일·해지 경로·되돌리기 옵션 확인","external_effect":false,"state":"done" },
|
|
|
{ "id":"er1-s1","seq":1,"phase":"act","tool":"web_search","label":"Netflix 해지/일시정지 방법 확인",
|
|
|
"detail":"계정 설정 > 멤버십 일시정지 경로 확인","external_effect":false,"state":"done" },
|
|
|
{ "id":"er1-s2","seq":2,"phase":"observe","tool":null,"label":"최근 시청 기록 확인",
|
|
|
"detail":"최근 2개월 시청 0건 — 일시정지 적합","external_effect":false,"state":"done" },
|
|
|
{ "id":"er1-s3","seq":3,"phase":"act","tool":"http_fetch","label":"해지 페이지 접속·세션 준비",
|
|
|
"detail":"로그인 세션 확보, 일시정지 폼 로드","external_effect":false,"state":"done" },
|
|
|
{ "id":"er1-s4","seq":4,"phase":"reflect","tool":null,"label":"결제 임박 확인",
|
|
|
"detail":"모레 17,000원 결제 예정 — 그 전에 처리 필요","external_effect":false,"state":"done" },
|
|
|
{ "id":"er1-s5","seq":5,"phase":"act","tool":"form_fill","label":"일시정지 신청 제출",
|
|
|
"detail":"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)","external_effect":true,"state":"blocked" }
|
|
|
]
|
|
|
}
|
|
|
```
|
|
|
→ 동시에 phase-7 `Approval` 생성(결재함에 등장):
|
|
|
```json
|
|
|
{ "id":"ap-er1","icon":"wallet","tone":"amber","risk":"high","status":"pending",
|
|
|
"source":"agent","time":"확인 필요","title":"Netflix 일시정지을(를) 추천해요",
|
|
|
"detail":"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)","cta":"일시정지","alt":"유지" }
|
|
|
```
|
|
|
→ 사용자가 결재함에서 `POST /api/approvals/ap-er1/approve` → `approval.executed` 발행 → worker `errand_runner`가 `resume_after_approval` → s5 실행 → `status:"done"`, `result_summary:"모레 결제 전 일시정지 완료(되돌리기 가능)"`.
|
|
|
|
|
|
**능동 카드 — 미팅 3연속**
|
|
|
|
|
|
`POST /api/worker/run/proactive` 후 `GET /api/proactive`:
|
|
|
```json
|
|
|
[
|
|
|
{ "id":"pc1","kind":"schedule","icon":"cal","tone":"violet",
|
|
|
"title":"내일 미팅 3연속 — 점심 비워뒀어요",
|
|
|
"why":"10:00·11:00·13:00 연속, 12시 공백을 보호 블록으로 잡았어요",
|
|
|
"cta":"그대로 둘게요","action_kind":"focus_block","detected_at":"방금","status":"active" },
|
|
|
{ "id":"pc2","kind":"finance","icon":"wallet","tone":"amber",
|
|
|
"title":"ChatGPT Plus 청구서가 평소보다 6천원 비싸요",
|
|
|
"why":"평소 29,000원 → 이번 35,000원 · 사유 확인 권장",
|
|
|
"cta":"자세히","action_kind":"task","detected_at":"방금","status":"active" }
|
|
|
]
|
|
|
```
|
|
|
|
|
|
**멀티모달 — 음성 transcribe**
|
|
|
|
|
|
요청: `POST /api/inbox/transcribe` (multipart: `audio`=녹음, `hint`="pool")
|
|
|
응답:
|
|
|
```json
|
|
|
{ "text": "음성 메모 0:14 — 수영장 차광막 부품 알아보기", "seconds": 0.0,
|
|
|
"model": "heuristic", "confidence": 0.4 }
|
|
|
```
|
|
|
→ 클라이언트가 `POST /api/inbox/capture {kind:"voice", raw:"음성 메모 0:14 — 수영장 차광막 부품 알아보기"}` → phase-4 분류 → `{proj:"집 관리 · 수영장", tone:"blue", status:"아이디어"}`(원본 `data.js magicInbox` 기대값).
|
|
|
|
|
|
**주간 리뷰**
|
|
|
|
|
|
`GET /api/weekly-review?week=6/7~6/13`:
|
|
|
```json
|
|
|
{ "id":"wr-2026-w24","week_label":"6/7~6/13",
|
|
|
"deep_work_h":8.0,"meeting_h":12.0,"done_count":18,"auto_count":12,"saved_minutes":196,
|
|
|
"patterns":[
|
|
|
{ "title":"이번 주 딥워크 8h, 회의 12h",
|
|
|
"suggest":"화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?",
|
|
|
"action_kind":"automation",
|
|
|
"payload":{"name":"화요일 오전 딥워크 보호","cat":"focus",
|
|
|
"trigger":"매주 화요일 08:00","action":"09:00–11:00 방해 금지 블록 예약"} }],
|
|
|
"note":"이번 주 딥워크 8h, 회의 12h — 화요일 오전을 ‘방해 금지’ 딥워크로 보호할까요?" }
|
|
|
```
|
|
|
|
|
|
### 4.4 시드 데이터 (`seed.py` — 내부 헬퍼, `run_seed` 안에서 호출)
|
|
|
|
|
|
```python
|
|
|
def _seed_errands(s: Session) -> None:
|
|
|
"""a6 Netflix 일시정지를 '진행 중 심부름'으로 시드(게이트 직전 상태)."""
|
|
|
# ErrandTask er1 + steps s0~s4 done, s5 blocked + Approval ap-er1(pending)
|
|
|
...
|
|
|
|
|
|
def _seed_proactive(s: Session) -> None:
|
|
|
"""능동 카드 2장(미팅 3연속 / 청구서 이상) — 대시보드 상단 데모."""
|
|
|
...
|
|
|
|
|
|
def _seed_weekly_review(s: Session) -> None:
|
|
|
"""6/7~6/13 주간 리뷰(딥워크 8h/회의 12h 패턴 제안)."""
|
|
|
...
|
|
|
|
|
|
# run_seed 안:
|
|
|
# _seed_errands(s); _seed_proactive(s); _seed_weekly_review(s)
|
|
|
```
|
|
|
|
|
|
| 테이블 | 시드 | 원본 근거 |
|
|
|
|---|---|---|
|
|
|
| `errand_task` | er1 "Netflix 일시정지"(cancel, amber, awaiting_approval) | `approve-data.js a6` |
|
|
|
| `errand_step` | er1-s0~s5(s5=blocked, external) | a6 detail "정지 절차는 준비해뒀어요" |
|
|
|
| `approval` | ap-er1(source:agent, risk:high, cta:"일시정지", alt:"유지") | a6 cta/alt 그대로 |
|
|
|
| `proactive_card` | pc1 미팅 3연속, pc2 청구서 이상 | `data.js schedule`/`finance.subs` |
|
|
|
| `weekly_review` | wr-2026-w24(8h/12h) | `wrap-data.js stats` 주간 합 |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 디자인 충실도 노트
|
|
|
|
|
|
### 5.1 결재함 합류 (phase-7 재사용 — 새 카드 디자인 없음)
|
|
|
|
|
|
심부름 게이트는 phase-7 결재함 카드를 **그대로** 쓴다. `a6`의 시각 값을 인용: `icon:"wallet"`, `tone:"amber"`(#e0a23c), `risk:"high"`, `time:"확인 필요"`, `cta:"일시정지"`, `alt:"유지"`. high-risk 카드는 phase-7대로 `cta`/`alt` 버튼이 보이고 `undo_label`은 없다(승인 전이므로). 승인 후 실행되면 phase-7 `undo`(되돌리기)로 전이.
|
|
|
|
|
|
### 5.2 능동 카드 색/토큰
|
|
|
|
|
|
- 미팅 보호(schedule): `--violet`(#8b6fd4), `cal` 아이콘. 데이터 `data.js schedule`.
|
|
|
- 청구서 이상(finance): `--amber`(#e0a23c), `wallet` 아이콘. 데이터 `data.js finance.subs`.
|
|
|
- 딥워크(focus): `--coral`(#df7256), `zap` 아이콘.
|
|
|
- `.pc-ok`는 phase-4 "좋아요" 라임 버튼 토큰 재사용(`--lime`/`--lime-ink`).
|
|
|
- 능동 발화는 `aria-live="polite"`(post-mvp-overview §11 접근성 — 능동 알림 라이브 영역).
|
|
|
|
|
|
### 5.3 심부름 타임라인
|
|
|
|
|
|
- phase 아이콘: plan=`brain`(violet), act=`zap`, observe=`clock`, reflect=`spark`(phase-1/4 Icon P 맵).
|
|
|
- step 상태색: done=green 14%, blocked=amber 16%, external step 행 배경 amber 7%.
|
|
|
- 한국어 가독성: `.er-detail` `max-width:62ch`, `word-break:keep-all`(phase-1 전역 토큰).
|
|
|
|
|
|
### 5.4 멀티모달 컴포저
|
|
|
|
|
|
- mic/image 버튼은 phase-4 `.sb-mode`(34×34, radius 10px, `--muted`) 그대로. 녹음 중 `.sb-mode.rec`만 추가(coral 펄스 — phase-4 `sb-pulse` 재사용).
|
|
|
- 캡처된 행의 kind 아이콘: voice→`mic`, image→`image`(phase-4 `CaptureRow` `cap-k` 분기 그대로). 멀티모달 실구현 후에도 **분류 결과 UI는 phase-4와 동일**(raw만 실제로 채워짐).
|
|
|
|
|
|
### 5.5 주간 리뷰 (하루 마감의 주간 변형)
|
|
|
|
|
|
`wrap-data.js`의 stat 카드 톤(`{n, unit, label}`, `--font-mono` 숫자)을 주간 합으로 재사용. 딥워크/회의/완료/자동처리 4 스탯 + 패턴 제안 1~2건. 수면 노트 대신 `note`(한 줄 코칭).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스
|
|
|
|
|
|
| 상태/케이스 | 트리거 | UI/동작 | 구현 |
|
|
|
|---|---|---|---|
|
|
|
| **에이전트 진행 중** | errand running | 타임샙 step 상태가 pending→running→done 순차 갱신, `aria-live` | `ErrandStep.state` 폴링/SSE |
|
|
|
| **승인 대기(게이트)** | external_effect step | step `blocked` + "결재함에서 승인 필요" + 결재함 링크 | `status=awaiting_approval` |
|
|
|
| **에이전트 미가용(폴백)** | Ollama/tool 다운 | scripted 플랜으로 결정적 진행, `model:"scripted"` 표기 | `_scripted()` 분기 |
|
|
|
| **예산 초과** | step/tool 폭주 | 가드레일 abort → `status=failed` + 사유, 부분 step 보존 | `guardrails.Budget` |
|
|
|
| **능동 카드 없음** | 감지 0건 | 배너 미표시(`if(!cards.length) return null`) | `ProactiveBanner` |
|
|
|
| **STT/Vision 미가용** | provider 폴백 | `HeuristicSTT/Vision`이 hint/더미 반환, `model:"heuristic"` | factory auto 폴백 |
|
|
|
| **마이크 권한 거부** | getUserMedia 실패 | phase-4 톤 폴백 문구로 capture 계속("텍스트로 적어주세요") | catch → VOICE_FALLBACK |
|
|
|
| **WORKER_ENABLED=false** | 기본 | 스케줄러 미가동, 수동 `POST /api/worker/run/{job}`로 동일 결과 | A4 |
|
|
|
| **승인 후 재개 실패** | resume 중 오류 | step `error` + 재시도 버튼, 이미 한 외부 행동은 되돌리기(undo) 유지 | `resume_after_approval` catch |
|
|
|
| **자율성 변경 중 진행** | full_auto여도 high | external_effect는 **항상 승인 대기**(A6 불변식) | `_raise_approval` 무조건 |
|
|
|
|
|
|
> **핵심 엣지: 자율성 레벨과 무관한 high-risk 게이트.** `full_auto`로 올려도 외부 행동(발송/결제/해지)은 자동 실행되지 않는다 — `external_effect` step은 `_raise_approval`을 무조건 호출한다. 자율성 레벨은 *low* 항목의 자동 실행 여부만 바꾼다(post-mvp-overview §10 A6, §12 자율성 레벨 변경).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 연합 이벤트 (발행/구독)
|
|
|
|
|
|
post-mvp-overview §9 매트릭스를 상속하고, 본 phase가 추가/사용하는 이벤트를 명시한다.
|
|
|
|
|
|
### 7.1 이 phase가 **발행**하는 이벤트
|
|
|
|
|
|
| 이벤트 | 발행 위치 | 페이로드 | 구독자(도착지) |
|
|
|
|---|---|---|---|
|
|
|
| `errand.updated` | `ErrandAgent._run_until_gate`/`resume_after_approval` | `{errand_id, seq, external}` | 타임라인 UI 갱신 · 관측성 로그 |
|
|
|
| `errand.completed` | `_run_until_gate` 끝 | `{errand_id}` | 하루 마감(phase-12가 구독해 집계) · 대시보드 |
|
|
|
| `approval.enqueued` | `_raise_approval`(external_effect) | `{id, risk:"high", source:"agent"}` | 결재함(phase-7) · 알림 벨 |
|
|
|
| `proactive.detected` | `worker/jobs/proactive.run_proactive` | `{id, kind}` | 대시보드 상단 `ProactiveBanner` · 알림 벨 |
|
|
|
| `digest.generated` | `worker/jobs/digest.run_digest` | `{digest_id, time, count}` | 알림(다이제스트 배달) · 능동 브리핑 |
|
|
|
| `weekly.reviewed` | `worker/jobs/weekly_review.run_weekly_review` | `{id, patterns}` | 하루 마감(주간 카드) · 자동화 제안 |
|
|
|
|
|
|
### 7.2 이 phase가 **구독**하는 이벤트
|
|
|
|
|
|
| 구독 이벤트 | 발행처 | 본 phase의 처리 |
|
|
|
|---|---|---|
|
|
|
| `approval.executed` | 결재함(phase-7) | `source:"agent"`면 `errand_runner`가 `resume_after_approval` 호출(이어 실행) |
|
|
|
| `mail.received` | 메일(phase-9)/RealConnector(phase-13) | proactive 감지 입력(중요 메일·청구 메일) |
|
|
|
| `finance.transaction` | 라이프(phase-11)/RealConnector | `_detect_pricey_bill` 입력(청구서 이상) |
|
|
|
| `calendar.updated` | 일정(phase-8) | proactive 미팅 보호/딥워크 카드 입력(연속 회의·빈 블록 감지) |
|
|
|
| `health.sample` | 라이프(phase-11)/RealConnector | 건강 코칭 능동 카드 입력 |
|
|
|
|
|
|
### 7.3 대표 연합 경로 — 심부름→결재함→되돌리기 (F6 확장)
|
|
|
|
|
|
```
|
|
|
POST /api/agents/errand {kind:"cancel", target:"Netflix"}
|
|
|
→ ErrandAgent.start → plan/act/observe/reflect (s0~s4 done)
|
|
|
→ s5(form_fill, external_effect) → guardrails.external_effect_of=True
|
|
|
→ _raise_approval: Approval(ap-er1, risk=high, source=agent, cta="일시정지")
|
|
|
→ bus.publish("approval.enqueued") ; ErrandTask.status=awaiting_approval
|
|
|
▼ 결재함(phase-7)에 a6 톤 카드 등장
|
|
|
사용자: POST /api/approvals/ap-er1/approve
|
|
|
→ phase-7: status approved→executed ; bus.publish("approval.executed", {source:"agent"})
|
|
|
▼ worker/errand_runner 구독
|
|
|
→ ErrandAgent.resume_after_approval: s5 실행(실제 일시정지) → done
|
|
|
→ ErrandTask.done, result_summary="모레 결제 전 일시정지 완료(되돌리기 가능)"
|
|
|
→ bus.publish("errand.completed")
|
|
|
▼ 하루 마감(phase-12)이 errand.completed 구독해 집계: "아리 자동 처리"에 합산, "47분 아낌" 류
|
|
|
되돌리기 필요 시: phase-7 POST /api/approvals/ap-er1/undo (undo_label)
|
|
|
```
|
|
|
|
|
|
### 7.4 대표 연합 경로 — 멀티모달→분류→작업 (F1 확장)
|
|
|
|
|
|
```
|
|
|
[음성] CaptureComposer 녹음 → POST /api/inbox/transcribe (STTProvider)
|
|
|
→ "음성 메모 0:14 — 수영장 차광막 부품 알아보기"
|
|
|
→ POST /api/inbox/capture {kind:"voice", raw:...} (phase-4 분류 — 무변경)
|
|
|
→ capture.classified {type:"idea", proj:"집 관리 · 수영장", tone:"blue"}
|
|
|
→ confirm → (idea는 보드 보관; task면 task.created → 작업 페이지 등장)
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 안전 / 신뢰 (Trust & Safety)
|
|
|
|
|
|
CONTRACT의 안전 요구를 데이터·코드 불변식으로 못 박는다.
|
|
|
|
|
|
| 원칙 | 구현 | 검증 |
|
|
|
|---|---|---|
|
|
|
| **외부 행동은 항상 승인** | `external_effect` step → `_raise_approval(risk="high")`, 자율성 레벨 무관 | `test_errand_external_effect_gated` |
|
|
|
| **모든 자동 실행은 되돌리기** | low approval은 phase-7 `undo_label`; 심부름 실행 후 `undo`로 전이 | `test_undo_after_errand` |
|
|
|
| **모든 행동은 감사 로그** | `ErrandStep`(타임라인) + phase-7 `approval_log`(time/text) + 구조적 로깅(event 발행) | 로그 점검 |
|
|
|
| **자율성 레벨 준수** | low 자동 실행은 `autonomy_setting.level`(mixed↑); high는 항상 대기 | `test_autonomy_levels` |
|
|
|
| **자원 예산** | `guardrails.Budget`(MAX_STEPS=12/RETRIES=2/TOOL_CALLS=8) | `test_agent_budget` |
|
|
|
| **모델 비종속·폴백** | scripted/heuristic 폴백 → 오프라인/CI 동일 동작 | `test_scripted_determinism` |
|
|
|
| **프라이버시** | STT/Vision 로컬 처리(Ollama/whisper류), 외부 토큰은 phase-13 암호화 저장 | phase-13/15 |
|
|
|
|
|
|
> **신뢰의 핵심 메시지(UI).** 심부름 타임라인은 "무엇을, 왜, 어디까지" 했는지 step 단위로 투명하게 보여준다(approve-data.js 철학: "신뢰 + 투명성"의 log[]와 동일 정신). 외부 영향 단계는 명시적으로 `external` 표시 + 결재함 링크 — 사용자가 *모르는 사이*에 결제/해지되지 않는다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. 테스팅 & 검증
|
|
|
|
|
|
### 9.1 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드
|
|
|
cd backend && uv run pytest -q
|
|
|
uv run pytest -k "errand or proactive or weekly or multimodal" # 본 phase
|
|
|
uv run pytest -k "agent and budget" # 가드레일 예산
|
|
|
uv run uvicorn app.main:app --reload # 로컬 서버(:8000)
|
|
|
|
|
|
# 수동 트리거(데모 결정성, WORKER_ENABLED=false 기본)
|
|
|
curl -s -X POST localhost:8000/api/worker/run/proactive | jq .
|
|
|
curl -s -X POST localhost:8000/api/worker/run/weekly_review -d '{"week":"6/7~6/13"}' | jq .
|
|
|
curl -s -X POST localhost:8000/api/agents/errand \
|
|
|
-d '{"kind":"cancel","title":"Netflix 일시정지","target":"Netflix","goal":"결제 전 정지"}' | jq .
|
|
|
|
|
|
# 프런트
|
|
|
cd frontend && pnpm test # Vitest + RTL
|
|
|
pnpm test errand proactive composer # 본 phase 관련
|
|
|
pnpm playwright test proactive.spec.ts errand.spec.ts multimodal.spec.ts
|
|
|
pnpm playwright test --grep @a11y # axe
|
|
|
```
|
|
|
|
|
|
### 9.2 백엔드 테스트 (pytest)
|
|
|
|
|
|
`backend/tests/test_errand.py` — scripted 폴백으로 LLM 없이도 결정적 통과:
|
|
|
```python
|
|
|
from app.agents.errand import ErrandAgent
|
|
|
from app.models import ErrandStatus, StepState, Approval
|
|
|
|
|
|
def test_errand_external_effect_gated(session):
|
|
|
er = ErrandAgent(session).start(kind="cancel", title="Netflix 일시정지",
|
|
|
goal="결제 전 정지", target="Netflix")
|
|
|
# 외부 영향(form_fill) step 직전에서 멈춤
|
|
|
assert er.status == ErrandStatus.awaiting_approval
|
|
|
ext = [s for s in _steps(session, er.id) if s.external_effect]
|
|
|
assert len(ext) == 1 and ext[0].state == StepState.blocked
|
|
|
# 결재함에 high-risk 승인 생성(a6 문구)
|
|
|
ap = session.get(Approval, er.approval_id)
|
|
|
assert ap.risk == "high" and ap.source == "agent"
|
|
|
assert ap.cta == "일시정지" and ap.alt == "유지"
|
|
|
|
|
|
def test_resume_after_approval_completes(session, client):
|
|
|
er = ErrandAgent(session).start(kind="cancel", title="Netflix 일시정지",
|
|
|
goal="x", target="Netflix")
|
|
|
client.post(f"/api/approvals/{er.approval_id}/approve")
|
|
|
from app.worker.jobs.errand_runner import run_errand_queue
|
|
|
run_errand_queue(session); session.refresh(er)
|
|
|
assert er.status == ErrandStatus.done
|
|
|
assert "되돌리기 가능" in (er.result_summary or "")
|
|
|
|
|
|
def test_full_auto_still_gates_high(session):
|
|
|
_set_autonomy(session, "full_auto")
|
|
|
er = ErrandAgent(session).start(kind="cancel", title="x", goal="x", target="Netflix")
|
|
|
assert er.status == ErrandStatus.awaiting_approval # full_auto여도 high는 대기
|
|
|
|
|
|
def test_agent_budget(session, monkeypatch):
|
|
|
# 동적 plan이 MAX_STEPS 초과 시 abort → failed (부분 step 보존)
|
|
|
...
|
|
|
```
|
|
|
|
|
|
`backend/tests/test_proactive.py`:
|
|
|
```python
|
|
|
from app.worker.jobs.proactive import run_proactive
|
|
|
|
|
|
def test_meeting_streak_card(session):
|
|
|
_seed_three_consecutive_meetings(session) # 10/11/13시
|
|
|
cards = run_proactive(session)
|
|
|
titles = [c.title for c in cards]
|
|
|
assert any("미팅 3연속" in t for t in titles)
|
|
|
sched = next(c for c in cards if c.kind == "schedule")
|
|
|
assert sched.action_kind == "focus_block"
|
|
|
|
|
|
def test_pricey_bill_card(session):
|
|
|
_seed_bill_anomaly(session, name="ChatGPT Plus", avg=29000, this=35000)
|
|
|
cards = run_proactive(session)
|
|
|
assert any("평소보다" in c.title and c.tone == "amber" for c in cards)
|
|
|
|
|
|
def test_accept_proactive_materializes(session, client):
|
|
|
run_proactive(session)
|
|
|
pc = _first_active(session, "schedule")
|
|
|
out = client.post(f"/api/proactive/{pc.id}/accept").json()
|
|
|
assert out["action_kind"] == "focus_block" and out["created_id"]
|
|
|
```
|
|
|
|
|
|
`backend/tests/test_multimodal.py`:
|
|
|
```python
|
|
|
from app.multimodal.factory import get_stt, get_vision
|
|
|
|
|
|
def test_stt_heuristic_golden(monkeypatch):
|
|
|
monkeypatch.setenv("STT_PROVIDER", "heuristic")
|
|
|
t = get_stt().transcribe(b"", hint="pool")
|
|
|
assert t.text == "음성 메모 0:14 — 수영장 차광막 부품 알아보기"
|
|
|
assert t.model == "heuristic"
|
|
|
|
|
|
def test_vision_heuristic_golden(monkeypatch):
|
|
|
monkeypatch.setenv("VISION_PROVIDER", "heuristic")
|
|
|
c = get_vision().describe(b"", hint="clip")
|
|
|
assert c.text == "캡처 사진 — 차광막 클립 부품"
|
|
|
|
|
|
def test_transcribe_then_capture_classifies(client):
|
|
|
r = client.post("/api/inbox/transcribe", files={"audio": ("a.webm", b"", "audio/webm")},
|
|
|
data={"hint": "pool"})
|
|
|
text = r.json()["text"]
|
|
|
cap = client.post("/api/inbox/capture", json={"kind": "voice", "raw": text}).json()
|
|
|
assert cap["classification"]["type"] in ("idea", "task")
|
|
|
assert "수영장" in cap["classification"]["proj_label"]
|
|
|
|
|
|
def test_weekly_review_pattern(session):
|
|
|
from app.worker.jobs.weekly_review import run_weekly_review
|
|
|
_seed_week(session, deep=8, meeting=12)
|
|
|
wr = run_weekly_review(session, week_label="6/7~6/13")
|
|
|
assert wr.deep_work_h == 8 and wr.meeting_h == 12
|
|
|
assert any(p["action_kind"] == "automation" for p in _patterns(wr))
|
|
|
```
|
|
|
|
|
|
### 9.3 컴포넌트 테스트 (Vitest + RTL)
|
|
|
|
|
|
`tests/ErrandTimeline.test.tsx`:
|
|
|
```ts
|
|
|
test("blocked external step → 결재함 안내 + 잠금 표시", () => {
|
|
|
render(<ErrandTimeline er={ERR_AWAITING} />);
|
|
|
expect(screen.getByText("일시정지 신청 제출")).toBeInTheDocument();
|
|
|
expect(screen.getByText(/결재함에서 승인 필요/)).toBeInTheDocument();
|
|
|
expect(screen.getByRole("link", { name: /결재함/ })).toHaveAttribute("href", "/approvals");
|
|
|
});
|
|
|
test("done → result_summary 표시", () => {
|
|
|
render(<ErrandTimeline er={ERR_DONE} />);
|
|
|
expect(screen.getByText(/되돌리기 가능/)).toBeInTheDocument();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`tests/ProactiveCard.test.tsx`:
|
|
|
```ts
|
|
|
test("카드: title/why/cta 렌더 + tone 색", () => {
|
|
|
render(<ProactiveBanner cards={[PC_MEETING]} />);
|
|
|
expect(screen.getByText("내일 미팅 3연속 — 점심 비워뒀어요")).toBeInTheDocument();
|
|
|
expect(screen.getByText(/12시 공백/)).toBeInTheDocument();
|
|
|
expect(screen.getByRole("button", { name: "그대로 둘게요" })).toBeInTheDocument();
|
|
|
});
|
|
|
test("카드 없으면 배너 미표시", () => {
|
|
|
const { container } = render(<ProactiveBanner cards={[]} />);
|
|
|
expect(container.querySelector(".proactive")).toBeNull();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`tests/CaptureComposer.mm.test.tsx` (멀티모달 폴백):
|
|
|
```ts
|
|
|
test("getUserMedia 미지원 → voice 폴백 문구로 제출", async () => {
|
|
|
const onSubmit = vi.fn();
|
|
|
// navigator.mediaDevices 미정의 환경
|
|
|
render(<CaptureComposer onSubmit={onSubmit} />);
|
|
|
fireEvent.click(screen.getByLabelText("음성으로 캡처"));
|
|
|
expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("텍스트로 적어주세요"), "voice");
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 9.4 E2E (Playwright)
|
|
|
|
|
|
`playwright/errand.spec.ts`:
|
|
|
```ts
|
|
|
test("심부름 → 결재함 게이트 → 승인 → 완료(연합)", async ({ page, request }) => {
|
|
|
await request.post("/api/agents/errand", { data: {
|
|
|
kind:"cancel", title:"Netflix 일시정지", target:"Netflix", goal:"결제 전 정지" }});
|
|
|
await page.goto("/approvals");
|
|
|
await expect(page.getByText("Netflix 일시정지을(를) 추천해요")).toBeVisible();
|
|
|
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible(); // high cta
|
|
|
await page.getByRole("button", { name: "일시정지" }).click();
|
|
|
// 이어 실행 후 완료
|
|
|
await request.post("/api/worker/run/errand_runner");
|
|
|
await page.goto("/agents/errand/er1"); // 또는 결재함 로그
|
|
|
await expect(page.getByText(/되돌리기 가능/)).toBeVisible();
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`playwright/proactive.spec.ts`:
|
|
|
```ts
|
|
|
test("능동 카드 → 대시보드 상단 노출 → 수락", async ({ page, request }) => {
|
|
|
await request.post("/api/worker/run/proactive");
|
|
|
await page.goto("/dashboard");
|
|
|
await expect(page.getByText("내일 미팅 3연속 — 점심 비워뒀어요")).toBeVisible();
|
|
|
await page.getByRole("button", { name: "그대로 둘게요" }).click();
|
|
|
// focus_block 실체화 → 일정/대시보드에 반영
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`playwright/multimodal.spec.ts`:
|
|
|
```ts
|
|
|
test("이미지 캡처 → 캡션 → 분류", async ({ page }) => {
|
|
|
await page.goto("/inbox");
|
|
|
await page.setInputFiles('input[type=file]', "fixtures/clip.jpg");
|
|
|
await expect(page.locator(".cap-k.image").first()).toBeVisible();
|
|
|
await expect(page.getByText(/차광막|수영장/)).toBeVisible(); // 캡션→분류
|
|
|
});
|
|
|
|
|
|
test("@a11y 능동 배너 aria-live", async ({ page, request }) => {
|
|
|
await request.post("/api/worker/run/proactive");
|
|
|
await page.goto("/dashboard");
|
|
|
await expect(page.locator('[aria-live="polite"]')).toBeVisible();
|
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
|
expect(results.violations).toEqual([]);
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 9.5 수동 QA 체크리스트
|
|
|
|
|
|
- [ ] `POST /api/agents/errand`("Netflix 일시정지") → 타임라인 s0~s4 done, s5 `blocked`(external) + "결재함에서 승인 필요".
|
|
|
- [ ] 결재함에 `a6` 톤 카드(amber, "일시정지"/"유지", "확인 필요") 등장 — `source:agent`.
|
|
|
- [ ] 결재함에서 "일시정지" 승인 → `errand_runner` 후 심부름 `done`, "모레 결제 전 일시정지 완료(되돌리기 가능)".
|
|
|
- [ ] 자율성 `full_auto`로 올려도 external step은 여전히 승인 대기(자동 실행 안 됨).
|
|
|
- [ ] `POST /api/worker/run/proactive` → 대시보드 상단 능동 배너 2장(미팅 3연속 violet / 청구서 amber).
|
|
|
- [ ] 능동 카드 "그대로 둘게요"/"자세히" → focus_block/task 실체화, 카드는 accepted로 사라짐.
|
|
|
- [ ] 능동 배너 `aria-live="polite"` — 스크린리더가 선제 제안을 읽음.
|
|
|
- [ ] 인박스 mic 버튼 → 실제 녹음(권한 허용 시) → transcribe → raw 채워져 분류. 권한 거부 → "텍스트로 적어주세요" 폴백.
|
|
|
- [ ] 인박스 image 버튼 → 파일 선택 → caption → raw 채워져 분류(차광막/수영장 골든).
|
|
|
- [ ] STT/Vision 미가용(OLLAMA off): `model:"heuristic"`로 골든 transcript/caption, 분류는 정상.
|
|
|
- [ ] `GET /api/weekly-review` → 딥워크 8h/회의 12h + "화요일 오전 보호할까요?" 제안. 수락 시 자동화 규칙 생성.
|
|
|
- [ ] 다크 테마 토글: 능동 카드/타임라인/배지 색이 토큰으로 자연스럽다.
|
|
|
- [ ] 키보드 only: 능동 카드 cta/닫기, 심부름 결재함 링크, mic/image 버튼 Tab 순회 + Enter/Space.
|
|
|
|
|
|
### 9.6 통과 기준
|
|
|
|
|
|
- 백엔드: errand 게이트/재개/full_auto 불변식, proactive 감지/수락, multimodal 골든, weekly 패턴, agent budget 전부 green — **scripted/heuristic 폴백으로 LLM 없이도 결정적 통과**.
|
|
|
- 프런트: 컴포넌트(타임라인/능동 카드/멀티모달 폴백) green.
|
|
|
- E2E: 심부름→결재함→승인→완료(연합), 능동 카드→대시보드→수락, 멀티모달→분류, a11y(aria-live + axe 0건) green.
|
|
|
- 안전 불변식: external_effect 자동 실행 0건(테스트로 강제), 모든 외부 행동에 approval_log 기록.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. 완료 기준 (Definition of Done)
|
|
|
|
|
|
- [ ] `backend/app/multimodal/`(STTProvider/VisionProvider + factory, env 주입 + 폴백) 구현, `/api/inbox/transcribe`·`/api/inbox/caption` 동작.
|
|
|
- [ ] 인박스 mic/image **실구현**(녹음/업로드 → transcribe/caption → phase-4 분류). 미가용 시 graceful 폴백.
|
|
|
- [ ] `backend/app/agents/errand.py` 심부름 에이전트(plan→act→observe→reflect, step 기록) + `guardrails`(external_effect 게이트 + 예산).
|
|
|
- [ ] external_effect step → phase-7 `Approval(risk=high, source=agent)` 게이트. 승인 후 `errand_runner`가 이어 실행, 되돌리기 가능.
|
|
|
- [ ] `backend/app/worker/jobs/`(proactive/digest/weekly_review/errand_runner) + 수동 트리거 `/api/worker/run/{job}`(WORKER_ENABLED=false 기본).
|
|
|
- [ ] `ProactiveCard`/`WeeklyReview` 모델·API, 대시보드 상단 `ProactiveBanner`(aria-live), 주간 리뷰 패턴→자동화/집중 블록 실체화.
|
|
|
- [ ] 연합: 심부름→결재함→승인→완료→하루 마감 합산, 능동 카드→작업/일정/자동화, 멀티모달→분류→작업.
|
|
|
- [ ] 안전 불변식: external 행동 항상 승인(full_auto여도), 모든 자동 실행 되돌리기+감사 로그.
|
|
|
- [ ] 모델 비종속: scripted/heuristic 폴백으로 오프라인/CI 결정적 동작(`model` 필드 표기).
|
|
|
- [ ] 신규 테이블/엔드포인트가 phase-2 패턴(PK=TEXT, 라우터 내부 prefix 없음 + `include_router(prefix="/api")`, `run_seed` 내부 `_seed_*` 헬퍼)을 따름.
|
|
|
- [ ] 9장 테스트/QA 전부 통과. 디자인은 원본(`approve-data.js a6`/`data.js`/`wrap-data.js`/phase-4) 토큰·문구를 그대로 재현.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 11. 다음 단계
|
|
|
|
|
|
다음 문서는 **`phase-15-production.md`**(프로덕션 하드닝). 본 phase의 산출물이 phase-15에서 다음과 같이 다뤄진다.
|
|
|
|
|
|
- **worker/스케줄러 운영화**: `WORKER_ENABLED=true` 프로덕션 가동, 잡 실패 알림·재시도·관측성(능동 잡 메트릭: proactive 생성률, digest 배달, errand 완료율).
|
|
|
- **인증/멀티유저**: 심부름·능동 카드·주간 리뷰가 **per-user 스코프**(현재 단일 데모 사용자 지우)로 격리. `AUTH_ENABLED=true`.
|
|
|
- **안전/보안 강화**: 외부 행동 승인의 감사 추적(approval_log)을 변조 불가 로그로, STT/Vision/에이전트의 입력·출력 프라이버시 경계(로컬 처리 보장), high-risk 행동의 2차 확인 옵션.
|
|
|
- **멀티모달 실모델**: `STT_MODEL`/`VISION_MODEL`을 실제 whisper/비전 모델로 주입, 성능(녹음 길이·이미지 크기 한도)·비용 가드.
|
|
|
- **에이전트 tool 실연동**: `form_fill`/`http_fetch`가 phase-13 `RealConnector.write()`와 결합해 실제 외부 시스템에 쓰기(예약 확정/해지)까지 — 프로덕션 권한·rate limit·롤백 정책.
|
|
|
|
|
|
phase 7~14 전체 연합(자율성 코어 → 페이지 → 실연동 → 능동)의 프로덕션 통합·관측성·성능·보안 종합은 **`phase-15-production.md`** 에서 마무리한다.
|