104 KiB
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. 목차
- 개요 & 목표
- 선행 조건 / 산출물
- 상세 구현 — 백엔드
- 상세 구현 — 에이전트 오케스트레이션
- 상세 구현 — RAG / 지식베이스
- 상세 구현 — 프론트엔드 (리서치)
- 상세 구현 — 프론트엔드 (여행)
- 데이터 / 타입 / API 계약
- 디자인 충실도 노트
- 상태 처리 & 엣지 케이스
- 연합 이벤트 (발행/구독)
- 테스팅 & 검증
- 완료 기준 (Definition of Done)
- 다음 단계
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.
# backend/app/models.py (phase-10 추가분)
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship, Column, JSON
from .models import now # 기존 헬퍼 재사용
# ============ 리서치 enums ============
class SourceKind(str, Enum):
pdf = "pdf"
web = "web"
note = "note"
# ============ 리서치 테이블 ============
class ResearchCollection(SQLModel, table=True):
"""지식 베이스 컬렉션 — 사용자가 모은 자료를 아리가 학습."""
__tablename__ = "research_collection"
id: str = Field(primary_key=True) # "chip" / "onb" / "stroller"
name: str # "AI 반도체 시장"
tone: str = "ink" # blue|violet|green ...
n: int = 0 # 자료 수 (원본 collections[].n)
sort_order: int = 0
class ResearchSource(SQLModel, table=True):
"""최근 저장 자료 — kind: pdf/web/note, learned: 아리 학습 완료 여부."""
__tablename__ = "research_source"
id: str = Field(primary_key=True) # "s1"~"s5"
kind: SourceKind
title: str # "2026 반도체 시장 전망 보고서"
from_label: str = "" # 원본 from. (from은 파이썬 예약어 → from_label, 응답에서 alias "from")
col: Optional[str] = Field(default=None, foreign_key="research_collection.id")
learned: bool = False
sort_order: int = 0
class ResearchReport(SQLModel, table=True):
"""멀티소스 종합 리포트 — 교차 분석. counts/cross는 JSON 컬럼."""
__tablename__ = "research_report"
id: str = Field(primary_key=True) # "rp1"
title: str # "최신 AI 반도체 시장 동향"
asked: str = "" # "보고서, 논문, 뉴스 기사를..."
meta: str = "" # "출처 10곳 수집 · 3분 전 완성"
synthesis: str = "" # HTML 허용 (<b>)
note: str = "" # 편향 주의
counts: list = Field(default_factory=list, sa_column=Column(JSON)) # [{k,n,tone}]
cross: list = Field(default_factory=list, sa_column=Column(JSON)) # [{src,tone,claim,stance}]
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
created_at: datetime = Field(default_factory=now)
class ResearchQA(SQLModel, table=True):
"""지식 베이스 Q&A — 저장 자료 기반 답변 + 원문 근거."""
__tablename__ = "knowledge_qa" # CONTRACT 명명: knowledge_qa
id: str = Field(primary_key=True) # "qa1"
q: str
a: str = "" # HTML 허용
refs: list = Field(default_factory=list, sa_column=Column(JSON)) # [{title,part}]
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
created_at: datetime = Field(default_factory=now)
class ResearchChart(SQLModel, table=True):
"""시각화 + 추세 예측 — bars + forecast."""
__tablename__ = "research_chart"
id: str = Field(primary_key=True) # "ch1"
title: str # "경쟁사 A — 연 매출 추이와 내년 예측"
asked: str = ""
unit: str = "" # "억원"
bars: list = Field(default_factory=list, sa_column=Column(JSON)) # [{y,v,forecast?}]
insight: str = "" # HTML 허용
caution: str = ""
# ============ 여행 테이블 ============
class Trip(SQLModel, table=True):
"""다가오는 출장(부산)."""
__tablename__ = "trip"
id: str = Field(primary_key=True) # "tp_busan"
dday: str = "" # "D-4"
title: str = "" # "부산 출장"
dates: str = "" # "6월 16일(월) – 17일(화) · 1박 2일"
purpose: str = "" # "부산 지사 · 분기 리포트 발표"
brief: str = ""
weather: list = Field(default_factory=list, sa_column=Column(JSON)) # [{d,t,icon}]
expense_budget: int = 0 # 400000
expense_planned: int = 0 # 331600
expense_note: str = ""
expense_rows: list = Field(default_factory=list, sa_column=Column(JSON)) # [{name,amt,state}]
class TripRoute(SQLModel, table=True):
"""이동 구간 — dir: out|back."""
__tablename__ = "trip_route"
id: str = Field(primary_key=True) # "rt_out" / "rt_back"
trip_id: str = Field(foreign_key="trip.id")
dir: str # "out" | "back"
mode: str # "KTX 101"
from_st: str; ft: str # "서울역", "07:00"
to_st: str; tt: str # "부산역", "09:42"
seat: str = ""; note: str = ""
class TripStay(SQLModel, table=True):
__tablename__ = "trip_stay"
id: str = Field(primary_key=True)
trip_id: str = Field(foreign_key="trip.id")
name: str; desc: str = ""; conf: str = ""
class TripPrep(SQLModel, table=True):
"""아리가 미리 해둔 일. state: done|doing."""
__tablename__ = "trip_prep"
id: str = Field(primary_key=True)
trip_id: str = Field(foreign_key="trip.id")
text: str
state: str = "done" # done | doing
sort_order: int = 0
class TripDay(SQLModel, table=True):
"""일정표 한 날 — items는 JSON([{t,kind,tone,title,meta,hot?}])."""
__tablename__ = "trip_day"
id: str = Field(primary_key=True) # "d1" / "d2"
trip_id: str = Field(foreign_key="trip.id")
tab: str # "16일 (월)"
items: list = Field(default_factory=list, sa_column=Column(JSON))
sort_order: int = 0
class TripChecklist(SQLModel, table=True):
"""체크리스트 항목. group으로 묶음, auto는 자동 처리 라벨."""
__tablename__ = "trip_checklist"
id: str = Field(primary_key=True) # "c1"~"c7"
trip_id: str = Field(foreign_key="trip.id")
group: str # "서류 · 결제" / "발표" / "짐"
text: str
auto: str = "" # "아리가 제출 완료" 등(체크 상태 자체는 localStorage)
sort_order: int = 0
class SavedTrip(SQLModel, table=True):
"""찜한 여행 아이디어 — 가격 추적(spark)."""
__tablename__ = "saved_trip"
id: str = Field(primary_key=True) # "sv1"~"sv3"
title: str; tag: str = ""; note: str = ""
now: str = "" # "89,000원"
delta: str = "" # "−12%"
down: bool = True # 가격 하락 여부
watch: bool = True # 가격 알림 on/off (토글 영속)
hint: str = ""
spark: list = Field(default_factory=list, sa_column=Column(JSON)) # [120,118,...]
sort_order: int = 0
class TripPlan(SQLModel, table=True):
"""AI 플래너 결과 — examples 인덱스와 1:1 매칭(제주=0, 도쿄=1). 자유입력은 0으로 폴백."""
__tablename__ = "trip_plan"
id: str = Field(primary_key=True) # "plan_jeju" / "plan_tokyo"
example: str # 매칭 질의 원문
idx: int = 0 # examples 인덱스
title: str; meta: str = ""; summary: str = "" # summary: HTML 허용
weather: str = ""
transport: list = Field(default_factory=list, sa_column=Column(JSON)) # [{mode,name,desc,price,pick?,note?}]
stay: list = Field(default_factory=list, sa_column=Column(JSON)) # [{name,desc,price,unit,pick?}]
days: list = Field(default_factory=list, sa_column=Column(JSON)) # [{tab,items:[{t,title,tone}]}]
budget: dict = Field(default_factory=dict, sa_column=Column(JSON)) # {total,cap,rows:[{name,amt}]}
checklist: list = Field(default_factory=list, sa_column=Column(JSON)) # [str]
sources: list = Field(default_factory=list, sa_column=Column(JSON)) # [str]
JSON 컬럼 노트:
counts/cross/bars/items/spark/transport등 중첩 배열은 원본*-data.js가 이미 JSON-shaped다. SQLite는 JSON1 확장을 기본 지원하므로sa_column=Column(JSON)으로 무손실 저장한다(phase-2의 단순 스칼라 컬럼과 달리 본 페이지는 표/차트 데이터가 중첩되어 JSON이 자연스럽다). 마이그레이션은import sqlmodel을 versions 파일 상단에 둔다(phase-2 §3.3 노트).
from/now예약어 회피: 원본source.from은from_label(응답 aliasfrom),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에 적재한다(s5는learned=false→ 학습 중, Q&A 근거에서 제외 가능하나 데모 Q&Aqa1의 근거는 유모차 안전 기준/후기 메모이므로 시드 청크에 포함). 근거 표시용part_label은qa1.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_query → cross-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 폴백
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)
# 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.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)
// 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/start의 queued_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(HTMLdangerouslySetInnerHTML, 새니타이즈) + 교차분석표(rs-cross: head출처군/핵심 주장/논조+ rows, grid150px 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 +title18자 잘라 덮어쓰기,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일차 탭 +schedev). - 예상 경비(
fin-big/fin-bar/fin-range+xp-rows). - 준비물(
pp-listtick rows).
7.3 UpcomingView / SavedView
- UpcomingView: 히어로(
tp-head제목 "부산 출장" + dates + brief +tp-tags날씨 +tp-routeLeg2개 +tp-stay) → 아리가 미리 해둔 일(pp-list, doing=clock/amber, done=tick/green, 하단 "예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.") → 체크리스트(tp-gp그룹 +task클릭 토글,tp-auto자동 라벨, countdoneN/allN) → 일정표(tp-tabs16일/17일 +KindIcon+핵심뱃지) → 경비(fin-big(planned/10000).toFixed(1)만,fin-barwidthpct%,xp-rowstate paid/hold/est). - SavedView:
saved각 카드 —sv-note+sv-price(nx-price mono+sv-deltadown=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만 aliasfrom으로 직렬화(모델은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/useTrip의 isLoading. |
| 조사 진행(streaming) | 리서치: rs-queued 배너(blue pulse) 또는 향후 SSE 스트리밍. 여행: pl-prog 4단계(650ms 간격) + pl-spin. prefers-reduced-motion이면 애니메이션 생략, 즉시 결과. |
| 타임아웃 | 에이전트 루프 max_steps/툴 LLM_TIMEOUT 초과 → scripted 폴백 또는 queued("끝나면 알림"). 사용자에게 에러 대신 "조사 중" 유지. |
| 에이전트 폴백 | tool-capable 미가용/예외 → Agent.run이 scripted로 폴백, AgentResult.fallback=true, model="scripted". UI는 동일(데모 결정성). |
| RAG 근거 없음 | AskOut.grounded=false + "저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요." 안내. refs=[]. |
| 학습 중 자료 | source.learned=false(s5) → 사이드바 rs-learning(amber blink), Q&A 근거 후보에서 가중치 하향 또는 제외 가능. |
| 임베딩 미가용 | HeuristicEmbedding(TF 해시) 폴백 — 오프라인/CI에서도 코사인 검색 동작(결정적). |
| 플래너 자유 입력 | examples 불일치 → 제주(idx 0) 폴백 + title 18자 잘라 덮어쓰기 + custom:true(원본 pickResult). |
| 체크리스트 영속 | localStorage["ariTp.check"] — 서버 미저장(원본과 동일). 초기화/충돌 시 {} 폴백. |
| HTML 시드 | synthesis/a/summary/insight는 <b> 포함 HTML — 신뢰 시드지만 렌더 전 새니타이즈(overview §15, DOMPurify). |
| 빈(empty) | 컬렉션/소스 없음: "저장하면 아리가 학습해요" 힌트. 찜 없음: "찜한 여행이 아직 없어요" 안내. |
| 에러 | lib/api.ts 비2xx throw → 카드 단위 에러 + 재시도. 셸(Topbar/테마) 유지. |
| 반응형 | 리서치 @media(max-width:1080px) .subnav 숨김; 760px .rs-cross 1열·.rs-counts 숨김. 여행 720px .pl-grid 1열(원본 미디어쿼리 그대로). |
11. 연합 이벤트 (발행/구독)
post-mvp-overview의 event_bus(phase-7) 이벤트 모델을 사용. 본 페이지가 발행/구독하는 이벤트:
11.1 발행(publish)
| 이벤트 | 발행 시점 | 페이로드 | 구독자 |
|---|---|---|---|
research.completed |
조사 종합 리포트 완성(/research/start ready 또는 worker 완료) |
{report_id, title, source_count} |
알림(phase-9, "리서치에 정리해둘게요"), 라이프 지식(phase-11) |
knowledge.ingested |
자료 학습(RAG ingest) 완료 | {source_id, collection_id, chunks} |
리서치 사이드바(learned 갱신), 라이프 knowledge_item |
task.created |
플래너/리서치 액션 → task_create 툴 실체화 |
{task_id, project_id, source:"agent"} |
작업(phase-3), 대시보드(phase-5) |
trip.plan.saved |
플래너 "이 계획으로 만들기" | {plan_id, title} |
찜·가격추적(저장), 일정(phase-8 일정 초안→event) |
price.target.hit |
찜 목표가 도달(worker 가격추적) | {saved_trip_id, now, target} |
알림(phase-9), 결재함(phase-7 — 예약 제안) |
11.2 구독(subscribe)
| 이벤트 | 출처 | 본 페이지 반응 |
|---|---|---|
notification.triaged |
phase-9 알림 | 조사 요청을 리서치 컬렉션으로 라우팅("리서치에 정리해둘게요") |
meeting.ended |
phase-8 회의 | 회의에서 나온 리서치 거리를 새 조사로 제안(선택) |
mail.received |
phase-9 메일 | 첨부 PDF/링크 → RAG ingest 후보(자료 자동 학습 제안) |
11.3 연합 가치 시나리오 (PROJECT-README §5)
- 여행 플래너 액션 → 작업/일정: PlanView "이 계획으로 만들기" →
trip.plan.saved→calendar_write(일정 초안 days→event, phase-8) +task_create(준비물 checklist→task, phase-3). 결제성 옵션(항공 예약)은 결재함(phase-7, risk=high)으로 enqueue. - 리서치 리포트 → 지식 컬렉션 저장:
research.completed→ 리포트를research_collection에 보관,knowledge_item(phase-11)과 공유. - "리서치에 정리해둘게요"(알림 연계): 알림(phase-9)에서 조사거리 감지 →
notification.triaged구독 → 백그라운드 조사 enqueue(worker) → 완료 시research.completed→ 알림으로 "정리 끝났어요". - 가격추적 → 알림/결재함: 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, 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 예시(결정성 골든):
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 testgreen: 뷰 전환/플래너 플로우/체크리스트 영속/가격토글. - 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.js1: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.
끝.