# Phase 10 — 리서치 + 여행 (에이전트 오케스트레이션)

> 한 줄 요약: 멀티소스 종합 리포트·지식 베이스 Q&A·시각화/추세 예측을 갖춘 **지능형 리서치 파트너**와, 다가오는 출장·AI 여행 플래너·찜/가격추적을 갖춘 **여행 도우미**를 구현한다. 이를 위해 횡단 아키텍처 신규물 3종 — **에이전트 오케스트레이션(`backend/app/agents/`)**, **RAG/지식베이스(`backend/app/rag/`)**, **웹검색 툴** — 을 도입하고, 모든 외부 의존은 *모델 비종속 + scripted/heuristic 폴백*으로 데모 결정성을 보장한다.

> 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
>
> 선행(포스트-MVP): `post-mvp-overview.md`(횡단 아키텍처·로드맵·가정), `phase-7-approvals-automation.md`(자율성 코어 — 결재함·자동화 엔진·`event_bus`), `phase-8-calendar-meetings.md`(액션→작업 연합), `phase-9-mail-notifications.md`(알림 트리아지 — "리서치에 정리해둘게요" 연계).
> 선행(MVP 상속): `overview.md`(스택·토큰·데이터/API·연합), `phase-2-backend.md`(모델/스키마/라우터/시드/LLM 추상화 패턴), `phase-1-design-system.md`(토큰·`Icon`·셸·`SubRail`), `phase-3-tasks.md`(`board`/카드/탭 패턴).
> 후속: `phase-11-life-care.md`(라이프 케어 — `knowledge_item` 공유), `phase-13-integrations.md`(웹검색/임베딩 real 커넥터 교체), `phase-14-proactive-agent.md`(심부름 에이전트·능동 알림).

원본 디자인(픽셀 충실 재현 기준):

```
REF = /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/design-reference
  REF/리서치.html                        리서치 진입 HTML
  REF/assets/research.jsx                리서치 UI (Composer/ReportCard/QACard/ChartCard/HomeGrid)
  REF/assets/research-data.js            리서치 시드(window.ResearchData)
  REF/assets/research.css                리서치 전용 스타일(tasks.css 뒤 로드)
  REF/여행.html                          여행 진입 HTML
  REF/assets/trip.jsx                    여행 UI (UpcomingView/PlanView/SavedView)
  REF/assets/trip-data.js                여행 시드(window.AriTrip)
  REF/assets/trip.css                    여행 전용 스타일(dash.css 토큰 위)
```

---

## 0. 목차

