# 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 허용 () 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=("세 출처군 모두 추론(inference) 수요로의 무게 이동에는 동의해요. " "다만 학술 쪽은 메모리 대역폭을, 뉴스는 HBM4 양산 경쟁을, " "산업 보고서는 전력 효율 규제를 핵심 변수로 봐요 — 투자 판단이라면 셋이 " "겹치는 ‘메모리 중심 아키텍처’가 가장 합의된 신호예요."), 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=("저장해두신 자료 기준으로는 풀플랫(완전 평탄) 시트양대면 전환이 신생아 필수 " "조건이에요. 안전 기준 글에서는 KC 인증 + 5점식 벨트를 최소선으로 꼽았고, 커뮤니티 " "메모에선 차 트렁크 크기를 먼저 재라는 조언이 가장 많았어요."), 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)은 28.9% — 2024년에 살짝 둔화(+18%)했다가 2025년 " "신제품으로 반등했어요. 같은 흐름이면 내년 매출은 약 412억(±9%)으로 예측돼요."), 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=("성수기 직전 금요일 출발이 가장 한적하고 저렴해요. 렌터카 + 서귀포 중심 숙소 기준으로 " "예산 150만원 안에 들어옵니다."), 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=("시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. 엔저가 이어지는 동안이 " "항공·숙박 모두 유리합니다."), 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("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 (

{RS_HEAD[view].h}

{RS_HEAD[view].p}

{view === "home" && (<>)} {view === "report" && } {view === "qa" && } {view === "chart" && }
); } ``` ### 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})` }} // 학습 완료: // 학습 중: (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) => (
{b.v}
{b.forecast && 예측}
{b.y}
)); ``` --- ## 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>({}); const [toast, setToast] = useState(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 (
다음 일정 {upcoming?.trip.dday} · {upcoming?.trip.title} 준비물 {doneN}/{allItems.length} 챙김

{h.title} {h.em}

{view === "upcoming" && } {view === "plan" && { setToast(`‘${r.title}’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.`); setTimeout(() => setToast(null), 3600); }} />} {view === "saved" && }
{toast &&
{toast}
}
); } ``` ### 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(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 ( ); } ``` --- ## 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": "세 출처군 모두 추론(inference) 수요로의 무게 이동에는 동의해요. …", "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": "저장해두신 자료 기준으로는 풀플랫(완전 평탄) 시트양대면 전환이 신생아 필수 조건이에요. …", "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": "시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. 엔저가 이어지는 동안이 항공·숙박 모두 유리합니다.", "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`는 `` 포함 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`에 `추론(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`. *끝.*