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

1805 lines
104 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# Phase 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`.
*끝.*