1. [개요 & 목표](#1-개요--목표)
2. [선행 조건 / 산출물](#2-선행-조건--산출물)
3. [상세 구현 — 백엔드](#3-상세-구현--백엔드)
4. [상세 구현 — 에이전트 오케스트레이션](#4-상세-구현--에이전트-오케스트레이션)
5. [상세 구현 — RAG / 지식베이스](#5-상세-구현--rag--지식베이스)
6. [상세 구현 — 프론트엔드 (리서치)](#6-상세-구현--프론트엔드-리서치)
7. [상세 구현 — 프론트엔드 (여행)](#7-상세-구현--프론트엔드-여행)
8. [데이터 / 타입 / API 계약](#8-데이터--타입--api-계약)
9. [디자인 충실도 노트](#9-디자인-충실도-노트)
10. [상태 처리 & 엣지 케이스](#10-상태-처리--엣지-케이스)
11. [연합 이벤트 (발행/구독)](#11-연합-이벤트-발행구독)
12. [테스팅 & 검증](#12-테스팅--검증)
13. [완료 기준 (Definition of Done)](#13-완료-기준-definition-of-done)
14. [다음 단계](#14-다음-단계)

---

## 1. 개요 & 목표

이 phase가 끝나면 **두 개의 페이지가 동작**하고, 그 둘이 공유하는 **세 가지 횡단 능력**이 백엔드에 자리잡는다.

### 1.1 페이지 목표

**리서치(`/research`)** — 지능형 리서치 파트너. 원본 `research.jsx`의 사이드바 4뷰 + 컬렉션 + 학습 자료를 픽셀 충실하게 재현한다.
- **새 조사(home)**: 자연어 명령 컴포저(`Composer`) + 예시 칩 + 입구 카드 3개(`HomeGrid`). "무엇이든 조사를 맡겨보세요" → 에이전트가 출처를 모아 교차 분석.
- **종합 리포트(report)**: 논문·뉴스·보고서를 출처군별로 모아 **교차 분석표**(주장/논조) + **종합 문단** + 편향 주의 노트.
- **지식 Q&A(qa)**: 저장한 PDF·웹·메모를 학습(RAG)해 **근거 링크와 함께** 답.
- **시각화·예측(chart)**: 막대 차트 + **예측 막대(점선)** + CAGR 인사이트 + "단순 추세 외삽" 한계 주의.

**여행(`/trip`)** — 여행·출장 도우미. 원본 `trip.jsx`의 `SubRail` 3뷰를 재현한다.
- **다가오는 출장(upcoming)**: 부산 D-4 — 히어로(route/stay/weather) · 아리가 미리 해둔 일(prep) · 체크리스트(localStorage) · 일정표(day 탭) · 경비(자동 정산).
- **새 여행 계획(plan)**: AI 플래너 — 자연어 한 문장 → 조사 진행 애니메이션 → transport/stay/days/budget/checklist/sources 결과. 예시 제주·도쿄는 완성 결과를 **결정적으로** 반환, 자유 입력은 0번(제주)으로 폴백.
- **찜·가격추적(saved)**: 저장한 여행 아이디어 + spark 가격 그래프 + 목표가 알림 토글.

### 1.2 횡단 능력 목표 (post-mvp-overview 명명 고정)

| 능력 | 디렉터리 | 이 phase의 책임 | 폴백 |
|---|---|---|---|
| **에이전트 오케스트레이션** | `backend/app/agents/` | `plan→act(tool)→observe→reflect` 멀티스텝 루프. 리서치 종합 파이프라인 + 여행 플래너 파이프라인. 툴: `web_search`, `rag_query`, `http_fetch`, `task_create`, `calendar_write`(stub) | tool-capable 모델 미가용/오프라인 시 **scripted 파이프라인**(예시 골든 결과 결정적 반환) |
| **RAG / 지식베이스** | `backend/app/rag/` | `ingest(pdf/web/note)→chunk→embed→vector store→query`. 지식 Q&A가 근거(`refs`)와 함께 답 | 임베딩 모델 미가용 시 **TF-IDF/코사인 heuristic** 검색 |
| **웹검색 툴** | `backend/app/agents/tools/web_search.py` | 외부 검색 결과를 출처 객체로 정규화 | `CONNECTOR_KNOWLEDGE=mock` 기본 — **MockSearchConnector**(시드 출처 반환) |

> **데모 결정성 원칙**(post-mvp-overview 가정 상속): 에이전트/RAG/임베딩은 *모델 비종속*. tool-capable·embedding 모델은 env로 주입하고, 미가용 시 scripted/heuristic 폴백으로 **시연 동작을 항상 보장**한다. 리서치 시드 리포트(`최신 AI 반도체 시장 동향`)와 플래너 예시 2개(제주·도쿄)는 폴백 경로에서도 **원본 `*-data.js`와 1바이트도 다르지 않은** 골든 결과를 반환한다.

### 1.3 상속 규약 요약 (변경 금지)

`overview.md §7~§14`와 `phase-2-backend.md` 그대로:
- 스택: 프론트 Next.js(App Router)+React+TS / 백엔드 FastAPI+SQLite(SQLModel+Alembic) / LLM 로컬 Ollama(Provider 추상화, `OLLAMA_MODEL` 주입, heuristic 폴백).
- 데이터모델: 모든 PK는 TEXT(str). `tone` 집합 `blue|violet|coral|green|amber|ink|faint`. 한국어 UI 문구는 원본 데이터 파일 그대로.
- API: prefix `/api`. 라우터는 내부 prefix 없이 정의하고 `main.py`의 `include_router(prefix="/api", tags=...)`에서만 `/api` 부착.
- 환경변수: `DATABASE_URL`, `OLLAMA_HOST`, `OLLAMA_MODEL`, `LLM_PROVIDER`, `FRONTEND_ORIGIN`, `NEXT_PUBLIC_API_BASE` + 본 phase 신규 `CONNECTOR_KNOWLEDGE`, `EMBED_PROVIDER`, `EMBED_MODEL`, `AGENT_PROVIDER`, `WEB_SEARCH_PROVIDER`.
- 시드 진입: `run_seed(session=None, reset=True)` 단일 정본 — 본 phase 시드는 `_seed_research(session)`, `_seed_trip(session)` 헬퍼로 `_run()` 안에서 호출.

---

## 2. 선행 조건 / 산출물

### 2.1 선행 조건

| 의존 | 내용 |
|---|---|
| `post-mvp-overview.md` | 횡단 아키텍처(`agents/`, `rag/`, `connectors/`, `event_bus`, `worker/`) 명명·디렉터리·env 규약 정의. |
| `phase-7-approvals-automation.md` | `event_bus`(내부 이벤트) + `approval` 모델. 여행 플래너의 결제성 액션(항공 예약 등)은 결재함으로 enqueue. |
| `phase-8-calendar-meetings.md` | `event`/`calendar` 모델 + `calendar_write` 툴 대상. 여행 일정 → 캘린더 쓰기. |
| `phase-9-mail-notifications.md` | `notification` 모델. "리서치에 정리해둘게요" — 알림이 끝난 조사를 알려준다(`notification.triaged` 연계). |
| `phase-2-backend.md` | 모델/스키마/라우터/시드/LLM 추상화 패턴. 본 phase 신규 테이블·엔드포인트는 이 패턴을 그대로 따른다. |
| `phase-1-design-system.md` | `Icon`(중앙 paths 맵), `Topbar`(13항목), `SubRail`, 토큰. |
| `phase-3-tasks.md` | `board`/카드(`card`/`ch`/`ico`/`htext`)/탭(`tp-tabs`) 레이아웃 패턴. `task_create` 연합 대상. |

### 2.2 산출물

```
backend/
├─ app/
│  ├─ models.py                  ← 신규 테이블 11종 추가(아래 §3.1)
│  ├─ schemas.py                 ← 리서치/여행 I/O 스키마 추가
│  ├─ seed.py                    ← _seed_research / _seed_trip 헬퍼 추가
│  ├─ routers/
│  │  ├─ research.py             GET 컬렉션/소스/리포트/qa/chart, POST 조사 시작, POST qa
│  │  └─ trip.py                 GET 출장/찜, POST 플래너 실행, POST 찜 가격알림 토글
│  ├─ agents/                    ★신규 — 에이전트 오케스트레이션
│  │  ├─ __init__.py
│  │  ├─ base.py                 Agent 루프(plan→act→observe→reflect) + AgentResult
│  │  ├─ registry.py             get_agent_provider() (tool-capable | scripted 선택)
│  │  ├─ research_agent.py       리서치 종합 파이프라인
│  │  ├─ travel_agent.py         여행 플래너 파이프라인
│  │  ├─ scripted.py             결정적 폴백(골든 결과)
│  │  └─ tools/
│  │     ├─ __init__.py
│  │     ├─ base.py              Tool 인터페이스 + ToolResult
│  │     ├─ web_search.py        web_search (MockSearchConnector 폴백)
│  │     ├─ rag_query.py         rag_query (rag/ 호출)
│  │     ├─ http_fetch.py        http_fetch (URL→본문, stub-safe)
│  │     ├─ task_create.py       task_create (federation → /api/tasks)
│  │     └─ calendar_write.py    calendar_write (stub; phase-8 연동점)
│  └─ rag/                       ★신규 — RAG / 지식베이스
│     ├─ __init__.py
│     ├─ pipeline.py             ingest→chunk→embed→store→query 오케스트레이션
│     ├─ chunk.py                텍스트 청크 분할
│     ├─ embed.py                EmbeddingProvider 추상(Ollama embeddings | heuristic TF-IDF)
│     └─ store.py                VectorStore (SQLite + 코사인; sqlite-vec 선택)
├─ migrations/versions/xxxx_phase10_research_travel.py
└─ tests/
   ├─ test_agents_research.py
   ├─ test_agents_travel_examples.py    ← 예시 2개 결정성 골든
   ├─ test_rag_qa.py
   ├─ test_api_research.py
   ├─ test_api_trip.py
   └─ test_seed_research_trip.py

frontend/
├─ app/
│  ├─ research/page.tsx          리서치 페이지(사이드바 4뷰)
│  └─ trip/page.tsx              여행 페이지(SubRail 3뷰)
├─ components/research/
│  ├─ ResearchSidebar.tsx        rs-nav + 컬렉션 + 학습 자료
│  ├─ Composer.tsx               rs-composer (조사 명령 + 칩 + queued)
│  ├─ HomeGrid.tsx               rs-entries
│  ├─ ReportCard.tsx             rs-report (교차분석표)
│  ├─ QACard.tsx                 rs-card (근거 refs)
│  └─ ChartCard.tsx              rs-chart (예측 막대)
├─ components/trip/
│  ├─ UpcomingView.tsx           히어로/prep/체크리스트/일정표/경비
│  ├─ PlanView.tsx               input→research(애니메이션)→result
│  ├─ SavedView.tsx              찜·가격추적 + Spark
│  ├─ Leg.tsx  Spark.tsx  KindIcon.tsx
├─ lib/hooks/
│  ├─ useResearch.ts             컬렉션/소스/리포트/qa/chart + 조사 시작 + qa 질의
│  └─ useTrip.ts                 출장/찜 + 플래너 실행 + 가격알림 토글
└─ styles/
   ├─ research.css               REF/assets/research.css 이식
   └─ trip.css                   REF/assets/trip.css 이식
```

---

## 3. 상세 구현 — 백엔드

### 3.1 `models.py` — 신규 테이블 (phase-2 패턴 그대로)

CONTRACT의 페이지별 테이블 스케치를 원본 `research-data.js`/`trip-data.js` 필드로 정밀 이식한다. **모든 PK는 TEXT(str)**, `tone`은 키 값('blue' 등), enum은 `str, Enum`.

```python
# backend/app/models.py  (phase-10 추가분)
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship, Column, JSON
from .models import now  # 기존 헬퍼 재사용


# ============ 리서치 enums ============
class SourceKind(str, Enum):
    pdf = "pdf"
    web = "web"
    note = "note"


# ============ 리서치 테이블 ============
class ResearchCollection(SQLModel, table=True):
    """지식 베이스 컬렉션 — 사용자가 모은 자료를 아리가 학습."""
    __tablename__ = "research_collection"
    id: str = Field(primary_key=True)          # "chip" / "onb" / "stroller"
    name: str                                   # "AI 반도체 시장"
    tone: str = "ink"                           # blue|violet|green ...
    n: int = 0                                   # 자료 수 (원본 collections[].n)
    sort_order: int = 0


class ResearchSource(SQLModel, table=True):
    """최근 저장 자료 — kind: pdf/web/note, learned: 아리 학습 완료 여부."""
    __tablename__ = "research_source"
    id: str = Field(primary_key=True)          # "s1"~"s5"
    kind: SourceKind
    title: str                                  # "2026 반도체 시장 전망 보고서"
    from_label: str = ""                        # 원본 from. (from은 파이썬 예약어 → from_label, 응답에서 alias "from")
    col: Optional[str] = Field(default=None, foreign_key="research_collection.id")
    learned: bool = False
    sort_order: int = 0


class ResearchReport(SQLModel, table=True):
    """멀티소스 종합 리포트 — 교차 분석. counts/cross는 JSON 컬럼."""
    __tablename__ = "research_report"
    id: str = Field(primary_key=True)          # "rp1"
    title: str                                  # "최신 AI 반도체 시장 동향"
    asked: str = ""                             # "보고서, 논문, 뉴스 기사를..."
    meta: str = ""                              # "출처 10곳 수집 · 3분 전 완성"
    synthesis: str = ""                         # HTML 허용 (<b>)
    note: str = ""                              # 편향 주의
    counts: list = Field(default_factory=list, sa_column=Column(JSON))  # [{k,n,tone}]
    cross: list = Field(default_factory=list, sa_column=Column(JSON))   # [{src,tone,claim,stance}]
    collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
    created_at: datetime = Field(default_factory=now)


class ResearchQA(SQLModel, table=True):
    """지식 베이스 Q&A — 저장 자료 기반 답변 + 원문 근거."""
    __tablename__ = "knowledge_qa"           # CONTRACT 명명: knowledge_qa
    id: str = Field(primary_key=True)          # "qa1"
    q: str
    a: str = ""                                 # HTML 허용
    refs: list = Field(default_factory=list, sa_column=Column(JSON))    # [{title,part}]
    collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
    created_at: datetime = Field(default_factory=now)


class ResearchChart(SQLModel, table=True):
    """시각화 + 추세 예측 — bars + forecast."""
    __tablename__ = "research_chart"
    id: str = Field(primary_key=True)          # "ch1"
    title: str                                  # "경쟁사 A — 연 매출 추이와 내년 예측"
    asked: str = ""
    unit: str = ""                             # "억원"
    bars: list = Field(default_factory=list, sa_column=Column(JSON))    # [{y,v,forecast?}]
    insight: str = ""                          # HTML 허용
    caution: str = ""


# ============ 여행 테이블 ============
class Trip(SQLModel, table=True):
    """다가오는 출장(부산)."""
    __tablename__ = "trip"
    id: str = Field(primary_key=True)          # "tp_busan"
    dday: str = ""                             # "D-4"
    title: str = ""                            # "부산 출장"
    dates: str = ""                            # "6월 16일(월) – 17일(화) · 1박 2일"
    purpose: str = ""                          # "부산 지사 · 분기 리포트 발표"
    brief: str = ""
    weather: list = Field(default_factory=list, sa_column=Column(JSON))  # [{d,t,icon}]
    expense_budget: int = 0                    # 400000
    expense_planned: int = 0                   # 331600
    expense_note: str = ""
    expense_rows: list = Field(default_factory=list, sa_column=Column(JSON))  # [{name,amt,state}]


class TripRoute(SQLModel, table=True):
    """이동 구간 — dir: out|back."""
    __tablename__ = "trip_route"
    id: str = Field(primary_key=True)          # "rt_out" / "rt_back"
    trip_id: str = Field(foreign_key="trip.id")
    dir: str                                    # "out" | "back"
    mode: str                                   # "KTX 101"
    from_st: str; ft: str                       # "서울역", "07:00"
    to_st: str; tt: str                         # "부산역", "09:42"
    seat: str = ""; note: str = ""


class TripStay(SQLModel, table=True):
    __tablename__ = "trip_stay"
    id: str = Field(primary_key=True)
    trip_id: str = Field(foreign_key="trip.id")
    name: str; desc: str = ""; conf: str = ""


class TripPrep(SQLModel, table=True):
    """아리가 미리 해둔 일. state: done|doing."""
    __tablename__ = "trip_prep"
    id: str = Field(primary_key=True)
    trip_id: str = Field(foreign_key="trip.id")
    text: str
    state: str = "done"                        # done | doing
    sort_order: int = 0


class TripDay(SQLModel, table=True):
    """일정표 한 날 — items는 JSON([{t,kind,tone,title,meta,hot?}])."""
    __tablename__ = "trip_day"
    id: str = Field(primary_key=True)          # "d1" / "d2"
    trip_id: str = Field(foreign_key="trip.id")
    tab: str                                    # "16일 (월)"
    items: list = Field(default_factory=list, sa_column=Column(JSON))
    sort_order: int = 0


class TripChecklist(SQLModel, table=True):
    """체크리스트 항목. group으로 묶음, auto는 자동 처리 라벨."""
    __tablename__ = "trip_checklist"
    id: str = Field(primary_key=True)          # "c1"~"c7"
    trip_id: str = Field(foreign_key="trip.id")
    group: str                                  # "서류 · 결제" / "발표" / "짐"
    text: str
    auto: str = ""                             # "아리가 제출 완료" 등(체크 상태 자체는 localStorage)
    sort_order: int = 0


class SavedTrip(SQLModel, table=True):
    """찜한 여행 아이디어 — 가격 추적(spark)."""
    __tablename__ = "saved_trip"
    id: str = Field(primary_key=True)          # "sv1"~"sv3"
    title: str; tag: str = ""; note: str = ""
    now: str = ""                              # "89,000원"
    delta: str = ""                            # "−12%"
    down: bool = True                          # 가격 하락 여부
    watch: bool = True                         # 가격 알림 on/off (토글 영속)
    hint: str = ""
    spark: list = Field(default_factory=list, sa_column=Column(JSON))   # [120,118,...]
    sort_order: int = 0


class TripPlan(SQLModel, table=True):
    """AI 플래너 결과 — examples 인덱스와 1:1 매칭(제주=0, 도쿄=1). 자유입력은 0으로 폴백."""
    __tablename__ = "trip_plan"
    id: str = Field(primary_key=True)          # "plan_jeju" / "plan_tokyo"
    example: str                                # 매칭 질의 원문
    idx: int = 0                                # examples 인덱스
    title: str; meta: str = ""; summary: str = ""   # summary: HTML 허용
    weather: str = ""
    transport: list = Field(default_factory=list, sa_column=Column(JSON))  # [{mode,name,desc,price,pick?,note?}]
    stay: list = Field(default_factory=list, sa_column=Column(JSON))       # [{name,desc,price,unit,pick?}]
    days: list = Field(default_factory=list, sa_column=Column(JSON))       # [{tab,items:[{t,title,tone}]}]
    budget: dict = Field(default_factory=dict, sa_column=Column(JSON))     # {total,cap,rows:[{name,amt}]}
    checklist: list = Field(default_factory=list, sa_column=Column(JSON))  # [str]
    sources: list = Field(default_factory=list, sa_column=Column(JSON))    # [str]
```

> **JSON 컬럼 노트**: `counts/cross/bars/items/spark/transport` 등 중첩 배열은 원본 `*-data.js`가 이미 JSON-shaped다. SQLite는 JSON1 확장을 기본 지원하므로 `sa_column=Column(JSON)`으로 무손실 저장한다(phase-2의 단순 스칼라 컬럼과 달리 본 페이지는 표/차트 데이터가 중첩되어 JSON이 자연스럽다). 마이그레이션은 `import sqlmodel`을 versions 파일 상단에 둔다(phase-2 §3.3 노트).
>
> **`from`/`now` 예약어 회피**: 원본 `source.from`은 `from_label`(응답 alias `from`), `source.now`/`saved.now`는 그대로 `now` 컬럼(파이썬에서 컬럼명으로 사용 가능, 단 모듈 헬퍼 `now()`와 충돌하지 않게 인스턴스 속성으로만 접근).

### 3.2 RAG / 지식베이스용 테이블

```python
# backend/app/models.py  (rag 추가분)
class RagChunk(SQLModel, table=True):
    """ingest 된 자료의 청크 + 임베딩. vector store(SQLite)."""
    __tablename__ = "rag_chunk"
    id: str = Field(primary_key=True)          # "chk_s1_0"
    source_id: str = Field(foreign_key="research_source.id")
    seq: int = 0                                # 청크 순서
    text: str                                   # 청크 본문
    part_label: str = ""                       # 근거 표시용 "‘인증·벨트’ 단락"
    embedding: list = Field(default_factory=list, sa_column=Column(JSON))  # float[] (또는 sqlite-vec 컬럼)
    dim: int = 0
    model: str = ""                            # 임베딩 모델/heuristic 표기
    created_at: datetime = Field(default_factory=now)
```

> `sqlite-vec`을 사용 가능하면 `embedding`을 vec0 가상 테이블로 두고 ANN 검색, 미가용 시 `embedding`을 JSON float 배열로 두고 **파이썬 코사인 유사도**로 폴백한다(post-mvp-overview의 "SQLite + sqlite-vec 또는 단순 코사인"). 두 경로 모두 동일한 `store.query(vec, k)` 인터페이스를 노출한다.

### 3.3 `routers/research.py`

phase-2 패턴: 라우터는 내부 prefix 없이 정의, `main.py`에서 `/api` 부착.

```python
# backend/app/routers/research.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import (ResearchCollection, ResearchSource, ResearchReport,
                      ResearchQA, ResearchChart)
from ..schemas import (ResearchHomeOut, ReportOut, QAOut, ChartOut,
                       StartResearchRequest, StartResearchOut,
                       AskRequest, AskOut)
from ..agents.research_agent import run_research, answer_question

router = APIRouter()


@router.get("/research/home", response_model=ResearchHomeOut)
def research_home(s: Session = Depends(get_session)):
    """사이드바(컬렉션·소스) + 입구 카드용 요약."""
    cols = s.exec(select(ResearchCollection).order_by(ResearchCollection.sort_order)).all()
    srcs = s.exec(select(ResearchSource).order_by(ResearchSource.sort_order)).all()
    return ResearchHomeOut.build(cols, srcs)


@router.get("/research/report", response_model=ReportOut)
def get_report(s: Session = Depends(get_session)):
    rp = s.exec(select(ResearchReport)).first()
    if not rp:
        raise HTTPException(404, "no report")
    return ReportOut.from_model(rp)


@router.get("/research/qa", response_model=QAOut)
def get_qa(s: Session = Depends(get_session)):
    qa = s.exec(select(ResearchQA)).first()
    return QAOut.from_model(qa)


@router.get("/research/chart", response_model=ChartOut)
def get_chart(s: Session = Depends(get_session)):
    c = s.exec(select(ResearchChart)).first()
    return ChartOut.from_model(c)


@router.post("/research/start", response_model=StartResearchOut)
def start_research(req: StartResearchRequest, s: Session = Depends(get_session)):
    """자연어 조사 명령 → 에이전트 파이프라인(비동기 enqueue 또는 동기 골든).

    데모: 시드 질의면 골든 리포트 즉시. 그 외에는 'queued'(끝나면 알림).
    """
    return run_research(s, req.query)


@router.post("/research/ask", response_model=AskOut)
def ask(req: AskRequest, s: Session = Depends(get_session)):
    """지식 Q&A — RAG 검색 + 근거 답변."""
    return answer_question(s, req.q, collection_id=req.collection_id)
```

### 3.4 `routers/trip.py`

```python
# backend/app/routers/trip.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import Trip, TripRoute, TripStay, TripPrep, TripDay, TripChecklist, SavedTrip
from ..schemas import (UpcomingTripOut, SavedTripOut, WatchToggleRequest,
                       PlanRequest, PlanResultOut)
from ..agents.travel_agent import run_planner

router = APIRouter()


@router.get("/trip/upcoming", response_model=UpcomingTripOut)
def upcoming(s: Session = Depends(get_session)):
    """다가오는 출장(부산) 전체: route/stay/prep/days/checklist/expense."""
    trip = s.exec(select(Trip)).first()
    if not trip:
        raise HTTPException(404, "no trip")
    return UpcomingTripOut.build(s, trip)


@router.get("/trip/saved", response_model=list[SavedTripOut])
def saved(s: Session = Depends(get_session)):
    rows = s.exec(select(SavedTrip).order_by(SavedTrip.sort_order)).all()
    return [SavedTripOut.from_model(r) for r in rows]


@router.post("/trip/saved/{sid}/watch", response_model=SavedTripOut)
def toggle_watch(sid: str, req: WatchToggleRequest, s: Session = Depends(get_session)):
    """가격 알림 토글(영속). 켜면 price_watch.armed → 목표가 도달 시 알림."""
    r = s.get(SavedTrip, sid)
    if not r:
        raise HTTPException(404, "no saved trip")
    r.watch = req.watch if req.watch is not None else (not r.watch)
    s.add(r); s.commit(); s.refresh(r)
    return SavedTripOut.from_model(r)


@router.post("/trip/plan", response_model=PlanResultOut)
def plan(req: PlanRequest, s: Session = Depends(get_session)):
    """AI 여행 플래너 실행 — 자연어 → 조사 → transport/stay/days/budget/checklist."""
    return run_planner(s, req.text)
```

`main.py` 등록(phase-2 패턴):

```python
# backend/app/main.py  (추가)
from .routers import research, trip
app.include_router(research.router, prefix="/api", tags=["research"])
app.include_router(trip.router, prefix="/api", tags=["trip"])
```

### 3.5 시드 — `_seed_research` / `_seed_trip`

원본 `research-data.js` / `trip-data.js`의 값을 **그대로** 적재한다. phase-2의 `run_seed(session=None, reset=True)` 안에서 호출.

```python
# backend/app/seed.py  (추가분)

# ---- 리서치 (REF/assets/research-data.js) ----
RS_COLLECTIONS = [
    ("chip", "AI 반도체 시장", "blue", 12),
    ("onb", "온보딩 리서치", "violet", 8),
    ("stroller", "육아 — 유모차", "green", 5),
]
RS_SOURCES = [
    ("s1", "pdf", "2026 반도체 시장 전망 보고서", "산업연구원 · PDF 48p", "chip", True),
    ("s2", "web", "HBM4 양산 경쟁, 누가 앞섰나", "테크 뉴스 · 웹페이지", "chip", True),
    ("s3", "pdf", "추론 가속기 아키텍처 비교 (논문)", "arXiv · PDF 22p", "chip", True),
    ("s4", "note", "온보딩 인터뷰 메모 — 5건 요약", "내 메모", "onb", True),
    ("s5", "web", "신생아 유모차 안전 기준 정리", "육아 커뮤니티 · 웹페이지", "stroller", False),
]
RS_REPORT = dict(
    id="rp1", title="최신 AI 반도체 시장 동향",
    asked="“보고서, 논문, 뉴스 기사를 종합해서 알려줘”",
    meta="출처 10곳 수집 · 3분 전 완성",
    counts=[{"k": "논문", "n": 3, "tone": "violet"},
            {"k": "뉴스", "n": 5, "tone": "blue"},
            {"k": "보고서·블로그", "n": 2, "tone": "green"}],
    synthesis=("세 출처군 모두 <b>추론(inference) 수요로의 무게 이동</b>에는 동의해요. "
               "다만 학술 쪽은 <b>메모리 대역폭</b>을, 뉴스는 <b>HBM4 양산 경쟁</b>을, "
               "산업 보고서는 <b>전력 효율 규제</b>를 핵심 변수로 봐요 — 투자 판단이라면 셋이 "
               "겹치는 ‘메모리 중심 아키텍처’가 가장 합의된 신호예요."),
    cross=[{"src": "학술 논문 (3편)", "tone": "violet",
            "claim": "추론 비용의 병목은 연산이 아니라 메모리 대역폭", "stance": "신중 — 벤치마크 근거"},
           {"src": "뉴스 기사 (5건)", "tone": "blue",
            "claim": "HBM4 양산 시점이 올해 시장 점유율을 가른다", "stance": "낙관 — 업계 발표 인용"},
           {"src": "산업 보고서 (2건)", "tone": "green",
            "claim": "데이터센터 전력 규제가 칩 설계 방향을 바꾼다", "stance": "중립 — 정책 시나리오"}],
    note="출처마다 이해관계가 달라요 — 뉴스 5건 중 3건은 제조사 발표 기반이라 교차 확인을 권해요.",
    collection_id="chip",
)
RS_QA = dict(
    id="qa1",
    q="유모차, 신생아 때부터 쓰려면 뭘 봐야 하지?",
    a=("저장해두신 자료 기준으로는 <b>풀플랫(완전 평탄) 시트</b>와 <b>양대면 전환</b>이 신생아 필수 "
       "조건이에요. 안전 기준 글에서는 <b>KC 인증 + 5점식 벨트</b>를 최소선으로 꼽았고, 커뮤니티 "
       "메모에선 차 트렁크 크기를 먼저 재라는 조언이 가장 많았어요."),
    refs=[{"title": "신생아 유모차 안전 기준 정리", "part": "‘인증·벨트’ 단락"},
          {"title": "유모차 실사용 후기 모음 (메모)", "part": "휴대성 비교 표"}],
    collection_id="stroller",
)
RS_CHART = dict(
    id="ch1", title="경쟁사 A — 연 매출 추이와 내년 예측",
    asked="“지난 5년 매출로 성장률 차트 그리고, 내년 예측해줘”", unit="억원",
    bars=[{"y": "2021", "v": 120}, {"y": "2022", "v": 158}, {"y": "2023", "v": 214},
          {"y": "2024", "v": 252}, {"y": "2025", "v": 331}, {"y": "2026", "v": 412, "forecast": True}],
    insight=("최근 5년 연평균 성장률(CAGR)은 <b>28.9%</b> — 2024년에 살짝 둔화(+18%)했다가 2025년 "
             "신제품으로 반등했어요. 같은 흐름이면 내년 매출은 <b>약 412억(±9%)</b>으로 예측돼요."),
    caution="단순 추세 외삽이에요 — 신규 규제·대형 수주 같은 이벤트는 반영되지 않아요.",
)
RS_PROMPTS = [
    "최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘",
    "신생아 유모차 구매 관련해서 정보 모아줘",
    "경쟁사 A 매출 5년 추이 차트 그리고 내년 예측해줘",
]


def _seed_research(s: Session) -> None:
    for i, (cid, name, tone, n) in enumerate(RS_COLLECTIONS):
        s.add(ResearchCollection(id=cid, name=name, tone=tone, n=n, sort_order=i))
    for i, (sid, kind, title, frm, col, learned) in enumerate(RS_SOURCES):
        s.add(ResearchSource(id=sid, kind=SourceKind(kind), title=title,
                             from_label=frm, col=col, learned=learned, sort_order=i))
    s.add(ResearchReport(**RS_REPORT))
    s.add(ResearchQA(**RS_QA))
    s.add(ResearchChart(**RS_CHART))
    s.commit()
    # prompts 는 settings/상수 테이블 없이 응답 헬퍼에서 RS_PROMPTS 상수로 노출(or research_prompt 테이블)
    # RAG: learned=True 인 소스만 ingest (학습 완료 자료가 곧 위키)
    from .rag.pipeline import ingest_seed_sources
    ingest_seed_sources(s)
```

> **RAG 시드 ingest**: `learned=True`인 소스(s1~s4)만 청크/임베딩하여 `rag_chunk`에 적재한다(`s5`는 `learned=false` → 학습 중, Q&A 근거에서 제외 가능하나 데모 Q&A `qa1`의 근거는 유모차 안전 기준/후기 메모이므로 시드 청크에 포함). 근거 표시용 `part_label`은 `qa1.refs[].part`("‘인증·벨트’ 단락", "휴대성 비교 표")와 정확히 일치하도록 시드한다.

```python
# ---- 여행 (REF/assets/trip-data.js) ----
TRIP = dict(
    id="tp_busan", dday="D-4", title="부산 출장",
    dates="6월 16일(월) – 17일(화) · 1박 2일",
    purpose="부산 지사 · 분기 리포트 발표",
    brief="표·숙소·일정 정리는 끝났어요. 발표자료 최종본만 챙기면 돼요.",
    weather=[{"d": "16일", "t": "맑음 26°", "icon": "sun"},
             {"d": "17일", "t": "오후 비 40% · 24°", "icon": "rain"}],
    expense_budget=400000, expense_planned=331600,
    expense_note="법인카드 결제 영수증은 아리가 모아서 돌아온 다음 날 정산서 초안을 만들어둘게요.",
    expense_rows=[{"name": "KTX 왕복", "amt": "119,600", "state": "paid"},
                  {"name": "숙소 1박", "amt": "132,000", "state": "hold"},
                  {"name": "식비 · 이동 예상", "amt": "80,000", "state": "est"}],
)
TRIP_ROUTES = [
    ("rt_out", "out", "KTX 101", "서울역", "07:00", "부산역", "09:42", "4호차 4A · 창측", "승차권 발급 완료"),
    ("rt_back", "back", "KTX 158", "부산역", "18:00", "서울역", "20:40", "7호차 11C", "변경 가능 좌석"),
]
TRIP_STAY = ("st_busan", "스테이 부산역", "부산역 도보 5분 · 지사 도보 12분 · 체크인 15:00", "예약 확정")
TRIP_PREP = [
    ("KTX 왕복 예매 — 환승 없는 가장 빠른 편", "done"),
    ("숙소 예약 — 역·지사 모두 도보권", "done"),
    ("16일 오전 정기 회의 2건 → 수요일로 이동", "done"),
    ("‘발표자료 최종본’ 마감 작업 생성 (D-2)", "done"),
    ("법인카드 한도 확인 → 정산서 양식 준비", "doing"),
]
TRIP_DAYS = [
    ("d1", "16일 (월)", [
        {"t": "07:00", "kind": "move", "tone": "blue", "title": "KTX 서울 → 부산", "meta": "09:42 도착 · 4호차 4A · 발표 리허설 노트 준비됨"},
        {"t": "10:00", "kind": "meet", "tone": "violet", "title": "부산 지사 도착 · 팀 인사", "meta": "지사 4층 · 안내: 박지원 매니저"},
        {"t": "11:00", "kind": "talk", "tone": "coral", "hot": True, "title": "분기 리포트 발표", "meta": "대회의실 · 45분 · 발표자료 v3 연결됨"},
        {"t": "12:30", "kind": "meal", "tone": "green", "title": "점심 — 지사 팀과", "meta": "초량 밀면집 · 4명 예약됨"},
        {"t": "14:00", "kind": "meet", "tone": "violet", "title": "온보딩 개선 워크숍", "meta": "부산 CS팀 합류 · 90분 · 액션은 작업으로 자동 전송"},
        {"t": "18:30", "kind": "meal", "tone": "green", "title": "저녁 자유 시간", "meta": "아리 추천: 전포 카페거리 · 광안리 야경"},
    ]),
    ("d2", "17일 (화)", [
        {"t": "09:30", "kind": "meet", "tone": "violet", "title": "지사장 1:1", "meta": "지난 분기 협업 회고 노트 준비됨"},
        {"t": "11:00", "kind": "meet", "tone": "blue", "title": "발표 후속 Q&A 정리", "meta": "결정 사항·액션 아이템 → 작업으로 전송"},
        {"t": "13:00", "kind": "meal", "tone": "green", "title": "점심 후 자유 시간", "meta": "우산 챙기세요 — 오후 비 40%"},
        {"t": "15:30", "kind": "move", "tone": "blue", "title": "부산역으로 이동", "meta": "체크아웃 짐은 역 물품보관함 추천"},
        {"t": "18:00", "kind": "move", "tone": "blue", "title": "KTX 부산 → 서울", "meta": "20:40 도착 · 내일 아침 브리핑에 출장 요약"},
    ]),
]
TRIP_CHECK = [  # (id, group, text, auto)
    ("c1", "서류 · 결제", "신분증 · 법인카드", ""),
    ("c2", "서류 · 결제", "출장 신청서", "아리가 제출 완료"),
    ("c3", "발표", "노트북 · 충전기", ""),
    ("c4", "발표", "HDMI 어댑터", ""),
    ("c5", "발표", "발표자료 USB 백업", "클라우드 백업됨"),
    ("c6", "짐", "1박 짐 · 세면도구", ""),
    ("c7", "짐", "접이식 우산", "17일 비 예보로 추가"),
]
SAVED_TRIPS = [
    ("sv1", "제주 가족 여행", "가족 · 3박 4일", "7/24(금) 출발이 가장 한적하고 저렴해요.",
     "89,000원", "−12%", True, True, "김포 → 제주 · 성인 1인 최저가 · 목표가 8만원",
     [120, 118, 112, 108, 104, 98, 89]),
    ("sv2", "강릉 주말 휴식", "혼자 · 1박 2일", "바다뷰 숙소가 주말마다 빨리 차요. 2주 전 예약 추천.",
     "112,000원", "+4%", False, True, "KTX 왕복 + 1박 패키지 · 목표가 10만원",
     [98, 101, 104, 100, 106, 108, 112]),
    ("sv3", "도쿄 자유여행", "커플 · 4박 5일", "엔저가 이어지는 동안이 기회예요. 9월 초가 항공 최저.",
     "312,000원", "−7%", True, False, "인천 → 나리타 왕복 · 성인 1인 · 목표가 30만원",
     [360, 352, 348, 340, 330, 322, 312]),
]
# planner.results — examples 인덱스와 1:1 (제주=0, 도쿄=1). 원본 값 전체 이식(생략 없이).
TRIP_PLANS = [
    dict(id="plan_jeju", idx=0, example="다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원",
         title="제주 가족여행", meta="3박 4일 · 7월 24일(금) – 27일(월) · 성인 2 + 아동 1",
         summary=("성수기 직전 금요일 출발이 가장 한적하고 저렴해요. 렌터카 + 서귀포 중심 숙소 기준으로 "
                  "<b>예산 150만원 안에</b> 들어옵니다."),
         weather="여행 기간 대체로 맑음 · 한낮 29° · 26일 오후 소나기 가능",
         transport=[{"mode": "항공", "name": "김포 → 제주 (왕복)", "desc": "07:30 출발 · 1시간 5분 · 직항", "price": "356,000", "pick": True, "note": "3인 합계 · 최저가"},
                    {"mode": "렌터카", "name": "준중형 SUV · 3일", "desc": "공항 픽업 · 완전자차 포함", "price": "187,000", "pick": True}],
         stay=[{"name": "서귀포 오션 리조트", "desc": "중문 해변 도보 8분 · 조식 포함 · 평점 4.7", "price": "168,000", "unit": "/박", "pick": True},
               {"name": "제주시 시티 호텔", "desc": "공항 15분 · 동문시장 인근 · 평점 4.4", "price": "121,000", "unit": "/박"}],
         days=[{"tab": "1일차", "items": [{"t": "오전", "title": "김포 → 제주 · 렌터카 픽업", "tone": "blue"}, {"t": "점심", "title": "공항 근처 고기국수", "tone": "green"}, {"t": "오후", "title": "용두암 · 이호테우 해변 산책", "tone": "violet"}, {"t": "저녁", "title": "서귀포 숙소 체크인 · 중문 일몰", "tone": "coral"}]},
               {"tab": "2일차", "items": [{"t": "종일", "title": "한라산 영실 코스 (아이 동반 완만)", "tone": "green"}, {"t": "오후", "title": "카멜리아힐 정원", "tone": "violet"}, {"t": "저녁", "title": "흑돼지 거리", "tone": "coral"}]},
               {"tab": "3일차", "items": [{"t": "오전", "title": "아쿠아플라넷 (실내 · 비 와도 OK)", "tone": "blue"}, {"t": "오후", "title": "성산일출봉 · 우도 페리", "tone": "violet"}, {"t": "저녁", "title": "성산 해산물", "tone": "coral"}]},
               {"tab": "4일차", "items": [{"t": "오전", "title": "동문시장 기념품 · 렌터카 반납", "tone": "green"}, {"t": "오후", "title": "제주 → 김포", "tone": "blue"}]}],
         budget={"total": "1,287,000", "cap": "1,500,000",
                 "rows": [{"name": "항공 (3인 왕복)", "amt": "356,000"}, {"name": "숙소 (3박)", "amt": "504,000"}, {"name": "렌터카 (3일)", "amt": "187,000"}, {"name": "식비·입장료 예상", "amt": "240,000"}]},
         checklist=["신분증 · 항공권", "아이 상비약 · 자외선 차단제", "여벌 옷 · 우산", "카시트 (렌터카 옵션 신청 완료)"],
         sources=["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "기상청 중기예보", "여행 리뷰 240건"]),
    dict(id="plan_tokyo", idx=1, example="도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘",
         title="도쿄 출장", meta="2박 3일 · 9월 8일(월) – 10일(수) · 성인 1",
         summary=("시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. <b>엔저가 이어지는 동안</b>이 "
                  "항공·숙박 모두 유리합니다."),
         weather="초가을 · 한낮 28° · 9일 오후 비 50% (우산 권장)",
         transport=[{"mode": "항공", "name": "인천 → 하네다 (왕복)", "desc": "08:40 출발 · 2시간 20분 · 직항", "price": "287,000", "pick": True, "note": "공항이 시내와 가까워요"},
                    {"mode": "교통", "name": "스이카 충전 + 공항버스", "desc": "시부야까지 환승 없이", "price": "32,000", "pick": True}],
         stay=[{"name": "시부야 스테이션 호텔", "desc": "역 도보 4분 · 회의장 2정거장 · 평점 4.6", "price": "142,000", "unit": "/박", "pick": True},
               {"name": "신주쿠 비즈니스 호텔", "desc": "역 도보 6분 · 조식 포함 · 평점 4.3", "price": "118,000", "unit": "/박"}],
         days=[{"tab": "1일차", "items": [{"t": "오전", "title": "인천 → 하네다 · 호텔 체크인", "tone": "blue"}, {"t": "오후", "title": "지사 방문 · 인사 미팅", "tone": "violet"}, {"t": "저녁", "title": "시부야 거래처 회식", "tone": "coral"}]},
               {"tab": "2일차", "items": [{"t": "오전", "title": "분기 리뷰 발표 (핵심 일정)", "tone": "coral"}, {"t": "오후", "title": "워크숍 · 액션 정리 → 작업으로", "tone": "violet"}, {"t": "저녁", "title": "자유 시간 · 우산 챙기기", "tone": "green"}]},
               {"tab": "3일차", "items": [{"t": "오전", "title": "후속 미팅 · 체크아웃", "tone": "violet"}, {"t": "오후", "title": "하네다 → 인천", "tone": "blue"}]}],
         budget={"total": "713,000", "cap": "900,000",
                 "rows": [{"name": "항공 왕복", "amt": "287,000"}, {"name": "숙소 (2박)", "amt": "284,000"}, {"name": "현지 교통", "amt": "42,000"}, {"name": "식비 예상", "amt": "100,000"}]},
         checklist=["여권 (유효기간 6개월+)", "노트북 · 발표자료 백업", "해외로밍 / eSIM", "접이식 우산 · 명함"],
         sources=["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]),
]
PLANNER_RESEARCH = [  # 조사 진행 단계(애니메이션 + scripted 파이프라인 단계와 1:1)
    {"icon": "plane", "label": "항공·교통편 검색", "detail": "32개 노선 가격 비교"},
    {"icon": "bed", "label": "숙소 비교", "detail": "위치·평점·예산 교차 분석"},
    {"icon": "sun", "label": "날씨·시즌·혼잡도 확인", "detail": "기상 예보 + 성수기 캘린더"},
    {"icon": "pin", "label": "동선·맛집 정리", "detail": "리뷰 240건 요약"},
]


def _seed_trip(s: Session) -> None:
    s.add(Trip(**TRIP))
    for r in TRIP_ROUTES:
        rid, d, mode, fs, ft, ts, tt, seat, note = r
        s.add(TripRoute(id=rid, trip_id="tp_busan", dir=d, mode=mode,
                        from_st=fs, ft=ft, to_st=ts, tt=tt, seat=seat, note=note))
    sid, name, desc, conf = TRIP_STAY
    s.add(TripStay(id=sid, trip_id="tp_busan", name=name, desc=desc, conf=conf))
    for i, (text, state) in enumerate(TRIP_PREP):
        s.add(TripPrep(id=f"pp{i+1}", trip_id="tp_busan", text=text, state=state, sort_order=i))
    for i, (did, tab, items) in enumerate(TRIP_DAYS):
        s.add(TripDay(id=did, trip_id="tp_busan", tab=tab, items=items, sort_order=i))
    for i, (cid, grp, text, auto) in enumerate(TRIP_CHECK):
        s.add(TripChecklist(id=cid, trip_id="tp_busan", group=grp, text=text, auto=auto, sort_order=i))
    for i, row in enumerate(SAVED_TRIPS):
        sv_id, title, tag, note, nw, delta, down, watch, hint, spark = row
        s.add(SavedTrip(id=sv_id, title=title, tag=tag, note=note, now=nw, delta=delta,
                        down=down, watch=watch, hint=hint, spark=spark, sort_order=i))
    for p in TRIP_PLANS:
        s.add(TripPlan(**p))
    s.commit()
```

`_run()`(phase-2)에 통합 — reset 목록에 신규 테이블 추가:

```python
def _run(s: Session, reset: bool) -> None:
    if reset:
        for tbl in (..., RagChunk, TripPlan, SavedTrip, TripChecklist, TripDay,
                    TripPrep, TripStay, TripRoute, Trip,
                    ResearchChart, ResearchQA, ResearchReport, ResearchSource, ResearchCollection):
            for row in s.exec(select(tbl)).all():
                s.delete(row)
        s.commit()
    # ... 기존 MVP 시드 ...
    _seed_research(s)
    _seed_trip(s)
    s.commit()
```

---

## 4. 상세 구현 — 에이전트 오케스트레이션

`backend/app/agents/` — LLM provider 위의 멀티스텝 루프(post-mvp-overview 명명: `plan→act(tool)→observe→reflect`). **tool-capable 모델** 사용, 미지원/오프라인 시 **scripted 파이프라인** 폴백으로 데모 결정성을 보장한다.

### 4.1 Tool 인터페이스 (`agents/tools/base.py`)

```python
# backend/app/agents/tools/base.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any


@dataclass
class ToolResult:
    ok: bool
    data: Any = None
    error: str = ""
    meta: dict = field(default_factory=dict)   # {source_count, latency_ms, ...}


class Tool(ABC):
    name: str                     # "web_search" | "rag_query" | "http_fetch" | "task_create" | "calendar_write"
    description: str              # LLM tool-spec 용 한국어/영어 설명
    schema: dict                  # JSON Schema (인자)

    @abstractmethod
    def run(self, **kwargs) -> ToolResult: ...
```

### 4.2 web_search 툴 (`agents/tools/web_search.py`)

```python
# backend/app/agents/tools/web_search.py
import os
from .base import Tool, ToolResult
from ..connectors_bridge import get_knowledge_connector  # CONNECTOR_KNOWLEDGE 라우팅


class WebSearchTool(Tool):
    name = "web_search"
    description = "웹/논문/뉴스를 검색해 출처 객체 목록을 돌려준다."
    schema = {"type": "object", "properties": {
        "query": {"type": "string"}, "k": {"type": "integer", "default": 8}}}

    def run(self, query: str, k: int = 8) -> ToolResult:
        conn = get_knowledge_connector()           # mock(기본) | real(phase-13)
        try:
            hits = conn.search(query, k=k)          # [{title, url, kind, snippet, group}]
            return ToolResult(ok=True, data=hits, meta={"source_count": len(hits)})
        except Exception as e:
            return ToolResult(ok=False, error=str(e))
```

> `CONNECTOR_KNOWLEDGE=mock`(기본)이면 `MockSearchConnector`가 시드 기반 출처(`research-data.js`의 출처군 — 논문 3/뉴스 5/보고서 2)를 반환해 **오프라인에서도** 종합 리포트가 생성된다. phase-13이 `real`(예: Tavily/검색 API)을 같은 인터페이스로 끼운다. `WEB_SEARCH_PROVIDER`로 provider 세부 선택.

### 4.3 다른 툴

| 툴 | 파일 | 책임 | 폴백/stub |
|---|---|---|---|
| `rag_query` | `tools/rag_query.py` | `rag/pipeline.query(q,k)` 호출 → 청크+근거 | heuristic 코사인(임베딩 미가용) |
| `http_fetch` | `tools/http_fetch.py` | URL → 본문 텍스트(웹/PDF) | mock 모드는 시드 소스 본문 반환, 네트워크 미가용 시 빈 결과 |
| `task_create` | `tools/task_create.py` | 분류된 액션을 `task`로 실체화(federation) — phase-2 `POST /api/tasks` 동일 경로 | 항상 동작(로컬 DB) |
| `calendar_write` | `tools/calendar_write.py` | 여행/리서치 일정 → `event`(phase-8) 쓰기 | **stub**: 미구현 구간은 결과만 기록(phase-8 연동점) |

### 4.4 Agent 루프 (`agents/base.py`)

```python
# backend/app/agents/base.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from .tools.base import Tool, ToolResult


@dataclass
class AgentStep:
    phase: str                    # "plan" | "act" | "observe" | "reflect"
    tool: str = ""
    note: str = ""
    result: ToolResult | None = None


@dataclass
class AgentResult:
    ok: bool
    output: dict = field(default_factory=dict)      # 파이프라인 산출물(리포트/플랜)
    steps: list = field(default_factory=list)        # 관측 가능성: 단계 로그
    fallback: bool = False                            # scripted 폴백 사용 여부
    model: str = ""                                   # 사용 provider/model


class Agent:
    """plan→act(tool)→observe→reflect 멀티스텝 루프.

    tool-capable 모델이면 LLM이 tool call 을 발화 → 실행 → 관측 → 반성.
    미가용/예외 시 scripted 파이프라인으로 폴백(데모 결정성).
    """
    def __init__(self, tools: dict[str, Tool], provider, scripted: Callable, max_steps: int = 6):
        self.tools = tools
        self.provider = provider          # tool-capable LLMProvider (None 가능)
        self.scripted = scripted          # 결정적 폴백 함수(s, query)->AgentResult
        self.max_steps = max_steps

    def run(self, *, session, query: str) -> AgentResult:
        if self.provider is None or not self.provider.tool_capable():
            return self.scripted(session, query)     # 폴백
        try:
            return self._loop(session, query)
        except Exception:
            r = self.scripted(session, query)
            r.fallback = True
            return r

    def _loop(self, session, query: str) -> AgentResult:
        steps: list[AgentStep] = []
        plan = self.provider.plan(query, list(self.tools.values()))   # 계획 수립
        steps.append(AgentStep(phase="plan", note=plan.summary))
        scratch = {}
        for _ in range(self.max_steps):
            action = self.provider.next_action(query, plan, scratch)  # tool 선택
            if action.final:
                break
            tool = self.tools[action.tool]
            res = tool.run(**action.args)                              # act
            steps.append(AgentStep(phase="act", tool=tool.name, result=res))
            scratch[tool.name] = res.data                             # observe
            steps.append(AgentStep(phase="observe", tool=tool.name,
                                   note=f"{res.meta.get('source_count','')} hits"))
        output = self.provider.synthesize(query, scratch)             # reflect/synthesize
        steps.append(AgentStep(phase="reflect", note="synthesis done"))
        return AgentResult(ok=True, output=output, steps=steps,
                           model=self.provider.model)
```

### 4.5 리서치 에이전트 (`agents/research_agent.py`)

파이프라인: `web_search` + `rag_query` → **cross-analysis**(출처군별 주장/논조) → **synthesis**(종합 문단 + 편향 노트).

```python
# backend/app/agents/research_agent.py
from sqlmodel import Session, select
from ..models import ResearchReport, ResearchQA
from ..schemas import StartResearchOut, AskOut
from ..rag.pipeline import query as rag_query_fn
from .base import Agent
from .registry import get_agent_provider
from .tools.web_search import WebSearchTool
from .tools.rag_query import RagQueryTool
from . import scripted

SEED_QUERIES = {
    "최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘": "rp1",
    "최신 AI 반도체 시장 동향": "rp1",
}


def run_research(s: Session, query: str) -> StartResearchOut:
    """조사 시작. 데모: 시드 질의는 골든 리포트 즉시 반환, 그 외 queued."""
    q = query.strip()
    # 1) 시드(골든) 질의 → 즉시 완성 리포트(결정성)
    for key, rid in SEED_QUERIES.items():
        if q == key or q.startswith("최신 AI 반도체"):
            rp = s.get(ResearchReport, rid)
            return StartResearchOut(status="ready", report_id=rid,
                                    report=_report_out(rp), queued_text=None)
    # 2) 그 외 → 에이전트 파이프라인(tool-capable 또는 scripted)
    agent = Agent(tools={"web_search": WebSearchTool(), "rag_query": RagQueryTool(s)},
                  provider=get_agent_provider(),
                  scripted=scripted.research_pipeline)
    res = agent.run(session=s, query=q)
    if res.output.get("ready"):
        return StartResearchOut(status="ready", report=res.output["report"],
                                report_id=res.output.get("report_id"), queued_text=None)
    # 3) 길어지면 queued — "끝나면 알림으로 알려드릴게요"(원본 rs-queued 문구)
    return StartResearchOut(
        status="queued", report=None, report_id=None,
        queued_text=f"“{q}” — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요.")


def answer_question(s: Session, q: str, collection_id: str | None = None) -> AskOut:
    """지식 Q&A — RAG 검색 + 근거 답변."""
    qn = q.strip()
    # 시드 질문은 골든 답(결정성). refs 는 rag_query 가 찾은 청크의 part_label 로 채움.
    seed = s.exec(select(ResearchQA)).first()
    if seed and (qn == seed.q or "유모차" in qn):
        return AskOut(a=seed.a, refs=seed.refs, grounded=True, model="seed")
    hits = rag_query_fn(s, qn, k=4, collection_id=collection_id)   # [{text, source_id, part_label, score}]
    if not hits:
        return AskOut(a="저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요.",
                      refs=[], grounded=False, model="rag")
    prov = get_agent_provider()
    answer = (prov.answer_with_context(qn, hits) if prov else
              _extractive_answer(qn, hits))                          # 폴백: 발췌형 답
    refs = [{"title": h["title"], "part": h["part_label"]} for h in hits[:2]]
    return AskOut(a=answer, refs=refs, grounded=True,
                  model=(prov.model if prov else "extractive"))
```

### 4.6 여행 에이전트 (`agents/travel_agent.py`)

파이프라인: 검색 → **옵션(transport/stay)** · **일정(days)** · **경비(budget)** · **준비물(checklist)** 생성. 예시 제주·도쿄는 **완성 결과를 그대로 반환**(결정성), 자유 입력은 0번(제주)으로 폴백하되 `title`을 입력에서 잘라 덮어쓴다(원본 `pickResult` 로직 미러링).

```python
# backend/app/agents/travel_agent.py
from sqlmodel import Session, select
from ..models import TripPlan
from ..schemas import PlanResultOut
from .base import Agent
from .registry import get_agent_provider
from .tools.web_search import WebSearchTool
from . import scripted


def _pick_plan(s: Session, text: str) -> tuple[TripPlan, bool]:
    """원본 trip.jsx pickResult 미러:
       examples 와 정확히 일치하면 그 결과, 아니면 idx=0(제주) + title 덮어쓰기."""
    t = text.strip()
    plans = {p.example: p for p in s.exec(select(TripPlan)).all()}
    if t in plans:
        return plans[t], False                         # custom=False
    base = s.get(TripPlan, "plan_jeju")                # idx<0 폴백 → 0번
    return base, True                                   # custom=True


def run_planner(s: Session, text: str) -> PlanResultOut:
    plan, custom = _pick_plan(s, text)
    out = PlanResultOut.from_model(plan)
    if custom and text.strip():
        out.title = text.strip()[:18]                  # 원본: q.trim().slice(0,18)
        out.custom = True
    # tool-capable 모델이 켜져 있으면 검색 단계를 실제로 돌려 sources/가격을 갱신할 수 있으나,
    # 데모 결정성 우선 — 예시 2개는 항상 골든 결과. (research 단계 4종은 프론트 애니메이션이 표현)
    return out
```

### 4.7 scripted 폴백 (`agents/scripted.py`)

```python
# backend/app/agents/scripted.py
"""tool-capable 모델 미가용/오프라인/CI 용 결정적 파이프라인.

- research_pipeline: 시드 리포트 골든을 ready 로 반환. 시드 외 질의는 queued.
- (travel 은 travel_agent._pick_plan 이 직접 결정적 — 별도 scripted 불필요)
"""
from sqlmodel import Session
from ..models import ResearchReport
from .base import AgentResult


def research_pipeline(s: Session, query: str) -> AgentResult:
    rp = s.get(ResearchReport, "rp1")
    q = query.strip()
    if q.startswith("최신 AI 반도체"):
        return AgentResult(ok=True, fallback=True, model="scripted",
                           output={"ready": True, "report_id": "rp1",
                                   "report": _report_dict(rp)})
    # 그 외 — 결정적으로 'queued'(데모: 끝나면 알림)
    return AgentResult(ok=True, fallback=True, model="scripted", output={"ready": False})
```

### 4.8 provider registry (`agents/registry.py`)

```python
# backend/app/agents/registry.py
import os
from ..llm.provider import get_provider   # phase-2 LLMProvider


def get_agent_provider():
    """AGENT_PROVIDER = auto | tool | scripted.
       tool-capable 모델이 가용하고 reachable 이면 ToolCapableProvider, 아니면 None(→scripted)."""
    mode = os.getenv("AGENT_PROVIDER", "auto")
    if mode == "scripted":
        return None
    base = get_provider()                  # OllamaProvider | HeuristicProvider
    if base.health().get("reachable") and getattr(base, "tool_capable", lambda: False)():
        return base
    if mode == "tool":
        return base                        # 강제 — 단 미지원이면 Agent.run 이 예외→scripted
    return None                            # auto: 미가용이면 None → scripted 폴백
```

> `LLMProvider`에 `tool_capable() -> bool` 메서드를 추가한다(기본 `False`). `OllamaProvider`는 `/api/chat`의 tools 지원 모델일 때만 `True`. 이로써 **특정 모델 강제 금지**(env 주입) 규약을 유지하면서 tool 사용 여부만 능력 기반으로 판정한다.

---

## 5. 상세 구현 — RAG / 지식베이스

`backend/app/rag/` — `ingest(pdf/web/note)→chunk→embed→vector store→query`. 지식 Q&A가 **근거 링크와 함께** 답한다.

### 5.1 임베딩 추상화 (`rag/embed.py`)

```python
# backend/app/rag/embed.py
import os, math
from abc import ABC, abstractmethod
import httpx
from ..config import get_settings


class EmbeddingProvider(ABC):
    name: str
    dim: int
    @abstractmethod
    def embed(self, texts: list[str]) -> list[list[float]]: ...


class OllamaEmbedding(EmbeddingProvider):
    """Ollama embeddings API. EMBED_MODEL 주입(모델 비종속). EMBED_PROVIDER=ollama 일 때 선택."""
    def __init__(self):
        st = get_settings()
        self.host = st.ollama_host
        self.model = os.getenv("EMBED_MODEL", "nomic-embed-text")  # placeholder, env 교체
        self.name = f"ollama:{self.model}"
        self.dim = 0
    def embed(self, texts):
        out = []
        with httpx.Client(timeout=20.0) as c:
            for t in texts:
                r = c.post(f"{self.host}/api/embeddings",
                           json={"model": self.model, "prompt": t})
                r.raise_for_status()
                v = r.json()["embedding"]; self.dim = len(v); out.append(v)
        return out


class HeuristicEmbedding(EmbeddingProvider):
    """오프라인/CI 폴백 — TF 해시 임베딩(코사인 검색용). 결정적."""
    name = "heuristic-tfhash"
    def __init__(self, dim: int = 256): self.dim = dim
    def embed(self, texts):
        vecs = []
        for t in texts:
            v = [0.0] * self.dim
            for tok in _tokenize(t):
                v[hash(tok) % self.dim] += 1.0
            n = math.sqrt(sum(x * x for x in v)) or 1.0
            vecs.append([x / n for x in v])
        return vecs


def get_embedding_provider() -> EmbeddingProvider:
    provider = os.getenv("EMBED_PROVIDER", "ollama")   # ollama | heuristic
    if provider == "heuristic":
        return HeuristicEmbedding()
    try:
        p = OllamaEmbedding()
        p.embed(["헬스체크"])           # reachable 확인
        return p
    except Exception:
        return HeuristicEmbedding()     # 폴백


def _tokenize(t: str) -> list[str]:
    import re
    return re.findall(r"[가-힣]{2,}|[A-Za-z]{2,}|\d+", t.lower())
```

### 5.2 vector store + 파이프라인

```python
# backend/app/rag/store.py
import math
from sqlmodel import Session, select
from ..models import RagChunk


def cosine(a: list[float], b: list[float]) -> float:
    if not a or not b: return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a)) or 1.0
    nb = math.sqrt(sum(y * y for y in b)) or 1.0
    return dot / (na * nb)


def add_chunks(s: Session, chunks: list[RagChunk]) -> None:
    for c in chunks: s.add(c)
    s.commit()


def query_store(s: Session, qvec: list[float], k: int, source_ids: list[str] | None) -> list:
    rows = s.exec(select(RagChunk)).all()
    if source_ids:
        rows = [r for r in rows if r.source_id in source_ids]
    scored = [(cosine(qvec, r.embedding), r) for r in rows]
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[:k]
```

```python
# backend/app/rag/pipeline.py
from sqlmodel import Session, select
from ..models import RagChunk, ResearchSource
from .embed import get_embedding_provider
from .chunk import split_chunks
from .store import add_chunks, query_store

_SEED_BODIES = {   # 학습 자료 본문(데모 시드). 실제는 http_fetch/PDF 파서.
    "s5": [("신생아는 풀플랫(완전 평탄) 시트와 양대면 전환이 필수입니다. KC 인증과 5점식 벨트를 "
            "최소 기준으로 보세요.", "‘인증·벨트’ 단락")],
    "note_review": [("휴대성 비교 표: 차 트렁크 크기를 먼저 재라는 조언이 가장 많았습니다.", "휴대성 비교 표")],
    # s1~s4: 반도체 자료 본문(생략 없이 시드)…
}


def ingest_seed_sources(s: Session) -> None:
    emb = get_embedding_provider()
    for src in s.exec(select(ResearchSource)).all():
        bodies = _SEED_BODIES.get(src.id) or [(src.title, src.title)]
        texts = [b[0] for b in bodies]
        vecs = emb.embed(texts)
        chunks = [RagChunk(id=f"chk_{src.id}_{i}", source_id=src.id, seq=i,
                           text=texts[i], part_label=bodies[i][1],
                           embedding=vecs[i], dim=len(vecs[i]), model=emb.name)
                  for i in range(len(texts))]
        add_chunks(s, chunks)


def query(s: Session, q: str, k: int = 4, collection_id: str | None = None) -> list[dict]:
    emb = get_embedding_provider()
    qvec = emb.embed([q])[0]
    src_ids = None
    if collection_id:
        src_ids = [r.id for r in s.exec(
            select(ResearchSource).where(ResearchSource.col == collection_id)).all()]
    scored = query_store(s, qvec, k, src_ids)
    out = []
    for score, ch in scored:
        src = s.get(ResearchSource, ch.source_id)
        out.append({"text": ch.text, "source_id": ch.source_id,
                    "title": src.title if src else "", "part_label": ch.part_label,
                    "score": round(score, 4)})
    return out
```

> `chunk.py`의 `split_chunks(text, size=400, overlap=60)`는 한국어 문단/문장 경계 우선 분할(정규식). 데모 시드 본문은 이미 짧아 1청크.

---

## 6. 상세 구현 — 프론트엔드 (리서치)

원본 `research.jsx`는 **MVP 셸과 별도 스코프**(`research.jsx` 상단 `const { useState } = React; const R = window.ResearchData;`, 독립 `P` 아이콘 맵, 자체 Topbar). Next.js 이식 시 **공용 `Topbar`(13항목)**(phase-1)와 **자체 사이드바**(`subnav` + `rs-nav`)를 쓴다. 사이드바 4뷰 전환은 `view` 로컬 상태 + localStorage(`ariR.view`).

### 6.1 페이지 (`app/research/page.tsx`)

```tsx
// frontend/app/research/page.tsx
"use client";
import { useState, useEffect } from "react";
import { Topbar } from "@/components/Topbar";
import { ResearchSidebar } from "@/components/research/ResearchSidebar";
import { Composer } from "@/components/research/Composer";
import { HomeGrid } from "@/components/research/HomeGrid";
import { ReportCard } from "@/components/research/ReportCard";
import { QACard } from "@/components/research/QACard";
import { ChartCard } from "@/components/research/ChartCard";
import { useResearch } from "@/lib/hooks/useResearch";
import "@/styles/research.css";

const RS_HEAD = {
  home:   { h: "지능형 리서치 파트너", p: "찾고, 비교하고, 그려보는 일은 아리가 — 판단은 지우님이 하세요." },
  report: { h: "멀티소스 종합 리포트", p: "논문·뉴스·보고서를 모아 출처군별로 교차 분석해요." },
  qa:     { h: "지식 베이스 Q&A", p: "저장한 PDF·웹·메모를 학습해 근거와 함께 답해요." },
  chart:  { h: "시각화 & 추세 예측", p: "데이터를 그리고, 한계까지 짚어 예측해요." },
} as const;
type View = keyof typeof RS_HEAD;

export default function ResearchPage() {
  const [view, setView] = useState<View>("home");
  const { home, report, qa, chart, isLoading } = useResearch();
  useEffect(() => {
    const v = localStorage.getItem("ariR.view");
    if (v) setView(JSON.parse(v));
  }, []);
  useEffect(() => { localStorage.setItem("ariR.view", JSON.stringify(view)); }, [view]);

  return (
    <div className="tpage">
      <Topbar current="research" />
      <div className="twork">
        <ResearchSidebar view={view} onPick={setView}
          collections={home?.collections ?? []} sources={home?.sources ?? []} />
        <main className="rs-main">
          <div className="rs-head"><h1>{RS_HEAD[view].h}</h1><p>{RS_HEAD[view].p}</p></div>
          {view === "home" && (<><Composer prompts={home?.prompts ?? []} /><HomeGrid go={setView} home={home} /></>)}
          {view === "report" && <ReportCard report={report} />}
          {view === "qa" && <QACard qa={qa} />}
          {view === "chart" && <ChartCard chart={chart} />}
        </main>
      </div>
    </div>
  );
}
```

### 6.2 사이드바 4뷰 (원본 `RS_NAV` 그대로)

원본 `RS_NAV`(research.jsx 59~64행)의 라벨/서브/tone을 그대로 이식한다.

```tsx
// frontend/components/research/ResearchSidebar.tsx
const RS_NAV = [
  { id: "home",   icon: "spark", label: "새 조사",     sub: "무엇이든 맡기기" },
  { id: "report", icon: "globe", label: "종합 리포트", sub: "멀티소스 교차분석", tone: "blue" },
  { id: "qa",     icon: "brain", label: "지식 Q&A",    sub: "내 자료에 질문",   tone: "green" },
  { id: "chart",  icon: "chart", label: "시각화·예측", sub: "추세 그리기",      tone: "coral" },
] as const;
const KIND = { pdf: { icon: "file", label: "PDF" }, web: { icon: "globe", label: "웹" }, note: { icon: "pen", label: "메모" } };
// 컬렉션 dot 색: style={{ background: `var(--${c.tone})` }}
// 학습 완료: <span className="rs-learned"><Icon name="tick" w={2.8}/></span>
// 학습 중: <span className="rs-learning" />  (amber blink 애니메이션 — 원본 rs-blink)
// 하단 힌트: "저장하면 아리가 내용을 학습해요 — 북마크가 아니라 답을 주는 자료가 돼요."
```

### 6.3 Composer (조사 명령 — 원본 그대로)

`rs-composer` — input + `조사 시작` 버튼(`rs-go`, `Icon name="send"`) + 예시 칩(`R.prompts`) + **queued 배너**(`rs-queued`, `rs-qpulse` 점멸). placeholder: `"무엇이든 조사를 맡겨보세요 — 전문 분야가 아니어도 괜찮아요"`. queued 문구는 백엔드 `POST /api/research/start`의 `queued_text`를 사용한다(원본 하드코딩 문구와 동일).

```tsx
// 핵심: go() → POST /research/start → status==="ready"면 view="report"로,
//             status==="queued"면 rs-queued 배너 표시("끝나면 알림으로 알려드릴게요").
```

### 6.4 ReportCard / QACard / ChartCard

- **ReportCard**(`rs-report`): `rs-ci tone-blue`(globe) + 제목 + `rs-counts`(논문/뉴스/보고서 dot pill) + `rs-synth`(HTML `dangerouslySetInnerHTML`, 새니타이즈) + **교차분석표**(`rs-cross`: head `출처군/핵심 주장/논조` + rows, grid `150px 1fr 170px`) + `rs-note`(shield 아이콘, amber).
- **QACard**: `rs-q`(msg 아이콘, 질문) + `rs-a`(HTML 답) + `rs-refs`(dashed 버튼, link 아이콘 + 제목 + part). 근거는 백엔드 `refs`(title/part).
- **ChartCard**: `rs-chart` 막대(높이 `(v/max)*100%`) + **forecast 막대**(점선 `repeating-linear-gradient`, `rs-btag` "예측" pill) + `rs-insight`(HTML) + `rs-note`(clock, "단순 추세 외삽").

```tsx
// ChartCard 막대 — 원본 research.jsx 167~195행 그대로
const max = Math.max(...chart.bars.map(b => b.v));
chart.bars.map((b, i) => (
  <div className={"rs-bar" + (b.forecast ? " forecast" : "")} key={i}>
    <span className="rs-bv mono">{b.v}</span>
    <div className="rs-bcol" style={{ height: Math.round((b.v/max)*100) + "%" }}>
      {b.forecast && <span className="rs-btag">예측</span>}
    </div>
    <span className="rs-by">{b.y}</span>
  </div>
));
```

---

## 7. 상세 구현 — 프론트엔드 (여행)

원본 `trip.jsx`는 **공용 셸 사용**(`window.AriShell.Icon/Topbar/SubRail`). Next.js 이식은 phase-1의 `Icon/Topbar/SubRail`을 그대로 쓰고, 페이지 전용 아이콘(`train/bed/food/pin/rain/plane/star/down/up2`)은 `Icon` 맵에 병합하거나 로컬 `TI` 컴포넌트로 둔다(원본 `TP_P`).

### 7.1 페이지 (`app/trip/page.tsx`) — SubRail 3뷰

```tsx
// frontend/app/trip/page.tsx
"use client";
import { useState, useEffect } from "react";
import { Topbar, SubRail, Icon } from "@/components/shell";
import { UpcomingView } from "@/components/trip/UpcomingView";
import { PlanView } from "@/components/trip/PlanView";
import { SavedView } from "@/components/trip/SavedView";
import { useTrip } from "@/lib/hooks/useTrip";
import "@/styles/trip.css";

const TP_RAIL = [
  { id: "upcoming", icon: "pin",   label: "다가오는 출장" },
  { id: "plan",     icon: "plus",  label: "새 여행 계획" },
  { sep: true },
  { id: "saved",    icon: "heart", label: "찜 · 가격 추적" },
];
const TP_HEAD = {
  upcoming: { title: "여행",        em: "준비는 아리가 해둘게요" },
  plan:     { title: "새 여행 계획", em: "말하면 아리가 다 조사해요" },
  saved:    { title: "찜한 여행",    em: "가격이 내리면 알려드려요" },
};

export default function TripPage() {
  const [view, setView] = useState<"upcoming"|"plan"|"saved">("upcoming");
  const [day, setDay] = useState("d1");
  const [done, setDone] = useState<Record<string, boolean>>({});
  const [toast, setToast] = useState<string | null>(null);
  const { upcoming } = useTrip();

  // 체크 상태 localStorage (원본 TKEY="ariTp.check")
  useEffect(() => {
    try { setDone(JSON.parse(localStorage.getItem("ariTp.check") || "{}")); } catch {}
  }, []);
  const flip = (id: string) => {
    const n = { ...done, [id]: !done[id] };
    setDone(n); localStorage.setItem("ariTp.check", JSON.stringify(n));
  };
  const allItems = (upcoming?.check ?? []).flatMap(g => g.items);
  const doneN = allItems.filter(it => done[it.id]).length;
  const h = TP_HEAD[view];

  return (
    <div className="dash" data-screen-label="여행">
      <Topbar current="trip" />
      <div className="pagehead">
        <div>
          <div className="ph-eyebrow">
            <span>다음 일정 {upcoming?.trip.dday} · {upcoming?.trip.title}</span>
            <span className="sep" />
            <span>준비물 {doneN}/{allItems.length} 챙김</span>
          </div>
          <h1 className="ph-title">{h.title} <em>{h.em}</em></h1>
        </div>
      </div>
      <div className="work">
        <SubRail items={TP_RAIL} active={view} onPick={setView} />
        {view === "upcoming" && <UpcomingView day={day} setDay={setDay} done={done} flip={flip} doneN={doneN} allN={allItems.length} data={upcoming} />}
        {view === "plan" && <PlanView onSave={(r) => { setToast(`‘${r.title}’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.`); setTimeout(() => setToast(null), 3600); }} />}
        {view === "saved" && <SavedView />}
      </div>
      {toast && <div className="au-toast"><Icon name="tick" />{toast}</div>}
    </div>
  );
}
```

### 7.2 PlanView — input → research(애니메이션) → result

원본 `PlanView`(trip.jsx 206~412행)의 3-phase 상태머신을 그대로 이식한다. 핵심은 **조사 진행 애니메이션**(setTimeout 650ms 간격으로 step 증가, 마지막+1에서 result)과 **예시 결정성**.

```tsx
// frontend/components/trip/PlanView.tsx (핵심)
const [phase, setPhase] = useState<"input"|"research"|"result">("input");
const [step, setStep] = useState(0);
const [result, setResult] = useState<PlanResult | null>(null);

const run = async () => {
  if (!text.trim()) return;
  // 1) 백엔드 결정성 결과 미리 받아둠 (예시 2개 골든, 자유입력은 제주 폴백+title)
  const r = await api.post("/trip/plan", { text });   // PlanResultOut
  setResult(r); setPhase("research"); setStep(0);
  // 2) 조사 진행 애니메이션 — PLANNER_RESEARCH 4단계 650ms 간격 (원본 타이밍)
  const research = r.research ?? PLANNER_RESEARCH;     // [{icon,label,detail}]
  research.forEach((_, i) => setTimeout(() => setStep(i + 1), 650 * (i + 1)));
  setTimeout(() => setPhase("result"), 650 * (research.length + 1));
};
```

> **결정성 핵심**: 원본은 프론트(`pickResult`)가 결과를 골랐지만, 이식본은 **백엔드 `/trip/plan`이 결정**(예시 정확 일치 → 해당 plan, 자유 입력 → 제주 plan + `title` 18자 잘라 덮어쓰기, `custom:true`). 애니메이션은 순수 UI 표현이므로 결과 자체는 입력→출력이 1:1 결정적이다. `prefers-reduced-motion`이면 애니메이션 생략하고 즉시 result(원본 `rs-blink`/`pl-spin` 패턴 존중).

result 화면 구성(원본 그대로):
- 히어로(`hero`): `아리의 추천 계획` + `다시 짜기`(`pl-redo`, swap) + 제목/meta + summary(HTML) + 날씨 태그 + **액션**(`pl-save` "이 계획으로 만들기"→저장 시 "내 여행에 추가됨" green / `pl-ghost` "계획 공유") + `pl-src`("…를 종합했어요", `sources.join(" · ")`).
- 이동·숙소(`opt-row`, pick=추천 강조 `opt-pick`).
- 일정 초안(`tp-tabs` 일차 탭 + `sched` `ev`).
- 예상 경비(`fin-big`/`fin-bar`/`fin-range` + `xp-rows`).
- 준비물(`pp-list` tick rows).

### 7.3 UpcomingView / SavedView

- **UpcomingView**: 히어로(`tp-head` 제목 "부산 출장" + dates + brief + `tp-tags` 날씨 + `tp-route` `Leg` 2개 + `tp-stay`) → 아리가 미리 해둔 일(`pp-list`, doing=clock/amber, done=tick/green, 하단 "예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.") → 체크리스트(`tp-gp` 그룹 + `task` 클릭 토글, `tp-auto` 자동 라벨, count `doneN/allN`) → 일정표(`tp-tabs` 16일/17일 + `KindIcon` + `핵심` 뱃지) → 경비(`fin-big` `(planned/10000).toFixed(1)만`, `fin-bar` width `pct%`, `xp-row` state paid/hold/est).
- **SavedView**: `saved` 각 카드 — `sv-note` + `sv-price`(`nx-price mono` + `sv-delta` down=green/up=coral) + `nx-hint` + **`Spark`**(SVG polyline, down=green/up=coral) + `nx-watch`(bell + 토글 `sw`). 토글은 `POST /trip/saved/{id}/watch`로 영속.

```tsx
// Spark — 원본 trip.jsx 54~70행 그대로 (viewBox 100x48, 마지막 점 circle)
function Spark({ vals, color }: { vals: number[]; color: string }) {
  const w = 100, h = 48, min = Math.min(...vals), max = Math.max(...vals);
  const span = max - min || 1;
  const pts = vals.map((v, i) => {
    const x = (i / (vals.length - 1)) * w;
    const y = h - 6 - ((v - min) / span) * (h - 12);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(" ");
  const [lx, ly] = pts.split(" ").pop()!.split(",");
  return (
    <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
      <polyline points={pts} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <circle cx={lx} cy={ly} r="3" fill={color} />
    </svg>
  );
}
```

---

## 8. 데이터 / 타입 / API 계약

### 8.1 엔드포인트 표 (prefix `/api`)

| 메서드 | 경로 | 용도 | 응답 |
|---|---|---|---|
| GET | `/api/research/home` | 사이드바 + 입구 카드 요약 | `{collections[], sources[], prompts[], entries[]}` |
| GET | `/api/research/report` | 종합 리포트 | `ReportOut` |
| GET | `/api/research/qa` | 지식 Q&A 시드(데모 질문/답) | `QAOut` |
| GET | `/api/research/chart` | 시각화·예측 차트 | `ChartOut` |
| POST | `/api/research/start` | 자연어 조사 시작(에이전트) | `StartResearchOut` |
| POST | `/api/research/ask` | 지식 Q&A 질의(RAG) | `AskOut` |
| GET | `/api/trip/upcoming` | 다가오는 출장 전체 | `UpcomingTripOut` |
| GET | `/api/trip/saved` | 찜·가격추적 목록 | `SavedTripOut[]` |
| POST | `/api/trip/saved/{id}/watch` | 가격 알림 토글 | `SavedTripOut` |
| POST | `/api/trip/plan` | AI 플래너 실행 | `PlanResultOut` |

### 8.2 요청/응답 JSON 예시

`GET /api/research/report` (원본 `report` 시드 1:1)
```json
{
  "id": "rp1",
  "title": "최신 AI 반도체 시장 동향",
  "asked": "“보고서, 논문, 뉴스 기사를 종합해서 알려줘”",
  "meta": "출처 10곳 수집 · 3분 전 완성",
  "counts": [
    { "k": "논문", "n": 3, "tone": "violet" },
    { "k": "뉴스", "n": 5, "tone": "blue" },
    { "k": "보고서·블로그", "n": 2, "tone": "green" }
  ],
  "synthesis": "세 출처군 모두 <b>추론(inference) 수요로의 무게 이동</b>에는 동의해요. …",
  "cross": [
    { "src": "학술 논문 (3편)", "tone": "violet", "claim": "추론 비용의 병목은 연산이 아니라 메모리 대역폭", "stance": "신중 — 벤치마크 근거" },
    { "src": "뉴스 기사 (5건)", "tone": "blue", "claim": "HBM4 양산 시점이 올해 시장 점유율을 가른다", "stance": "낙관 — 업계 발표 인용" },
    { "src": "산업 보고서 (2건)", "tone": "green", "claim": "데이터센터 전력 규제가 칩 설계 방향을 바꾼다", "stance": "중립 — 정책 시나리오" }
  ],
  "note": "출처마다 이해관계가 달라요 — 뉴스 5건 중 3건은 제조사 발표 기반이라 교차 확인을 권해요."
}
```

`POST /api/research/start`
```json
// 요청 (시드 질의)
{ "query": "최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘" }
// 응답 (ready — 골든 리포트 즉시)
{ "status": "ready", "report_id": "rp1", "report": { /* ReportOut */ }, "queued_text": null }

// 요청 (시드 외 자유 질의)
{ "query": "전고체 배터리 상용화 시점 정리해줘" }
// 응답 (queued — 끝나면 알림)
{ "status": "queued", "report_id": null, "report": null,
  "queued_text": "“전고체 배터리 상용화 시점 정리해줘” — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요." }
```

`POST /api/research/ask`
```json
// 요청
{ "q": "유모차, 신생아 때부터 쓰려면 뭘 봐야 하지?", "collection_id": "stroller" }
// 응답 (RAG — 근거 함께)
{
  "a": "저장해두신 자료 기준으로는 <b>풀플랫(완전 평탄) 시트</b>와 <b>양대면 전환</b>이 신생아 필수 조건이에요. …",
  "refs": [
    { "title": "신생아 유모차 안전 기준 정리", "part": "‘인증·벨트’ 단락" },
    { "title": "유모차 실사용 후기 모음 (메모)", "part": "휴대성 비교 표" }
  ],
  "grounded": true,
  "model": "seed"
}
```

`POST /api/trip/plan`
```json
// 요청 (예시 정확 일치 → 골든 결정성)
{ "text": "도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘" }
// 응답 (PlanResultOut — 원본 plan_tokyo 1:1)
{
  "id": "plan_tokyo", "idx": 1, "custom": false,
  "title": "도쿄 출장", "meta": "2박 3일 · 9월 8일(월) – 10일(수) · 성인 1",
  "summary": "시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. <b>엔저가 이어지는 동안</b>이 항공·숙박 모두 유리합니다.",
  "weather": "초가을 · 한낮 28° · 9일 오후 비 50% (우산 권장)",
  "transport": [
    { "mode": "항공", "name": "인천 → 하네다 (왕복)", "desc": "08:40 출발 · 2시간 20분 · 직항", "price": "287,000", "pick": true, "note": "공항이 시내와 가까워요" },
    { "mode": "교통", "name": "스이카 충전 + 공항버스", "desc": "시부야까지 환승 없이", "price": "32,000", "pick": true }
  ],
  "stay": [
    { "name": "시부야 스테이션 호텔", "desc": "역 도보 4분 · 회의장 2정거장 · 평점 4.6", "price": "142,000", "unit": "/박", "pick": true },
    { "name": "신주쿠 비즈니스 호텔", "desc": "역 도보 6분 · 조식 포함 · 평점 4.3", "price": "118,000", "unit": "/박" }
  ],
  "days": [ { "tab": "1일차", "items": [ { "t": "오전", "title": "인천 → 하네다 · 호텔 체크인", "tone": "blue" } ] } ],
  "budget": { "total": "713,000", "cap": "900,000", "rows": [ { "name": "항공 왕복", "amt": "287,000" } ] },
  "checklist": ["여권 (유효기간 6개월+)", "노트북 · 발표자료 백업", "해외로밍 / eSIM", "접이식 우산 · 명함"],
  "sources": ["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]
}
```

`GET /api/trip/upcoming` (요약 — 전체는 route/stay/prep/days/checklist/expense 포함)
```json
{
  "trip": { "id": "tp_busan", "dday": "D-4", "title": "부산 출장",
            "dates": "6월 16일(월) – 17일(화) · 1박 2일",
            "purpose": "부산 지사 · 분기 리포트 발표",
            "brief": "표·숙소·일정 정리는 끝났어요. 발표자료 최종본만 챙기면 돼요.",
            "weather": [ { "d": "16일", "t": "맑음 26°", "icon": "sun" }, { "d": "17일", "t": "오후 비 40% · 24°", "icon": "rain" } ] },
  "route": { "out": { "mode": "KTX 101", "from": "서울역", "ft": "07:00", "to": "부산역", "tt": "09:42", "seat": "4호차 4A · 창측", "note": "승차권 발급 완료" },
             "back": { "mode": "KTX 158", "from": "부산역", "ft": "18:00", "to": "서울역", "tt": "20:40", "seat": "7호차 11C", "note": "변경 가능 좌석" } },
  "stay": { "name": "스테이 부산역", "desc": "부산역 도보 5분 · 지사 도보 12분 · 체크인 15:00", "conf": "예약 확정" },
  "prep": [ { "text": "KTX 왕복 예매 — 환승 없는 가장 빠른 편", "state": "done" }, { "text": "법인카드 한도 확인 → 정산서 양식 준비", "state": "doing" } ],
  "days": [ { "id": "d1", "tab": "16일 (월)", "items": [ { "t": "11:00", "kind": "talk", "tone": "coral", "hot": true, "title": "분기 리포트 발표", "meta": "대회의실 · 45분 · 발표자료 v3 연결됨" } ] } ],
  "check": [ { "name": "서류 · 결제", "items": [ { "id": "c1", "text": "신분증 · 법인카드" }, { "id": "c2", "text": "출장 신청서", "auto": "아리가 제출 완료" } ] } ],
  "expense": { "budget": 400000, "planned": 331600,
               "rows": [ { "name": "KTX 왕복", "amt": "119,600", "state": "paid" }, { "name": "숙소 1박", "amt": "132,000", "state": "hold" }, { "name": "식비 · 이동 예상", "amt": "80,000", "state": "est" } ],
               "note": "법인카드 결제 영수증은 아리가 모아서 돌아온 다음 날 정산서 초안을 만들어둘게요." }
}
```

> 응답 형태는 **원본 `T.route`/`T.check` 구조**(중첩 `{out,back}`, `check[].items[]`)를 그대로 미러링해 프론트(`UpcomingView`)가 변환 없이 소비한다. `source.from`만 alias `from`으로 직렬화(모델은 `from_label`).

### 8.3 Pydantic 스키마(핵심)

```python
# backend/app/schemas.py  (phase-10 추가분, 발췌)
from pydantic import BaseModel, Field as PField

class StartResearchRequest(BaseModel):
    query: str

class StartResearchOut(BaseModel):
    status: str                         # "ready" | "queued"
    report_id: str | None = None
    report: "ReportOut | None" = None
    queued_text: str | None = None

class AskRequest(BaseModel):
    q: str
    collection_id: str | None = None

class AskOut(BaseModel):
    a: str
    refs: list[dict] = []               # [{title, part}]
    grounded: bool = True
    model: str = ""

class SourceOut(BaseModel):
    id: str; kind: str; title: str
    from_: str = PField(alias="from")   # 직렬화 키 "from"
    col: str | None = None; learned: bool = False
    model_config = {"populate_by_name": True}

class PlanRequest(BaseModel):
    text: str

class PlanResultOut(BaseModel):
    id: str; idx: int; custom: bool = False
    title: str; meta: str; summary: str; weather: str
    transport: list[dict]; stay: list[dict]; days: list[dict]
    budget: dict; checklist: list[str]; sources: list[str]
    research: list[dict] | None = None  # 조사 진행 단계(프론트 애니메이션)

class WatchToggleRequest(BaseModel):
    watch: bool | None = None           # None 이면 토글
```

> `frontend/lib/types.ts`는 위와 1:1(snake_case 유지, `from`은 직렬화 키). phase-2 §3.2의 types.ts 대응 규약을 따른다.

---

## 9. 디자인 충실도 노트

> 원본 토큰/레이아웃/문구를 **그대로** 인용. REF 경로 명시.

### 9.1 리서치 (REF/assets/research.css, research.jsx)

| 요소 | 원본 값 | REF |
|---|---|---|
| 사이드바 뷰 행 | `.rs-navrow` padding `9px 10px`, radius `11px`, on=`var(--fill)` | research.css 6~12 |
| 뷰 아이콘 tone | blue `color-mix(in oklab, var(--blue) 15%, transparent)`, green, coral | research.css 19~21 |
| 입구 카드 | `.rs-entry` radius `var(--radius)`(22px), hover `translateY(-2px)` | research.css 30~51 |
| 컴포저 박스 | `.rs-cbox` padding `8px 9px 8px 16px`, radius `16px`, spark `var(--coral)` | research.css 91~97 |
| `조사 시작` 버튼 | `.rs-go` `var(--fill)`/`var(--on-fill)`, radius `11px` | research.css 100~107 |
| queued 배너 | `.rs-queued` 테두리 `color-mix(..., var(--blue) 30%)`, pulse blue 점멸 `rs-blink 1.3s` | research.css 115~124 |
| 카드 | `.rs-card` glass + radius `var(--radius)`, padding `20px 22px` | research.css 127~131 |
| counts pill | `.rs-count` `var(--card-2)`, dot 7px | research.css 143~144 |
| 교차분석표 | `.rs-cross` border radius `13px`; head/row grid `150px 1fr 170px` gap `14px` | research.css 150~157 |
| 주의 노트 | `.rs-note` 아이콘 `var(--amber)` (shield/clock) | research.css 159~160 |
| Q&A 질문 | `.rs-q` `var(--card-2)`, msg 아이콘 `var(--green)` | research.css 163~164 |
| 근거 버튼 | `.rs-ref` dashed `var(--line-2)`, hover green solid | research.css 168~176 |
| 차트 막대 | `.rs-bcol` gradient coral 78%→48%, radius `9px 9px 4px 4px`, max-width `52px` | research.css 183~187 |
| 예측 막대 | `.rs-bar.forecast .rs-bcol` `repeating-linear-gradient(-45deg, coral 38%…14%)` + dashed; `.rs-btag` `var(--coral)` "예측" pill | research.css 188~198 |
| 진입 애니메이션 | `.entered .rs-main > *` `rs-rise 0.45s`, nth-child delay 0.04/0.08/0.12s | research.css 204~208 |
| 헤더 | `.rs-head h1` `var(--font-disp)` 26px 800 letter-spacing `-0.03em` | research.css 86~87 |

문구 인용(그대로): 사이드바 4뷰 라벨/서브(`새 조사`/`무엇이든 맡기기`, `종합 리포트`/`멀티소스 교차분석`, `지식 Q&A`/`내 자료에 질문`, `시각화·예측`/`추세 그리기`), 헤더(`RS_HEAD`), 컴포저 placeholder, 하단 힌트(`저장하면 아리가 내용을 학습해요 — 북마크가 아니라 답을 주는 자료가 돼요.`).

### 9.2 여행 (REF/assets/trip.css, trip.jsx)

| 요소 | 원본 값 | REF |
|---|---|---|
| 토글 스위치 | `.sw` 42x24, on=`var(--green)`, knob `translateX(18px)` | trip.css 6~22 |
| 히어로 제목 | `.tp-title` `var(--font-disp)` 26px 700 letter-spacing `-0.02em` | trip.css 26 |
| 날씨 태그 | `.tp-tag.wx .ic` `var(--amber)` | trip.css 36 |
| 이동 구간 | `.tp-leg` glass-2, `.tp-mode` `var(--blue)` pill #fff, `.tp-line` 2px dashed | trip.css 38~59 |
| 숙소 | `.tp-stay .st-ic` `var(--violet)` 14% bg, `.st-conf` `var(--green)` "예약 확정" | trip.css 60~81 |
| 미리 해둔 일 | `.pp-row` border-top `var(--line)`, doing=`var(--amber)`, done=`var(--green)` | trip.css 84~101 |
| 일정 탭 | `.tp-tab.on` `var(--fill)`/`var(--on-fill)` | trip.css 108~116 |
| 자동 라벨 | `.tp-auto` `var(--violet)` 12% pill | trip.css 133~140 |
| 경비 상태 | `.xp-state.paid` green, `.hold` amber | trip.css 151~158 |
| 가격 | `.nx-price` `var(--font-disp)` 28px; `.sv-delta.down` green / `.up` coral | trip.css 162~163, 309~315 |
| 플래너 캡처 그리드 | `.pl-cap-ic` `var(--coral)` 13% | trip.css 177~194 |
| 조사 진행 | `.pl-step.now` border coral, `.pl-step.done .pl-step-ic` `var(--green)`; `.pl-spin` 0.7s linear | trip.css 196~225 |
| 결과 액션 | `.pl-save` `var(--fill)`→done `var(--green)`; `.pl-ghost` glass-2 | trip.css 238~258 |
| 옵션 추천 | `.opt-row.pick` coral 11% gradient + `.opt-pick` coral #fff pill | trip.css 272~297 |
| 토스트 | `.au-toast` `var(--fill)`, tick `var(--lime)`, `toastin 0.3s` | trip.css 318~328 |

문구 인용(그대로): `SubRail` 라벨(`다가오는 출장`/`새 여행 계획`/`찜 · 가격 추적`), `TP_HEAD` em(`준비는 아리가 해둘게요` 등), `아리가 미리 해둔 일`, `예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.`, 플래너 placeholder(`예: 다음 달 말 제주 가족여행 3박 4일, 예산 150만원`), 토스트(`‘…’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.`).

### 9.3 KindIcon / 페이지 전용 아이콘

원본 `TP_KIND`(trip.jsx 31~40): `move`=로컬 `train`, `meet`=공용 `users`, `talk`=공용 `mic`, `meal`=로컬 `food`. 페이지 전용 path(`TP_P`: train/bed/food/pin/rain/plane/star/down/up2/sun)는 phase-1 `Icon` 중앙 맵에 병합하거나 로컬 `TI`로 둔다(누락 시 렌더 깨짐 — overview §11.5 가드).

---

## 10. 상태 처리 & 엣지 케이스

overview §15 전역 원칙 + 본 페이지 특수 상태.

| 상태 | 처리 |
|---|---|
| **로딩** | 글래스 스켈레톤(카드 형태 유지). `useResearch`/`useTrip`의 `isLoading`. |
| **조사 진행(streaming)** | 리서치: `rs-queued` 배너(blue pulse) 또는 향후 SSE 스트리밍. 여행: `pl-prog` 4단계(650ms 간격) + `pl-spin`. `prefers-reduced-motion`이면 애니메이션 생략, 즉시 결과. |
| **타임아웃** | 에이전트 루프 `max_steps`/툴 `LLM_TIMEOUT` 초과 → scripted 폴백 또는 `queued`("끝나면 알림"). 사용자에게 에러 대신 "조사 중" 유지. |
| **에이전트 폴백** | tool-capable 미가용/예외 → `Agent.run`이 `scripted`로 폴백, `AgentResult.fallback=true`, `model="scripted"`. UI는 동일(데모 결정성). |
| **RAG 근거 없음** | `AskOut.grounded=false` + "저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요." 안내. `refs=[]`. |
| **학습 중 자료** | `source.learned=false`(s5) → 사이드바 `rs-learning`(amber blink), Q&A 근거 후보에서 가중치 하향 또는 제외 가능. |
| **임베딩 미가용** | `HeuristicEmbedding`(TF 해시) 폴백 — 오프라인/CI에서도 코사인 검색 동작(결정적). |
| **플래너 자유 입력** | examples 불일치 → 제주(idx 0) 폴백 + `title` 18자 잘라 덮어쓰기 + `custom:true`(원본 `pickResult`). |
| **체크리스트 영속** | `localStorage["ariTp.check"]` — 서버 미저장(원본과 동일). 초기화/충돌 시 `{}` 폴백. |
| **HTML 시드** | `synthesis`/`a`/`summary`/`insight`는 `<b>` 포함 HTML — 신뢰 시드지만 렌더 전 새니타이즈(overview §15, DOMPurify). |
| **빈(empty)** | 컬렉션/소스 없음: "저장하면 아리가 학습해요" 힌트. 찜 없음: "찜한 여행이 아직 없어요" 안내. |
| **에러** | `lib/api.ts` 비2xx throw → 카드 단위 에러 + 재시도. 셸(Topbar/테마) 유지. |
| **반응형** | 리서치 `@media(max-width:1080px)` `.subnav` 숨김; 760px `.rs-cross` 1열·`.rs-counts` 숨김. 여행 720px `.pl-grid` 1열(원본 미디어쿼리 그대로). |

---

## 11. 연합 이벤트 (발행/구독)

post-mvp-overview의 `event_bus`(phase-7) 이벤트 모델을 사용. 본 페이지가 발행/구독하는 이벤트:

### 11.1 발행(publish)

| 이벤트 | 발행 시점 | 페이로드 | 구독자 |
|---|---|---|---|
| `research.completed` | 조사 종합 리포트 완성(`/research/start` ready 또는 worker 완료) | `{report_id, title, source_count}` | 알림(phase-9, "리서치에 정리해둘게요"), 라이프 지식(phase-11) |
| `knowledge.ingested` | 자료 학습(RAG ingest) 완료 | `{source_id, collection_id, chunks}` | 리서치 사이드바(learned 갱신), 라이프 `knowledge_item` |
| `task.created` | 플래너/리서치 액션 → `task_create` 툴 실체화 | `{task_id, project_id, source:"agent"}` | 작업(phase-3), 대시보드(phase-5) |
| `trip.plan.saved` | 플래너 "이 계획으로 만들기" | `{plan_id, title}` | 찜·가격추적(저장), 일정(phase-8 일정 초안→event) |
| `price.target.hit` | 찜 목표가 도달(worker 가격추적) | `{saved_trip_id, now, target}` | 알림(phase-9), 결재함(phase-7 — 예약 제안) |

### 11.2 구독(subscribe)

| 이벤트 | 출처 | 본 페이지 반응 |
|---|---|---|
| `notification.triaged` | phase-9 알림 | 조사 요청을 리서치 컬렉션으로 라우팅("리서치에 정리해둘게요") |
| `meeting.ended` | phase-8 회의 | 회의에서 나온 리서치 거리를 새 조사로 제안(선택) |
| `mail.received` | phase-9 메일 | 첨부 PDF/링크 → RAG ingest 후보(자료 자동 학습 제안) |

### 11.3 연합 가치 시나리오 (PROJECT-README §5)

1. **여행 플래너 액션 → 작업/일정**: PlanView "이 계획으로 만들기" → `trip.plan.saved` → `calendar_write`(일정 초안 days→event, phase-8) + `task_create`(준비물 checklist→task, phase-3). 결제성 옵션(항공 예약)은 **결재함**(phase-7, risk=high)으로 enqueue.
2. **리서치 리포트 → 지식 컬렉션 저장**: `research.completed` → 리포트를 `research_collection`에 보관, `knowledge_item`(phase-11)과 공유.
3. **"리서치에 정리해둘게요"(알림 연계)**: 알림(phase-9)에서 조사거리 감지 → `notification.triaged` 구독 → 백그라운드 조사 enqueue(worker) → 완료 시 `research.completed` → 알림으로 "정리 끝났어요".
4. **가격추적 → 알림/결재함**: worker가 `saved_trip` 가격 모니터 → `price.target.hit`(목표가 도달) → 알림 + (예약 제안 시) 결재함.

---

## 12. 테스팅 & 검증

### 12.1 실행 명령

```bash
# 백엔드 (backend/)
uv run pytest                                   # 전체
uv run pytest tests/test_agents_travel_examples.py   # 플래너 예시 2개 결정성
uv run pytest tests/test_rag_qa.py              # RAG Q&A 근거
uv run pytest -k "research or trip"             # 페이지 API
uv run python -m app.seed                       # 시드(리서치+여행 포함)
uv run uvicorn app.main:app --reload            # 개발 서버 :8000

# 프론트엔드 (frontend/)
pnpm dev                                        # :3000
pnpm test                                       # Vitest + RTL (컴포넌트)
pnpm playwright test --grep research            # E2E 리서치
pnpm playwright test --grep trip                # E2E 여행
pnpm playwright test --grep @a11y               # axe
```

### 12.2 백엔드 테스트 케이스

| # | 파일 | 케이스 | 통과 기준 |
|---|---|---|---|
| RT-1 | `test_seed_research_trip.py` | 시드 후 카운트 | collection 3, source 5(learned 4), report 1, qa 1, chart 1; trip 1, route 2, prep 5(doing 1), day 2, checklist 7, saved 3, plan 2 |
| RT-2 | `test_api_research.py` | `GET /research/report` | `counts`=[논문3,뉴스5,보고서2], `cross` 3행, `synthesis`에 `<b>추론(inference)…` 포함, `note` 일치 |
| RT-3 | `test_api_research.py` | `GET /research/chart` | bars 6개, 마지막 `forecast:true v=412`, `insight`에 `28.9%`/`412억(±9%)` 포함 |
| RT-4 | `test_agents_research.py` | `POST /research/start` 시드 질의 | `status="ready"`, `report_id="rp1"`, report 1:1 |
| RT-5 | `test_agents_research.py` | `POST /research/start` 자유 질의 | `status="queued"`, `queued_text`에 입력 질의 + "끝나면 알림으로 알려드릴게요" |
| RT-6 | `test_rag_qa.py` | `POST /research/ask` 유모차 질문 | `grounded=true`, `refs` 2건(`‘인증·벨트’ 단락`,`휴대성 비교 표`), `a`에 `풀플랫`/`KC 인증` |
| RT-7 | `test_rag_qa.py` | RAG heuristic 임베딩 폴백 | `EMBED_PROVIDER=heuristic`(또는 `EMBED_MODEL` 미설정/오프라인)에서도 `query()`가 청크 반환(코사인>0), 결정적 |
| RT-8 | `test_rag_qa.py` | 근거 없음 | 무관 질의 시 `grounded=false`, `refs=[]` |
| RT-9 | `test_agents_travel_examples.py` | **예시2 결정성** | `POST /trip/plan {"text":"다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원"}` → `id=plan_jeju` budget total `1,287,000`, transport pick 2건; `도쿄 출장 2박 3일…` → `id=plan_tokyo` total `713,000` |
| RT-10 | `test_agents_travel_examples.py` | 자유 입력 폴백 | `{"text":"부산 당일치기"}` → `id=plan_jeju`, `custom=true`, `title="부산 당일치기"`(≤18자) |
| RT-11 | `test_api_trip.py` | `GET /trip/upcoming` | route `{out,back}` 중첩, expense budget 400000/planned 331600, check 3그룹, day d1에 `hot:true` "분기 리포트 발표" |
| RT-12 | `test_api_trip.py` | `POST /trip/saved/sv3/watch` | `watch` false→true 토글 영속, 응답 `SavedTripOut` |
| RT-13 | `test_api_trip.py` | saved spark | sv1 `[120,…,89]` `down=true` `delta="−12%"` |
| RT-14 | `test_agents_research.py` | scripted 폴백 강제 | `AGENT_PROVIDER=scripted`에서도 시드 질의 ready(골든) — tool-capable 없이 동작 |

`test_agents_travel_examples.py` 예시(결정성 골든):

```python
def test_plan_jeju_golden(client):
    r = client.post("/api/trip/plan", json={
        "text": "다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원"})
    p = r.json()
    assert p["id"] == "plan_jeju" and p["custom"] is False
    assert p["budget"]["total"] == "1,287,000" and p["budget"]["cap"] == "1,500,000"
    picks = [o for o in p["transport"] if o.get("pick")]
    assert len(picks) == 2 and p["transport"][0]["price"] == "356,000"
    assert p["checklist"][-1] == "카시트 (렌터카 옵션 신청 완료)"

def test_plan_tokyo_golden(client):
    r = client.post("/api/trip/plan", json={"text": "도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘"})
    p = r.json()
    assert p["id"] == "plan_tokyo" and p["title"] == "도쿄 출장"
    assert p["budget"]["total"] == "713,000"
    assert p["sources"] == ["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]

def test_plan_freeform_fallback(client):
    r = client.post("/api/trip/plan", json={"text": "부산 당일치기로 바닷바람 쐬고 오기"})
    p = r.json()
    assert p["id"] == "plan_jeju" and p["custom"] is True
    assert p["title"] == "부산 당일치기로 바닷바람 쐬고"[:18]
```

### 12.3 프론트 테스트 케이스 (Vitest/RTL + Playwright)

| # | 종류 | 케이스 | 통과 기준 |
|---|---|---|---|
| F-1 | RTL | `ChartCard` 막대 높이 | max=412 기준 v/max*100% 적용, forecast 막대에 `rs-btag` "예측" |
| F-2 | RTL | `ReportCard` 교차분석표 | head `출처군/핵심 주장/논조` + 3 rows, dot 색 `var(--violet/blue/green)` |
| F-3 | RTL | `Composer` queued | 자유 질의 go → `rs-queued` 배너 + 입력 질의 표시 |
| F-4 | RTL | `Spark` polyline | 7값 입력 시 polyline points 7개 + 마지막 circle, down=green |
| F-5 | RTL | 체크리스트 토글 | task 클릭 → `.done` 클래스 + localStorage `ariTp.check` 갱신 |
| F-6 | E2E | 리서치 뷰 전환 | 사이드바 4뷰 클릭 → 헤더 h1/p 변경, localStorage `ariR.view` 영속 |
| F-7 | E2E | 플래너 풀 플로우 | 예시 칩 클릭 → "계획 만들기" → `pl-prog` 4단계 → result(제주) → "이 계획으로 만들기" → 토스트 |
| F-8 | E2E | 가격알림 토글 | saved sv3 `sw` 클릭 → on, 새로고침 후 유지(서버 영속) |
| F-9 | a11y | axe | 리서치/여행 양 페이지 serious 이상 위반 0 |
| F-10 | E2E | reduced-motion | `prefers-reduced-motion`에서 플래너 즉시 result(애니메이션 생략) |

### 12.4 수동 QA 체크리스트

리서치:
- [ ] 상단 13항목 내비에서 "리서치" active, 좌측 사이드바 4뷰 + 컬렉션 3 + 학습 자료 5(학습 완료 4 tick, s5 amber blink).
- [ ] 새 조사 홈: 컴포저 + 예시 칩 3개 + 입구 카드 3개. 예시 칩 클릭 시 입력창 채움.
- [ ] "최신 AI 반도체…" 조사 시작 → 종합 리포트(논문3/뉴스5/보고서2, 교차분석표 3행, 편향 노트).
- [ ] 자유 질의 조사 시작 → queued 배너("끝나면 알림으로 알려드릴게요").
- [ ] 지식 Q&A: 유모차 질문 → 근거 2건(인증·벨트 단락 / 휴대성 비교 표) 함께 답.
- [ ] 시각화·예측: 막대 6개, 마지막 점선+"예측" pill(412), CAGR 28.9% 인사이트, "단순 추세 외삽" 주의.
- [ ] 라이트/다크 토글, 줄바꿈 `word-break: keep-all`.

여행:
- [ ] 페이지 헤드 "다음 일정 D-4 · 부산 출장 · 준비물 N/7 챙김". SubRail 3뷰.
- [ ] 다가오는 출장: 히어로(KTX 왕복 Leg 2 + 스테이 부산역 + 날씨 2), 미리 해둔 일(doing 1 amber), 체크리스트(그룹 3, 자동 라벨), 일정표(16/17일 탭, "분기 리포트 발표" 핵심 뱃지), 경비(331,600/400,000 · 82% · paid/hold/est).
- [ ] 체크리스트 항목 토글 → 새로고침 후 유지(localStorage).
- [ ] 새 여행 계획: 예시 입력 → 조사 4단계 애니메이션 → 제주/도쿄 결과(이동·숙소 추천 pick, 일정 일차 탭, 경비 바, 준비물). "이 계획으로 만들기" → 토스트.
- [ ] 찜·가격추적: 3카드(제주 −12% green / 강릉 +4% coral / 도쿄 −7% green), spark 그래프, 알림 토글.

### 12.5 통과 기준(요약)

- 백엔드 `pytest` 전건 green: 시드 카운트(RT-1), 리포트/차트 1:1(RT-2/3), 조사 ready/queued(RT-4/5), RAG 근거(RT-6~8), **플래너 예시2 결정성(RT-9)** + 폴백(RT-10), 출장/찜 API(RT-11~13), scripted 폴백(RT-14).
- 프론트 `pnpm test` + `pnpm playwright test` green: 뷰 전환/플래너 플로우/체크리스트 영속/가격토글.
- axe serious 이상 0. 라이트/다크 토큰, 13항목 내비 일치.
- **데모 결정성**: tool-capable·임베딩 모델 미가용(오프라인/CI)에서도 시드 리포트·예시 2 플래너·유모차 Q&A가 골든으로 동작.

---

## 13. 완료 기준 (Definition of Done)

- [ ] 신규 테이블 11종(research_collection/source, research_report, knowledge_qa, research_chart, trip, trip_route/stay/prep/day/checklist, saved_trip, trip_plan, rag_chunk) 모델 + 마이그레이션 + 시드(원본 `*-data.js` 1:1).
- [ ] `backend/app/agents/`: Agent 루프(plan→act→observe→reflect) + tools(web_search/rag_query/http_fetch/task_create/calendar_write) + research_agent/travel_agent + scripted 폴백 + registry.
- [ ] `backend/app/rag/`: pipeline(ingest→chunk→embed→store→query) + EmbeddingProvider(Ollama/heuristic) + VectorStore(코사인/sqlite-vec).
- [ ] 엔드포인트 10종(`/research/*` 6, `/trip/*` 4) — phase-2 패턴(라우터 내부 prefix 없음, main.py에서 `/api`).
- [ ] 리서치 페이지: 사이드바 4뷰 + Composer + HomeGrid + ReportCard(교차분석표) + QACard(근거) + ChartCard(예측 막대). 원본 토큰/문구 충실.
- [ ] 여행 페이지: SubRail 3뷰 — UpcomingView(route/stay/prep/체크리스트/일정표/경비) + PlanView(input→research 애니메이션→result, 예시2 결정성) + SavedView(Spark·가격알림). 체크 상태 localStorage.
- [ ] 연합: 플래너 액션→작업/일정, 리포트→컬렉션 저장, "리서치에 정리해둘게요" 알림 연계 이벤트(§11) 정의·발행/구독 명시.
- [ ] 상태 처리: 조사 진행/스트리밍/타임아웃/폴백/RAG 근거없음/임베딩 폴백/HTML 새니타이즈/반응형.
- [ ] 테스트: 백엔드 RT-1~14, 프론트 F-1~10 green. 데모 결정성(오프라인) 보장.
- [ ] 디자인 충실도: research.css/trip.css 토큰·클래스·문구 인용 일치. 라이트/다크·13항목 내비.
- [ ] 상호 참조: post-mvp-overview, phase-2, phase-7/8/9, phase-11/13/14 정확한 파일명.

---

## 14. 다음 단계

다음 문서: **`phase-11-life-care.md`** — 라이프 케어(건강·금융·지식, 커넥터). 본 phase의 `knowledge_item`(리서치/라이프 공유), RAG 파이프라인, 커넥터 추상화를 그대로 이어받아 `connector_source`(health/finance/knowledge) + `health`(rings/vitals/sleep/coach/habits) + `finance`(budget/cats/subs/coach/insights) + `knowledge_item`(article|note|idea|highlight + ai)를 구현한다. 이후 `phase-12-daily-narrative.md`(여정+하루 마감 집계) → `phase-13-integrations.md`(web_search/embedding mock→real 교체) → `phase-14-proactive-agent.md`(심부름 에이전트·능동 알림·멀티모달) → `phase-15-production.md`.

*끝.*
