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

104 KiB

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

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

이 문서는 포스트-MVP 세트의 일부 — 먼저 dev/overview.mddev/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. 개요 & 목표
  2. 선행 조건 / 산출물
  3. 상세 구현 — 백엔드
  4. 상세 구현 — 에이전트 오케스트레이션
  5. 상세 구현 — RAG / 지식베이스
  6. 상세 구현 — 프론트엔드 (리서치)
  7. 상세 구현 — 프론트엔드 (여행)
  8. 데이터 / 타입 / API 계약
  9. 디자인 충실도 노트
  10. 상태 처리 & 엣지 케이스
  11. 연합 이벤트 (발행/구독)
  12. 테스팅 & 검증
  13. 완료 기준 (Definition of Done)
  14. 다음 단계

1. 개요 & 목표

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

1.1 페이지 목표

리서치(/research) — 지능형 리서치 파트너. 원본 research.jsx의 사이드바 4뷰 + 컬렉션 + 학습 자료를 픽셀 충실하게 재현한다.

  • 새 조사(home): 자연어 명령 컴포저(Composer) + 예시 칩 + 입구 카드 3개(HomeGrid). "무엇이든 조사를 맡겨보세요" → 에이전트가 출처를 모아 교차 분석.
  • 종합 리포트(report): 논문·뉴스·보고서를 출처군별로 모아 교차 분석표(주장/논조) + 종합 문단 + 편향 주의 노트.
  • 지식 Q&A(qa): 저장한 PDF·웹·메모를 학습(RAG)해 근거 링크와 함께 답.
  • 시각화·예측(chart): 막대 차트 + 예측 막대(점선) + CAGR 인사이트 + "단순 추세 외삽" 한계 주의.

여행(/trip) — 여행·출장 도우미. 원본 trip.jsxSubRail 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~§14phase-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.pyinclude_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.

# 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.fromfrom_label(응답 alias from), source.now/saved.now는 그대로 now 컬럼(파이썬에서 컬럼명으로 사용 가능, 단 모듈 헬퍼 now()와 충돌하지 않게 인스턴스 속성으로만 접근).

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

# 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 부착.

# 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

# 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 패턴):

# 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) 안에서 호출.

# 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에 적재한다(s5learned=false → 학습 중, Q&A 근거에서 제외 가능하나 데모 Q&A qa1의 근거는 유모차 안전 기준/후기 메모이므로 시드 청크에 포함). 근거 표시용 part_labelqa1.refs[].part("‘인증·벨트’ 단락", "휴대성 비교 표")와 정확히 일치하도록 시드한다.

# ---- 여행 (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 목록에 신규 테이블 추가:

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)

# 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)

# 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)

# 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_querycross-analysis(출처군별 주장/논조) → synthesis(종합 문단 + 편향 노트).

# 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 로직 미러링).

# 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)

# 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)

# 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 폴백

LLMProvidertool_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)

# 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 + 파이프라인

# 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]
# 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.pysplit_chunks(text, size=400, overlap=60)는 한국어 문단/문장 경계 우선 분할(정규식). 데모 시드 본문은 이미 짧아 1청크.


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

원본 research.jsxMVP 셸과 별도 스코프(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)

// 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을 그대로 이식한다.

// 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/startqueued_text를 사용한다(원본 하드코딩 문구와 동일).

// 핵심: 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, "단순 추세 외삽").
// 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뷰

// 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)과 예시 결정성.

// 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로 영속.
// 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)

{
  "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

// 요청 (시드 질의)
{ "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

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

POST /api/trip/plan

// 요청 (예시 정확 일치 → 골든 결정성)
{ "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 포함)

{
  "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 스키마(핵심)

# 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/useTripisLoading.
조사 진행(streaming) 리서치: rs-queued 배너(blue pulse) 또는 향후 SSE 스트리밍. 여행: pl-prog 4단계(650ms 간격) + pl-spin. prefers-reduced-motion이면 애니메이션 생략, 즉시 결과.
타임아웃 에이전트 루프 max_steps/툴 LLM_TIMEOUT 초과 → scripted 폴백 또는 queued("끝나면 알림"). 사용자에게 에러 대신 "조사 중" 유지.
에이전트 폴백 tool-capable 미가용/예외 → Agent.runscripted로 폴백, 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.savedcalendar_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 실행 명령

# 백엔드 (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, insight28.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 예시(결정성 골든):

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.

끝.