|
|
# Phase 10 — 리서치 + 여행 (에이전트 오케스트레이션)
|
|
|
|
|
|
> 한 줄 요약: 멀티소스 종합 리포트·지식 베이스 Q&A·시각화/추세 예측을 갖춘 **지능형 리서치 파트너**와, 다가오는 출장·AI 여행 플래너·찜/가격추적을 갖춘 **여행 도우미**를 구현한다. 이를 위해 횡단 아키텍처 신규물 3종 — **에이전트 오케스트레이션(`backend/app/agents/`)**, **RAG/지식베이스(`backend/app/rag/`)**, **웹검색 툴** — 을 도입하고, 모든 외부 의존은 *모델 비종속 + scripted/heuristic 폴백*으로 데모 결정성을 보장한다.
|
|
|
|
|
|
> 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
|
|
|
>
|
|
|
> 선행(포스트-MVP): `post-mvp-overview.md`(횡단 아키텍처·로드맵·가정), `phase-7-approvals-automation.md`(자율성 코어 — 결재함·자동화 엔진·`event_bus`), `phase-8-calendar-meetings.md`(액션→작업 연합), `phase-9-mail-notifications.md`(알림 트리아지 — "리서치에 정리해둘게요" 연계).
|
|
|
> 선행(MVP 상속): `overview.md`(스택·토큰·데이터/API·연합), `phase-2-backend.md`(모델/스키마/라우터/시드/LLM 추상화 패턴), `phase-1-design-system.md`(토큰·`Icon`·셸·`SubRail`), `phase-3-tasks.md`(`board`/카드/탭 패턴).
|
|
|
> 후속: `phase-11-life-care.md`(라이프 케어 — `knowledge_item` 공유), `phase-13-integrations.md`(웹검색/임베딩 real 커넥터 교체), `phase-14-proactive-agent.md`(심부름 에이전트·능동 알림).
|
|
|
|
|
|
원본 디자인(픽셀 충실 재현 기준):
|
|
|
|
|
|
```
|
|
|
REF = /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/design-reference
|
|
|
REF/리서치.html 리서치 진입 HTML
|
|
|
REF/assets/research.jsx 리서치 UI (Composer/ReportCard/QACard/ChartCard/HomeGrid)
|
|
|
REF/assets/research-data.js 리서치 시드(window.ResearchData)
|
|
|
REF/assets/research.css 리서치 전용 스타일(tasks.css 뒤 로드)
|
|
|
REF/여행.html 여행 진입 HTML
|
|
|
REF/assets/trip.jsx 여행 UI (UpcomingView/PlanView/SavedView)
|
|
|
REF/assets/trip-data.js 여행 시드(window.AriTrip)
|
|
|
REF/assets/trip.css 여행 전용 스타일(dash.css 토큰 위)
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 0. 목차
|
|
|
|
|
|
1. [개요 & 목표](#1-개요--목표)
|
|
|
2. [선행 조건 / 산출물](#2-선행-조건--산출물)
|
|
|
3. [상세 구현 — 백엔드](#3-상세-구현--백엔드)
|
|
|
4. [상세 구현 — 에이전트 오케스트레이션](#4-상세-구현--에이전트-오케스트레이션)
|
|
|
5. [상세 구현 — RAG / 지식베이스](#5-상세-구현--rag--지식베이스)
|
|
|
6. [상세 구현 — 프론트엔드 (리서치)](#6-상세-구현--프론트엔드-리서치)
|
|
|
7. [상세 구현 — 프론트엔드 (여행)](#7-상세-구현--프론트엔드-여행)
|
|
|
8. [데이터 / 타입 / API 계약](#8-데이터--타입--api-계약)
|
|
|
9. [디자인 충실도 노트](#9-디자인-충실도-노트)
|
|
|
10. [상태 처리 & 엣지 케이스](#10-상태-처리--엣지-케이스)
|
|
|
11. [연합 이벤트 (발행/구독)](#11-연합-이벤트-발행구독)
|
|
|
12. [테스팅 & 검증](#12-테스팅--검증)
|
|
|
13. [완료 기준 (Definition of Done)](#13-완료-기준-definition-of-done)
|
|
|
14. [다음 단계](#14-다음-단계)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
이 phase가 끝나면 **두 개의 페이지가 동작**하고, 그 둘이 공유하는 **세 가지 횡단 능력**이 백엔드에 자리잡는다.
|
|
|
|
|
|
### 1.1 페이지 목표
|
|
|
|
|
|
**리서치(`/research`)** — 지능형 리서치 파트너. 원본 `research.jsx`의 사이드바 4뷰 + 컬렉션 + 학습 자료를 픽셀 충실하게 재현한다.
|
|
|
- **새 조사(home)**: 자연어 명령 컴포저(`Composer`) + 예시 칩 + 입구 카드 3개(`HomeGrid`). "무엇이든 조사를 맡겨보세요" → 에이전트가 출처를 모아 교차 분석.
|
|
|
- **종합 리포트(report)**: 논문·뉴스·보고서를 출처군별로 모아 **교차 분석표**(주장/논조) + **종합 문단** + 편향 주의 노트.
|
|
|
- **지식 Q&A(qa)**: 저장한 PDF·웹·메모를 학습(RAG)해 **근거 링크와 함께** 답.
|
|
|
- **시각화·예측(chart)**: 막대 차트 + **예측 막대(점선)** + CAGR 인사이트 + "단순 추세 외삽" 한계 주의.
|
|
|
|
|
|
**여행(`/trip`)** — 여행·출장 도우미. 원본 `trip.jsx`의 `SubRail` 3뷰를 재현한다.
|
|
|
- **다가오는 출장(upcoming)**: 부산 D-4 — 히어로(route/stay/weather) · 아리가 미리 해둔 일(prep) · 체크리스트(localStorage) · 일정표(day 탭) · 경비(자동 정산).
|
|
|
- **새 여행 계획(plan)**: AI 플래너 — 자연어 한 문장 → 조사 진행 애니메이션 → transport/stay/days/budget/checklist/sources 결과. 예시 제주·도쿄는 완성 결과를 **결정적으로** 반환, 자유 입력은 0번(제주)으로 폴백.
|
|
|
- **찜·가격추적(saved)**: 저장한 여행 아이디어 + spark 가격 그래프 + 목표가 알림 토글.
|
|
|
|
|
|
### 1.2 횡단 능력 목표 (post-mvp-overview 명명 고정)
|
|
|
|
|
|
| 능력 | 디렉터리 | 이 phase의 책임 | 폴백 |
|
|
|
|---|---|---|---|
|
|
|
| **에이전트 오케스트레이션** | `backend/app/agents/` | `plan→act(tool)→observe→reflect` 멀티스텝 루프. 리서치 종합 파이프라인 + 여행 플래너 파이프라인. 툴: `web_search`, `rag_query`, `http_fetch`, `task_create`, `calendar_write`(stub) | tool-capable 모델 미가용/오프라인 시 **scripted 파이프라인**(예시 골든 결과 결정적 반환) |
|
|
|
| **RAG / 지식베이스** | `backend/app/rag/` | `ingest(pdf/web/note)→chunk→embed→vector store→query`. 지식 Q&A가 근거(`refs`)와 함께 답 | 임베딩 모델 미가용 시 **TF-IDF/코사인 heuristic** 검색 |
|
|
|
| **웹검색 툴** | `backend/app/agents/tools/web_search.py` | 외부 검색 결과를 출처 객체로 정규화 | `CONNECTOR_KNOWLEDGE=mock` 기본 — **MockSearchConnector**(시드 출처 반환) |
|
|
|
|
|
|
> **데모 결정성 원칙**(post-mvp-overview 가정 상속): 에이전트/RAG/임베딩은 *모델 비종속*. tool-capable·embedding 모델은 env로 주입하고, 미가용 시 scripted/heuristic 폴백으로 **시연 동작을 항상 보장**한다. 리서치 시드 리포트(`최신 AI 반도체 시장 동향`)와 플래너 예시 2개(제주·도쿄)는 폴백 경로에서도 **원본 `*-data.js`와 1바이트도 다르지 않은** 골든 결과를 반환한다.
|
|
|
|
|
|
### 1.3 상속 규약 요약 (변경 금지)
|
|
|
|
|
|
`overview.md §7~§14`와 `phase-2-backend.md` 그대로:
|
|
|
- 스택: 프론트 Next.js(App Router)+React+TS / 백엔드 FastAPI+SQLite(SQLModel+Alembic) / LLM 로컬 Ollama(Provider 추상화, `OLLAMA_MODEL` 주입, heuristic 폴백).
|
|
|
- 데이터모델: 모든 PK는 TEXT(str). `tone` 집합 `blue|violet|coral|green|amber|ink|faint`. 한국어 UI 문구는 원본 데이터 파일 그대로.
|
|
|
- API: prefix `/api`. 라우터는 내부 prefix 없이 정의하고 `main.py`의 `include_router(prefix="/api", tags=...)`에서만 `/api` 부착.
|
|
|
- 환경변수: `DATABASE_URL`, `OLLAMA_HOST`, `OLLAMA_MODEL`, `LLM_PROVIDER`, `FRONTEND_ORIGIN`, `NEXT_PUBLIC_API_BASE` + 본 phase 신규 `CONNECTOR_KNOWLEDGE`, `EMBED_PROVIDER`, `EMBED_MODEL`, `AGENT_PROVIDER`, `WEB_SEARCH_PROVIDER`.
|
|
|
- 시드 진입: `run_seed(session=None, reset=True)` 단일 정본 — 본 phase 시드는 `_seed_research(session)`, `_seed_trip(session)` 헬퍼로 `_run()` 안에서 호출.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건 / 산출물
|
|
|
|
|
|
### 2.1 선행 조건
|
|
|
|
|
|
| 의존 | 내용 |
|
|
|
|---|---|
|
|
|
| `post-mvp-overview.md` | 횡단 아키텍처(`agents/`, `rag/`, `connectors/`, `event_bus`, `worker/`) 명명·디렉터리·env 규약 정의. |
|
|
|
| `phase-7-approvals-automation.md` | `event_bus`(내부 이벤트) + `approval` 모델. 여행 플래너의 결제성 액션(항공 예약 등)은 결재함으로 enqueue. |
|
|
|
| `phase-8-calendar-meetings.md` | `event`/`calendar` 모델 + `calendar_write` 툴 대상. 여행 일정 → 캘린더 쓰기. |
|
|
|
| `phase-9-mail-notifications.md` | `notification` 모델. "리서치에 정리해둘게요" — 알림이 끝난 조사를 알려준다(`notification.triaged` 연계). |
|
|
|
| `phase-2-backend.md` | 모델/스키마/라우터/시드/LLM 추상화 패턴. 본 phase 신규 테이블·엔드포인트는 이 패턴을 그대로 따른다. |
|
|
|
| `phase-1-design-system.md` | `Icon`(중앙 paths 맵), `Topbar`(13항목), `SubRail`, 토큰. |
|
|
|
| `phase-3-tasks.md` | `board`/카드(`card`/`ch`/`ico`/`htext`)/탭(`tp-tabs`) 레이아웃 패턴. `task_create` 연합 대상. |
|
|
|
|
|
|
### 2.2 산출물
|
|
|
|
|
|
```
|
|
|
backend/
|
|
|
├─ app/
|
|
|
│ ├─ models.py ← 신규 테이블 11종 추가(아래 §3.1)
|
|
|
│ ├─ schemas.py ← 리서치/여행 I/O 스키마 추가
|
|
|
│ ├─ seed.py ← _seed_research / _seed_trip 헬퍼 추가
|
|
|
│ ├─ routers/
|
|
|
│ │ ├─ research.py GET 컬렉션/소스/리포트/qa/chart, POST 조사 시작, POST qa
|
|
|
│ │ └─ trip.py GET 출장/찜, POST 플래너 실행, POST 찜 가격알림 토글
|
|
|
│ ├─ agents/ ★신규 — 에이전트 오케스트레이션
|
|
|
│ │ ├─ __init__.py
|
|
|
│ │ ├─ base.py Agent 루프(plan→act→observe→reflect) + AgentResult
|
|
|
│ │ ├─ registry.py get_agent_provider() (tool-capable | scripted 선택)
|
|
|
│ │ ├─ research_agent.py 리서치 종합 파이프라인
|
|
|
│ │ ├─ travel_agent.py 여행 플래너 파이프라인
|
|
|
│ │ ├─ scripted.py 결정적 폴백(골든 결과)
|
|
|
│ │ └─ tools/
|
|
|
│ │ ├─ __init__.py
|
|
|
│ │ ├─ base.py Tool 인터페이스 + ToolResult
|
|
|
│ │ ├─ web_search.py web_search (MockSearchConnector 폴백)
|
|
|
│ │ ├─ rag_query.py rag_query (rag/ 호출)
|
|
|
│ │ ├─ http_fetch.py http_fetch (URL→본문, stub-safe)
|
|
|
│ │ ├─ task_create.py task_create (federation → /api/tasks)
|
|
|
│ │ └─ calendar_write.py calendar_write (stub; phase-8 연동점)
|
|
|
│ └─ rag/ ★신규 — RAG / 지식베이스
|
|
|
│ ├─ __init__.py
|
|
|
│ ├─ pipeline.py ingest→chunk→embed→store→query 오케스트레이션
|
|
|
│ ├─ chunk.py 텍스트 청크 분할
|
|
|
│ ├─ embed.py EmbeddingProvider 추상(Ollama embeddings | heuristic TF-IDF)
|
|
|
│ └─ store.py VectorStore (SQLite + 코사인; sqlite-vec 선택)
|
|
|
├─ migrations/versions/xxxx_phase10_research_travel.py
|
|
|
└─ tests/
|
|
|
├─ test_agents_research.py
|
|
|
├─ test_agents_travel_examples.py ← 예시 2개 결정성 골든
|
|
|
├─ test_rag_qa.py
|
|
|
├─ test_api_research.py
|
|
|
├─ test_api_trip.py
|
|
|
└─ test_seed_research_trip.py
|
|
|
|
|
|
frontend/
|
|
|
├─ app/
|
|
|
│ ├─ research/page.tsx 리서치 페이지(사이드바 4뷰)
|
|
|
│ └─ trip/page.tsx 여행 페이지(SubRail 3뷰)
|
|
|
├─ components/research/
|
|
|
│ ├─ ResearchSidebar.tsx rs-nav + 컬렉션 + 학습 자료
|
|
|
│ ├─ Composer.tsx rs-composer (조사 명령 + 칩 + queued)
|
|
|
│ ├─ HomeGrid.tsx rs-entries
|
|
|
│ ├─ ReportCard.tsx rs-report (교차분석표)
|
|
|
│ ├─ QACard.tsx rs-card (근거 refs)
|
|
|
│ └─ ChartCard.tsx rs-chart (예측 막대)
|
|
|
├─ components/trip/
|
|
|
│ ├─ UpcomingView.tsx 히어로/prep/체크리스트/일정표/경비
|
|
|
│ ├─ PlanView.tsx input→research(애니메이션)→result
|
|
|
│ ├─ SavedView.tsx 찜·가격추적 + Spark
|
|
|
│ ├─ Leg.tsx Spark.tsx KindIcon.tsx
|
|
|
├─ lib/hooks/
|
|
|
│ ├─ useResearch.ts 컬렉션/소스/리포트/qa/chart + 조사 시작 + qa 질의
|
|
|
│ └─ useTrip.ts 출장/찜 + 플래너 실행 + 가격알림 토글
|
|
|
└─ styles/
|
|
|
├─ research.css REF/assets/research.css 이식
|
|
|
└─ trip.css REF/assets/trip.css 이식
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현 — 백엔드
|
|
|
|
|
|
### 3.1 `models.py` — 신규 테이블 (phase-2 패턴 그대로)
|
|
|
|
|
|
CONTRACT의 페이지별 테이블 스케치를 원본 `research-data.js`/`trip-data.js` 필드로 정밀 이식한다. **모든 PK는 TEXT(str)**, `tone`은 키 값('blue' 등), enum은 `str, Enum`.
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (phase-10 추가분)
|
|
|
from __future__ import annotations
|
|
|
from datetime import date, datetime
|
|
|
from enum import Enum
|
|
|
from typing import Optional
|
|
|
from sqlmodel import SQLModel, Field, Relationship, Column, JSON
|
|
|
from .models import now # 기존 헬퍼 재사용
|
|
|
|
|
|
|
|
|
# ============ 리서치 enums ============
|
|
|
class SourceKind(str, Enum):
|
|
|
pdf = "pdf"
|
|
|
web = "web"
|
|
|
note = "note"
|
|
|
|
|
|
|
|
|
# ============ 리서치 테이블 ============
|
|
|
class ResearchCollection(SQLModel, table=True):
|
|
|
"""지식 베이스 컬렉션 — 사용자가 모은 자료를 아리가 학습."""
|
|
|
__tablename__ = "research_collection"
|
|
|
id: str = Field(primary_key=True) # "chip" / "onb" / "stroller"
|
|
|
name: str # "AI 반도체 시장"
|
|
|
tone: str = "ink" # blue|violet|green ...
|
|
|
n: int = 0 # 자료 수 (원본 collections[].n)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class ResearchSource(SQLModel, table=True):
|
|
|
"""최근 저장 자료 — kind: pdf/web/note, learned: 아리 학습 완료 여부."""
|
|
|
__tablename__ = "research_source"
|
|
|
id: str = Field(primary_key=True) # "s1"~"s5"
|
|
|
kind: SourceKind
|
|
|
title: str # "2026 반도체 시장 전망 보고서"
|
|
|
from_label: str = "" # 원본 from. (from은 파이썬 예약어 → from_label, 응답에서 alias "from")
|
|
|
col: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
learned: bool = False
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class ResearchReport(SQLModel, table=True):
|
|
|
"""멀티소스 종합 리포트 — 교차 분석. counts/cross는 JSON 컬럼."""
|
|
|
__tablename__ = "research_report"
|
|
|
id: str = Field(primary_key=True) # "rp1"
|
|
|
title: str # "최신 AI 반도체 시장 동향"
|
|
|
asked: str = "" # "보고서, 논문, 뉴스 기사를..."
|
|
|
meta: str = "" # "출처 10곳 수집 · 3분 전 완성"
|
|
|
synthesis: str = "" # HTML 허용 (<b>)
|
|
|
note: str = "" # 편향 주의
|
|
|
counts: list = Field(default_factory=list, sa_column=Column(JSON)) # [{k,n,tone}]
|
|
|
cross: list = Field(default_factory=list, sa_column=Column(JSON)) # [{src,tone,claim,stance}]
|
|
|
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ResearchQA(SQLModel, table=True):
|
|
|
"""지식 베이스 Q&A — 저장 자료 기반 답변 + 원문 근거."""
|
|
|
__tablename__ = "knowledge_qa" # CONTRACT 명명: knowledge_qa
|
|
|
id: str = Field(primary_key=True) # "qa1"
|
|
|
q: str
|
|
|
a: str = "" # HTML 허용
|
|
|
refs: list = Field(default_factory=list, sa_column=Column(JSON)) # [{title,part}]
|
|
|
collection_id: Optional[str] = Field(default=None, foreign_key="research_collection.id")
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
|
|
|
|
|
|
class ResearchChart(SQLModel, table=True):
|
|
|
"""시각화 + 추세 예측 — bars + forecast."""
|
|
|
__tablename__ = "research_chart"
|
|
|
id: str = Field(primary_key=True) # "ch1"
|
|
|
title: str # "경쟁사 A — 연 매출 추이와 내년 예측"
|
|
|
asked: str = ""
|
|
|
unit: str = "" # "억원"
|
|
|
bars: list = Field(default_factory=list, sa_column=Column(JSON)) # [{y,v,forecast?}]
|
|
|
insight: str = "" # HTML 허용
|
|
|
caution: str = ""
|
|
|
|
|
|
|
|
|
# ============ 여행 테이블 ============
|
|
|
class Trip(SQLModel, table=True):
|
|
|
"""다가오는 출장(부산)."""
|
|
|
__tablename__ = "trip"
|
|
|
id: str = Field(primary_key=True) # "tp_busan"
|
|
|
dday: str = "" # "D-4"
|
|
|
title: str = "" # "부산 출장"
|
|
|
dates: str = "" # "6월 16일(월) – 17일(화) · 1박 2일"
|
|
|
purpose: str = "" # "부산 지사 · 분기 리포트 발표"
|
|
|
brief: str = ""
|
|
|
weather: list = Field(default_factory=list, sa_column=Column(JSON)) # [{d,t,icon}]
|
|
|
expense_budget: int = 0 # 400000
|
|
|
expense_planned: int = 0 # 331600
|
|
|
expense_note: str = ""
|
|
|
expense_rows: list = Field(default_factory=list, sa_column=Column(JSON)) # [{name,amt,state}]
|
|
|
|
|
|
|
|
|
class TripRoute(SQLModel, table=True):
|
|
|
"""이동 구간 — dir: out|back."""
|
|
|
__tablename__ = "trip_route"
|
|
|
id: str = Field(primary_key=True) # "rt_out" / "rt_back"
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
dir: str # "out" | "back"
|
|
|
mode: str # "KTX 101"
|
|
|
from_st: str; ft: str # "서울역", "07:00"
|
|
|
to_st: str; tt: str # "부산역", "09:42"
|
|
|
seat: str = ""; note: str = ""
|
|
|
|
|
|
|
|
|
class TripStay(SQLModel, table=True):
|
|
|
__tablename__ = "trip_stay"
|
|
|
id: str = Field(primary_key=True)
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
name: str; desc: str = ""; conf: str = ""
|
|
|
|
|
|
|
|
|
class TripPrep(SQLModel, table=True):
|
|
|
"""아리가 미리 해둔 일. state: done|doing."""
|
|
|
__tablename__ = "trip_prep"
|
|
|
id: str = Field(primary_key=True)
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
text: str
|
|
|
state: str = "done" # done | doing
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripDay(SQLModel, table=True):
|
|
|
"""일정표 한 날 — items는 JSON([{t,kind,tone,title,meta,hot?}])."""
|
|
|
__tablename__ = "trip_day"
|
|
|
id: str = Field(primary_key=True) # "d1" / "d2"
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
tab: str # "16일 (월)"
|
|
|
items: list = Field(default_factory=list, sa_column=Column(JSON))
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripChecklist(SQLModel, table=True):
|
|
|
"""체크리스트 항목. group으로 묶음, auto는 자동 처리 라벨."""
|
|
|
__tablename__ = "trip_checklist"
|
|
|
id: str = Field(primary_key=True) # "c1"~"c7"
|
|
|
trip_id: str = Field(foreign_key="trip.id")
|
|
|
group: str # "서류 · 결제" / "발표" / "짐"
|
|
|
text: str
|
|
|
auto: str = "" # "아리가 제출 완료" 등(체크 상태 자체는 localStorage)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class SavedTrip(SQLModel, table=True):
|
|
|
"""찜한 여행 아이디어 — 가격 추적(spark)."""
|
|
|
__tablename__ = "saved_trip"
|
|
|
id: str = Field(primary_key=True) # "sv1"~"sv3"
|
|
|
title: str; tag: str = ""; note: str = ""
|
|
|
now: str = "" # "89,000원"
|
|
|
delta: str = "" # "−12%"
|
|
|
down: bool = True # 가격 하락 여부
|
|
|
watch: bool = True # 가격 알림 on/off (토글 영속)
|
|
|
hint: str = ""
|
|
|
spark: list = Field(default_factory=list, sa_column=Column(JSON)) # [120,118,...]
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
class TripPlan(SQLModel, table=True):
|
|
|
"""AI 플래너 결과 — examples 인덱스와 1:1 매칭(제주=0, 도쿄=1). 자유입력은 0으로 폴백."""
|
|
|
__tablename__ = "trip_plan"
|
|
|
id: str = Field(primary_key=True) # "plan_jeju" / "plan_tokyo"
|
|
|
example: str # 매칭 질의 원문
|
|
|
idx: int = 0 # examples 인덱스
|
|
|
title: str; meta: str = ""; summary: str = "" # summary: HTML 허용
|
|
|
weather: str = ""
|
|
|
transport: list = Field(default_factory=list, sa_column=Column(JSON)) # [{mode,name,desc,price,pick?,note?}]
|
|
|
stay: list = Field(default_factory=list, sa_column=Column(JSON)) # [{name,desc,price,unit,pick?}]
|
|
|
days: list = Field(default_factory=list, sa_column=Column(JSON)) # [{tab,items:[{t,title,tone}]}]
|
|
|
budget: dict = Field(default_factory=dict, sa_column=Column(JSON)) # {total,cap,rows:[{name,amt}]}
|
|
|
checklist: list = Field(default_factory=list, sa_column=Column(JSON)) # [str]
|
|
|
sources: list = Field(default_factory=list, sa_column=Column(JSON)) # [str]
|
|
|
```
|
|
|
|
|
|
> **JSON 컬럼 노트**: `counts/cross/bars/items/spark/transport` 등 중첩 배열은 원본 `*-data.js`가 이미 JSON-shaped다. SQLite는 JSON1 확장을 기본 지원하므로 `sa_column=Column(JSON)`으로 무손실 저장한다(phase-2의 단순 스칼라 컬럼과 달리 본 페이지는 표/차트 데이터가 중첩되어 JSON이 자연스럽다). 마이그레이션은 `import sqlmodel`을 versions 파일 상단에 둔다(phase-2 §3.3 노트).
|
|
|
>
|
|
|
> **`from`/`now` 예약어 회피**: 원본 `source.from`은 `from_label`(응답 alias `from`), `source.now`/`saved.now`는 그대로 `now` 컬럼(파이썬에서 컬럼명으로 사용 가능, 단 모듈 헬퍼 `now()`와 충돌하지 않게 인스턴스 속성으로만 접근).
|
|
|
|
|
|
### 3.2 RAG / 지식베이스용 테이블
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (rag 추가분)
|
|
|
class RagChunk(SQLModel, table=True):
|
|
|
"""ingest 된 자료의 청크 + 임베딩. vector store(SQLite)."""
|
|
|
__tablename__ = "rag_chunk"
|
|
|
id: str = Field(primary_key=True) # "chk_s1_0"
|
|
|
source_id: str = Field(foreign_key="research_source.id")
|
|
|
seq: int = 0 # 청크 순서
|
|
|
text: str # 청크 본문
|
|
|
part_label: str = "" # 근거 표시용 "‘인증·벨트’ 단락"
|
|
|
embedding: list = Field(default_factory=list, sa_column=Column(JSON)) # float[] (또는 sqlite-vec 컬럼)
|
|
|
dim: int = 0
|
|
|
model: str = "" # 임베딩 모델/heuristic 표기
|
|
|
created_at: datetime = Field(default_factory=now)
|
|
|
```
|
|
|
|
|
|
> `sqlite-vec`을 사용 가능하면 `embedding`을 vec0 가상 테이블로 두고 ANN 검색, 미가용 시 `embedding`을 JSON float 배열로 두고 **파이썬 코사인 유사도**로 폴백한다(post-mvp-overview의 "SQLite + sqlite-vec 또는 단순 코사인"). 두 경로 모두 동일한 `store.query(vec, k)` 인터페이스를 노출한다.
|
|
|
|
|
|
### 3.3 `routers/research.py`
|
|
|
|
|
|
phase-2 패턴: 라우터는 내부 prefix 없이 정의, `main.py`에서 `/api` 부착.
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/research.py
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
from sqlmodel import Session, select
|
|
|
from ..db import get_session
|
|
|
from ..models import (ResearchCollection, ResearchSource, ResearchReport,
|
|
|
ResearchQA, ResearchChart)
|
|
|
from ..schemas import (ResearchHomeOut, ReportOut, QAOut, ChartOut,
|
|
|
StartResearchRequest, StartResearchOut,
|
|
|
AskRequest, AskOut)
|
|
|
from ..agents.research_agent import run_research, answer_question
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
@router.get("/research/home", response_model=ResearchHomeOut)
|
|
|
def research_home(s: Session = Depends(get_session)):
|
|
|
"""사이드바(컬렉션·소스) + 입구 카드용 요약."""
|
|
|
cols = s.exec(select(ResearchCollection).order_by(ResearchCollection.sort_order)).all()
|
|
|
srcs = s.exec(select(ResearchSource).order_by(ResearchSource.sort_order)).all()
|
|
|
return ResearchHomeOut.build(cols, srcs)
|
|
|
|
|
|
|
|
|
@router.get("/research/report", response_model=ReportOut)
|
|
|
def get_report(s: Session = Depends(get_session)):
|
|
|
rp = s.exec(select(ResearchReport)).first()
|
|
|
if not rp:
|
|
|
raise HTTPException(404, "no report")
|
|
|
return ReportOut.from_model(rp)
|
|
|
|
|
|
|
|
|
@router.get("/research/qa", response_model=QAOut)
|
|
|
def get_qa(s: Session = Depends(get_session)):
|
|
|
qa = s.exec(select(ResearchQA)).first()
|
|
|
return QAOut.from_model(qa)
|
|
|
|
|
|
|
|
|
@router.get("/research/chart", response_model=ChartOut)
|
|
|
def get_chart(s: Session = Depends(get_session)):
|
|
|
c = s.exec(select(ResearchChart)).first()
|
|
|
return ChartOut.from_model(c)
|
|
|
|
|
|
|
|
|
@router.post("/research/start", response_model=StartResearchOut)
|
|
|
def start_research(req: StartResearchRequest, s: Session = Depends(get_session)):
|
|
|
"""자연어 조사 명령 → 에이전트 파이프라인(비동기 enqueue 또는 동기 골든).
|
|
|
|
|
|
데모: 시드 질의면 골든 리포트 즉시. 그 외에는 'queued'(끝나면 알림).
|
|
|
"""
|
|
|
return run_research(s, req.query)
|
|
|
|
|
|
|
|
|
@router.post("/research/ask", response_model=AskOut)
|
|
|
def ask(req: AskRequest, s: Session = Depends(get_session)):
|
|
|
"""지식 Q&A — RAG 검색 + 근거 답변."""
|
|
|
return answer_question(s, req.q, collection_id=req.collection_id)
|
|
|
```
|
|
|
|
|
|
### 3.4 `routers/trip.py`
|
|
|
|
|
|
```python
|
|
|
# backend/app/routers/trip.py
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
from sqlmodel import Session, select
|
|
|
from ..db import get_session
|
|
|
from ..models import Trip, TripRoute, TripStay, TripPrep, TripDay, TripChecklist, SavedTrip
|
|
|
from ..schemas import (UpcomingTripOut, SavedTripOut, WatchToggleRequest,
|
|
|
PlanRequest, PlanResultOut)
|
|
|
from ..agents.travel_agent import run_planner
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
@router.get("/trip/upcoming", response_model=UpcomingTripOut)
|
|
|
def upcoming(s: Session = Depends(get_session)):
|
|
|
"""다가오는 출장(부산) 전체: route/stay/prep/days/checklist/expense."""
|
|
|
trip = s.exec(select(Trip)).first()
|
|
|
if not trip:
|
|
|
raise HTTPException(404, "no trip")
|
|
|
return UpcomingTripOut.build(s, trip)
|
|
|
|
|
|
|
|
|
@router.get("/trip/saved", response_model=list[SavedTripOut])
|
|
|
def saved(s: Session = Depends(get_session)):
|
|
|
rows = s.exec(select(SavedTrip).order_by(SavedTrip.sort_order)).all()
|
|
|
return [SavedTripOut.from_model(r) for r in rows]
|
|
|
|
|
|
|
|
|
@router.post("/trip/saved/{sid}/watch", response_model=SavedTripOut)
|
|
|
def toggle_watch(sid: str, req: WatchToggleRequest, s: Session = Depends(get_session)):
|
|
|
"""가격 알림 토글(영속). 켜면 price_watch.armed → 목표가 도달 시 알림."""
|
|
|
r = s.get(SavedTrip, sid)
|
|
|
if not r:
|
|
|
raise HTTPException(404, "no saved trip")
|
|
|
r.watch = req.watch if req.watch is not None else (not r.watch)
|
|
|
s.add(r); s.commit(); s.refresh(r)
|
|
|
return SavedTripOut.from_model(r)
|
|
|
|
|
|
|
|
|
@router.post("/trip/plan", response_model=PlanResultOut)
|
|
|
def plan(req: PlanRequest, s: Session = Depends(get_session)):
|
|
|
"""AI 여행 플래너 실행 — 자연어 → 조사 → transport/stay/days/budget/checklist."""
|
|
|
return run_planner(s, req.text)
|
|
|
```
|
|
|
|
|
|
`main.py` 등록(phase-2 패턴):
|
|
|
|
|
|
```python
|
|
|
# backend/app/main.py (추가)
|
|
|
from .routers import research, trip
|
|
|
app.include_router(research.router, prefix="/api", tags=["research"])
|
|
|
app.include_router(trip.router, prefix="/api", tags=["trip"])
|
|
|
```
|
|
|
|
|
|
### 3.5 시드 — `_seed_research` / `_seed_trip`
|
|
|
|
|
|
원본 `research-data.js` / `trip-data.js`의 값을 **그대로** 적재한다. phase-2의 `run_seed(session=None, reset=True)` 안에서 호출.
|
|
|
|
|
|
```python
|
|
|
# backend/app/seed.py (추가분)
|
|
|
|
|
|
# ---- 리서치 (REF/assets/research-data.js) ----
|
|
|
RS_COLLECTIONS = [
|
|
|
("chip", "AI 반도체 시장", "blue", 12),
|
|
|
("onb", "온보딩 리서치", "violet", 8),
|
|
|
("stroller", "육아 — 유모차", "green", 5),
|
|
|
]
|
|
|
RS_SOURCES = [
|
|
|
("s1", "pdf", "2026 반도체 시장 전망 보고서", "산업연구원 · PDF 48p", "chip", True),
|
|
|
("s2", "web", "HBM4 양산 경쟁, 누가 앞섰나", "테크 뉴스 · 웹페이지", "chip", True),
|
|
|
("s3", "pdf", "추론 가속기 아키텍처 비교 (논문)", "arXiv · PDF 22p", "chip", True),
|
|
|
("s4", "note", "온보딩 인터뷰 메모 — 5건 요약", "내 메모", "onb", True),
|
|
|
("s5", "web", "신생아 유모차 안전 기준 정리", "육아 커뮤니티 · 웹페이지", "stroller", False),
|
|
|
]
|
|
|
RS_REPORT = dict(
|
|
|
id="rp1", title="최신 AI 반도체 시장 동향",
|
|
|
asked="“보고서, 논문, 뉴스 기사를 종합해서 알려줘”",
|
|
|
meta="출처 10곳 수집 · 3분 전 완성",
|
|
|
counts=[{"k": "논문", "n": 3, "tone": "violet"},
|
|
|
{"k": "뉴스", "n": 5, "tone": "blue"},
|
|
|
{"k": "보고서·블로그", "n": 2, "tone": "green"}],
|
|
|
synthesis=("세 출처군 모두 <b>추론(inference) 수요로의 무게 이동</b>에는 동의해요. "
|
|
|
"다만 학술 쪽은 <b>메모리 대역폭</b>을, 뉴스는 <b>HBM4 양산 경쟁</b>을, "
|
|
|
"산업 보고서는 <b>전력 효율 규제</b>를 핵심 변수로 봐요 — 투자 판단이라면 셋이 "
|
|
|
"겹치는 ‘메모리 중심 아키텍처’가 가장 합의된 신호예요."),
|
|
|
cross=[{"src": "학술 논문 (3편)", "tone": "violet",
|
|
|
"claim": "추론 비용의 병목은 연산이 아니라 메모리 대역폭", "stance": "신중 — 벤치마크 근거"},
|
|
|
{"src": "뉴스 기사 (5건)", "tone": "blue",
|
|
|
"claim": "HBM4 양산 시점이 올해 시장 점유율을 가른다", "stance": "낙관 — 업계 발표 인용"},
|
|
|
{"src": "산업 보고서 (2건)", "tone": "green",
|
|
|
"claim": "데이터센터 전력 규제가 칩 설계 방향을 바꾼다", "stance": "중립 — 정책 시나리오"}],
|
|
|
note="출처마다 이해관계가 달라요 — 뉴스 5건 중 3건은 제조사 발표 기반이라 교차 확인을 권해요.",
|
|
|
collection_id="chip",
|
|
|
)
|
|
|
RS_QA = dict(
|
|
|
id="qa1",
|
|
|
q="유모차, 신생아 때부터 쓰려면 뭘 봐야 하지?",
|
|
|
a=("저장해두신 자료 기준으로는 <b>풀플랫(완전 평탄) 시트</b>와 <b>양대면 전환</b>이 신생아 필수 "
|
|
|
"조건이에요. 안전 기준 글에서는 <b>KC 인증 + 5점식 벨트</b>를 최소선으로 꼽았고, 커뮤니티 "
|
|
|
"메모에선 차 트렁크 크기를 먼저 재라는 조언이 가장 많았어요."),
|
|
|
refs=[{"title": "신생아 유모차 안전 기준 정리", "part": "‘인증·벨트’ 단락"},
|
|
|
{"title": "유모차 실사용 후기 모음 (메모)", "part": "휴대성 비교 표"}],
|
|
|
collection_id="stroller",
|
|
|
)
|
|
|
RS_CHART = dict(
|
|
|
id="ch1", title="경쟁사 A — 연 매출 추이와 내년 예측",
|
|
|
asked="“지난 5년 매출로 성장률 차트 그리고, 내년 예측해줘”", unit="억원",
|
|
|
bars=[{"y": "2021", "v": 120}, {"y": "2022", "v": 158}, {"y": "2023", "v": 214},
|
|
|
{"y": "2024", "v": 252}, {"y": "2025", "v": 331}, {"y": "2026", "v": 412, "forecast": True}],
|
|
|
insight=("최근 5년 연평균 성장률(CAGR)은 <b>28.9%</b> — 2024년에 살짝 둔화(+18%)했다가 2025년 "
|
|
|
"신제품으로 반등했어요. 같은 흐름이면 내년 매출은 <b>약 412억(±9%)</b>으로 예측돼요."),
|
|
|
caution="단순 추세 외삽이에요 — 신규 규제·대형 수주 같은 이벤트는 반영되지 않아요.",
|
|
|
)
|
|
|
RS_PROMPTS = [
|
|
|
"최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘",
|
|
|
"신생아 유모차 구매 관련해서 정보 모아줘",
|
|
|
"경쟁사 A 매출 5년 추이 차트 그리고 내년 예측해줘",
|
|
|
]
|
|
|
|
|
|
|
|
|
def _seed_research(s: Session) -> None:
|
|
|
for i, (cid, name, tone, n) in enumerate(RS_COLLECTIONS):
|
|
|
s.add(ResearchCollection(id=cid, name=name, tone=tone, n=n, sort_order=i))
|
|
|
for i, (sid, kind, title, frm, col, learned) in enumerate(RS_SOURCES):
|
|
|
s.add(ResearchSource(id=sid, kind=SourceKind(kind), title=title,
|
|
|
from_label=frm, col=col, learned=learned, sort_order=i))
|
|
|
s.add(ResearchReport(**RS_REPORT))
|
|
|
s.add(ResearchQA(**RS_QA))
|
|
|
s.add(ResearchChart(**RS_CHART))
|
|
|
s.commit()
|
|
|
# prompts 는 settings/상수 테이블 없이 응답 헬퍼에서 RS_PROMPTS 상수로 노출(or research_prompt 테이블)
|
|
|
# RAG: learned=True 인 소스만 ingest (학습 완료 자료가 곧 위키)
|
|
|
from .rag.pipeline import ingest_seed_sources
|
|
|
ingest_seed_sources(s)
|
|
|
```
|
|
|
|
|
|
> **RAG 시드 ingest**: `learned=True`인 소스(s1~s4)만 청크/임베딩하여 `rag_chunk`에 적재한다(`s5`는 `learned=false` → 학습 중, Q&A 근거에서 제외 가능하나 데모 Q&A `qa1`의 근거는 유모차 안전 기준/후기 메모이므로 시드 청크에 포함). 근거 표시용 `part_label`은 `qa1.refs[].part`("‘인증·벨트’ 단락", "휴대성 비교 표")와 정확히 일치하도록 시드한다.
|
|
|
|
|
|
```python
|
|
|
# ---- 여행 (REF/assets/trip-data.js) ----
|
|
|
TRIP = dict(
|
|
|
id="tp_busan", dday="D-4", title="부산 출장",
|
|
|
dates="6월 16일(월) – 17일(화) · 1박 2일",
|
|
|
purpose="부산 지사 · 분기 리포트 발표",
|
|
|
brief="표·숙소·일정 정리는 끝났어요. 발표자료 최종본만 챙기면 돼요.",
|
|
|
weather=[{"d": "16일", "t": "맑음 26°", "icon": "sun"},
|
|
|
{"d": "17일", "t": "오후 비 40% · 24°", "icon": "rain"}],
|
|
|
expense_budget=400000, expense_planned=331600,
|
|
|
expense_note="법인카드 결제 영수증은 아리가 모아서 돌아온 다음 날 정산서 초안을 만들어둘게요.",
|
|
|
expense_rows=[{"name": "KTX 왕복", "amt": "119,600", "state": "paid"},
|
|
|
{"name": "숙소 1박", "amt": "132,000", "state": "hold"},
|
|
|
{"name": "식비 · 이동 예상", "amt": "80,000", "state": "est"}],
|
|
|
)
|
|
|
TRIP_ROUTES = [
|
|
|
("rt_out", "out", "KTX 101", "서울역", "07:00", "부산역", "09:42", "4호차 4A · 창측", "승차권 발급 완료"),
|
|
|
("rt_back", "back", "KTX 158", "부산역", "18:00", "서울역", "20:40", "7호차 11C", "변경 가능 좌석"),
|
|
|
]
|
|
|
TRIP_STAY = ("st_busan", "스테이 부산역", "부산역 도보 5분 · 지사 도보 12분 · 체크인 15:00", "예약 확정")
|
|
|
TRIP_PREP = [
|
|
|
("KTX 왕복 예매 — 환승 없는 가장 빠른 편", "done"),
|
|
|
("숙소 예약 — 역·지사 모두 도보권", "done"),
|
|
|
("16일 오전 정기 회의 2건 → 수요일로 이동", "done"),
|
|
|
("‘발표자료 최종본’ 마감 작업 생성 (D-2)", "done"),
|
|
|
("법인카드 한도 확인 → 정산서 양식 준비", "doing"),
|
|
|
]
|
|
|
TRIP_DAYS = [
|
|
|
("d1", "16일 (월)", [
|
|
|
{"t": "07:00", "kind": "move", "tone": "blue", "title": "KTX 서울 → 부산", "meta": "09:42 도착 · 4호차 4A · 발표 리허설 노트 준비됨"},
|
|
|
{"t": "10:00", "kind": "meet", "tone": "violet", "title": "부산 지사 도착 · 팀 인사", "meta": "지사 4층 · 안내: 박지원 매니저"},
|
|
|
{"t": "11:00", "kind": "talk", "tone": "coral", "hot": True, "title": "분기 리포트 발표", "meta": "대회의실 · 45분 · 발표자료 v3 연결됨"},
|
|
|
{"t": "12:30", "kind": "meal", "tone": "green", "title": "점심 — 지사 팀과", "meta": "초량 밀면집 · 4명 예약됨"},
|
|
|
{"t": "14:00", "kind": "meet", "tone": "violet", "title": "온보딩 개선 워크숍", "meta": "부산 CS팀 합류 · 90분 · 액션은 작업으로 자동 전송"},
|
|
|
{"t": "18:30", "kind": "meal", "tone": "green", "title": "저녁 자유 시간", "meta": "아리 추천: 전포 카페거리 · 광안리 야경"},
|
|
|
]),
|
|
|
("d2", "17일 (화)", [
|
|
|
{"t": "09:30", "kind": "meet", "tone": "violet", "title": "지사장 1:1", "meta": "지난 분기 협업 회고 노트 준비됨"},
|
|
|
{"t": "11:00", "kind": "meet", "tone": "blue", "title": "발표 후속 Q&A 정리", "meta": "결정 사항·액션 아이템 → 작업으로 전송"},
|
|
|
{"t": "13:00", "kind": "meal", "tone": "green", "title": "점심 후 자유 시간", "meta": "우산 챙기세요 — 오후 비 40%"},
|
|
|
{"t": "15:30", "kind": "move", "tone": "blue", "title": "부산역으로 이동", "meta": "체크아웃 짐은 역 물품보관함 추천"},
|
|
|
{"t": "18:00", "kind": "move", "tone": "blue", "title": "KTX 부산 → 서울", "meta": "20:40 도착 · 내일 아침 브리핑에 출장 요약"},
|
|
|
]),
|
|
|
]
|
|
|
TRIP_CHECK = [ # (id, group, text, auto)
|
|
|
("c1", "서류 · 결제", "신분증 · 법인카드", ""),
|
|
|
("c2", "서류 · 결제", "출장 신청서", "아리가 제출 완료"),
|
|
|
("c3", "발표", "노트북 · 충전기", ""),
|
|
|
("c4", "발표", "HDMI 어댑터", ""),
|
|
|
("c5", "발표", "발표자료 USB 백업", "클라우드 백업됨"),
|
|
|
("c6", "짐", "1박 짐 · 세면도구", ""),
|
|
|
("c7", "짐", "접이식 우산", "17일 비 예보로 추가"),
|
|
|
]
|
|
|
SAVED_TRIPS = [
|
|
|
("sv1", "제주 가족 여행", "가족 · 3박 4일", "7/24(금) 출발이 가장 한적하고 저렴해요.",
|
|
|
"89,000원", "−12%", True, True, "김포 → 제주 · 성인 1인 최저가 · 목표가 8만원",
|
|
|
[120, 118, 112, 108, 104, 98, 89]),
|
|
|
("sv2", "강릉 주말 휴식", "혼자 · 1박 2일", "바다뷰 숙소가 주말마다 빨리 차요. 2주 전 예약 추천.",
|
|
|
"112,000원", "+4%", False, True, "KTX 왕복 + 1박 패키지 · 목표가 10만원",
|
|
|
[98, 101, 104, 100, 106, 108, 112]),
|
|
|
("sv3", "도쿄 자유여행", "커플 · 4박 5일", "엔저가 이어지는 동안이 기회예요. 9월 초가 항공 최저.",
|
|
|
"312,000원", "−7%", True, False, "인천 → 나리타 왕복 · 성인 1인 · 목표가 30만원",
|
|
|
[360, 352, 348, 340, 330, 322, 312]),
|
|
|
]
|
|
|
# planner.results — examples 인덱스와 1:1 (제주=0, 도쿄=1). 원본 값 전체 이식(생략 없이).
|
|
|
TRIP_PLANS = [
|
|
|
dict(id="plan_jeju", idx=0, example="다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원",
|
|
|
title="제주 가족여행", meta="3박 4일 · 7월 24일(금) – 27일(월) · 성인 2 + 아동 1",
|
|
|
summary=("성수기 직전 금요일 출발이 가장 한적하고 저렴해요. 렌터카 + 서귀포 중심 숙소 기준으로 "
|
|
|
"<b>예산 150만원 안에</b> 들어옵니다."),
|
|
|
weather="여행 기간 대체로 맑음 · 한낮 29° · 26일 오후 소나기 가능",
|
|
|
transport=[{"mode": "항공", "name": "김포 → 제주 (왕복)", "desc": "07:30 출발 · 1시간 5분 · 직항", "price": "356,000", "pick": True, "note": "3인 합계 · 최저가"},
|
|
|
{"mode": "렌터카", "name": "준중형 SUV · 3일", "desc": "공항 픽업 · 완전자차 포함", "price": "187,000", "pick": True}],
|
|
|
stay=[{"name": "서귀포 오션 리조트", "desc": "중문 해변 도보 8분 · 조식 포함 · 평점 4.7", "price": "168,000", "unit": "/박", "pick": True},
|
|
|
{"name": "제주시 시티 호텔", "desc": "공항 15분 · 동문시장 인근 · 평점 4.4", "price": "121,000", "unit": "/박"}],
|
|
|
days=[{"tab": "1일차", "items": [{"t": "오전", "title": "김포 → 제주 · 렌터카 픽업", "tone": "blue"}, {"t": "점심", "title": "공항 근처 고기국수", "tone": "green"}, {"t": "오후", "title": "용두암 · 이호테우 해변 산책", "tone": "violet"}, {"t": "저녁", "title": "서귀포 숙소 체크인 · 중문 일몰", "tone": "coral"}]},
|
|
|
{"tab": "2일차", "items": [{"t": "종일", "title": "한라산 영실 코스 (아이 동반 완만)", "tone": "green"}, {"t": "오후", "title": "카멜리아힐 정원", "tone": "violet"}, {"t": "저녁", "title": "흑돼지 거리", "tone": "coral"}]},
|
|
|
{"tab": "3일차", "items": [{"t": "오전", "title": "아쿠아플라넷 (실내 · 비 와도 OK)", "tone": "blue"}, {"t": "오후", "title": "성산일출봉 · 우도 페리", "tone": "violet"}, {"t": "저녁", "title": "성산 해산물", "tone": "coral"}]},
|
|
|
{"tab": "4일차", "items": [{"t": "오전", "title": "동문시장 기념품 · 렌터카 반납", "tone": "green"}, {"t": "오후", "title": "제주 → 김포", "tone": "blue"}]}],
|
|
|
budget={"total": "1,287,000", "cap": "1,500,000",
|
|
|
"rows": [{"name": "항공 (3인 왕복)", "amt": "356,000"}, {"name": "숙소 (3박)", "amt": "504,000"}, {"name": "렌터카 (3일)", "amt": "187,000"}, {"name": "식비·입장료 예상", "amt": "240,000"}]},
|
|
|
checklist=["신분증 · 항공권", "아이 상비약 · 자외선 차단제", "여벌 옷 · 우산", "카시트 (렌터카 옵션 신청 완료)"],
|
|
|
sources=["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "기상청 중기예보", "여행 리뷰 240건"]),
|
|
|
dict(id="plan_tokyo", idx=1, example="도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘",
|
|
|
title="도쿄 출장", meta="2박 3일 · 9월 8일(월) – 10일(수) · 성인 1",
|
|
|
summary=("시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. <b>엔저가 이어지는 동안</b>이 "
|
|
|
"항공·숙박 모두 유리합니다."),
|
|
|
weather="초가을 · 한낮 28° · 9일 오후 비 50% (우산 권장)",
|
|
|
transport=[{"mode": "항공", "name": "인천 → 하네다 (왕복)", "desc": "08:40 출발 · 2시간 20분 · 직항", "price": "287,000", "pick": True, "note": "공항이 시내와 가까워요"},
|
|
|
{"mode": "교통", "name": "스이카 충전 + 공항버스", "desc": "시부야까지 환승 없이", "price": "32,000", "pick": True}],
|
|
|
stay=[{"name": "시부야 스테이션 호텔", "desc": "역 도보 4분 · 회의장 2정거장 · 평점 4.6", "price": "142,000", "unit": "/박", "pick": True},
|
|
|
{"name": "신주쿠 비즈니스 호텔", "desc": "역 도보 6분 · 조식 포함 · 평점 4.3", "price": "118,000", "unit": "/박"}],
|
|
|
days=[{"tab": "1일차", "items": [{"t": "오전", "title": "인천 → 하네다 · 호텔 체크인", "tone": "blue"}, {"t": "오후", "title": "지사 방문 · 인사 미팅", "tone": "violet"}, {"t": "저녁", "title": "시부야 거래처 회식", "tone": "coral"}]},
|
|
|
{"tab": "2일차", "items": [{"t": "오전", "title": "분기 리뷰 발표 (핵심 일정)", "tone": "coral"}, {"t": "오후", "title": "워크숍 · 액션 정리 → 작업으로", "tone": "violet"}, {"t": "저녁", "title": "자유 시간 · 우산 챙기기", "tone": "green"}]},
|
|
|
{"tab": "3일차", "items": [{"t": "오전", "title": "후속 미팅 · 체크아웃", "tone": "violet"}, {"t": "오후", "title": "하네다 → 인천", "tone": "blue"}]}],
|
|
|
budget={"total": "713,000", "cap": "900,000",
|
|
|
"rows": [{"name": "항공 왕복", "amt": "287,000"}, {"name": "숙소 (2박)", "amt": "284,000"}, {"name": "현지 교통", "amt": "42,000"}, {"name": "식비 예상", "amt": "100,000"}]},
|
|
|
checklist=["여권 (유효기간 6개월+)", "노트북 · 발표자료 백업", "해외로밍 / eSIM", "접이식 우산 · 명함"],
|
|
|
sources=["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]),
|
|
|
]
|
|
|
PLANNER_RESEARCH = [ # 조사 진행 단계(애니메이션 + scripted 파이프라인 단계와 1:1)
|
|
|
{"icon": "plane", "label": "항공·교통편 검색", "detail": "32개 노선 가격 비교"},
|
|
|
{"icon": "bed", "label": "숙소 비교", "detail": "위치·평점·예산 교차 분석"},
|
|
|
{"icon": "sun", "label": "날씨·시즌·혼잡도 확인", "detail": "기상 예보 + 성수기 캘린더"},
|
|
|
{"icon": "pin", "label": "동선·맛집 정리", "detail": "리뷰 240건 요약"},
|
|
|
]
|
|
|
|
|
|
|
|
|
def _seed_trip(s: Session) -> None:
|
|
|
s.add(Trip(**TRIP))
|
|
|
for r in TRIP_ROUTES:
|
|
|
rid, d, mode, fs, ft, ts, tt, seat, note = r
|
|
|
s.add(TripRoute(id=rid, trip_id="tp_busan", dir=d, mode=mode,
|
|
|
from_st=fs, ft=ft, to_st=ts, tt=tt, seat=seat, note=note))
|
|
|
sid, name, desc, conf = TRIP_STAY
|
|
|
s.add(TripStay(id=sid, trip_id="tp_busan", name=name, desc=desc, conf=conf))
|
|
|
for i, (text, state) in enumerate(TRIP_PREP):
|
|
|
s.add(TripPrep(id=f"pp{i+1}", trip_id="tp_busan", text=text, state=state, sort_order=i))
|
|
|
for i, (did, tab, items) in enumerate(TRIP_DAYS):
|
|
|
s.add(TripDay(id=did, trip_id="tp_busan", tab=tab, items=items, sort_order=i))
|
|
|
for i, (cid, grp, text, auto) in enumerate(TRIP_CHECK):
|
|
|
s.add(TripChecklist(id=cid, trip_id="tp_busan", group=grp, text=text, auto=auto, sort_order=i))
|
|
|
for i, row in enumerate(SAVED_TRIPS):
|
|
|
sv_id, title, tag, note, nw, delta, down, watch, hint, spark = row
|
|
|
s.add(SavedTrip(id=sv_id, title=title, tag=tag, note=note, now=nw, delta=delta,
|
|
|
down=down, watch=watch, hint=hint, spark=spark, sort_order=i))
|
|
|
for p in TRIP_PLANS:
|
|
|
s.add(TripPlan(**p))
|
|
|
s.commit()
|
|
|
```
|
|
|
|
|
|
`_run()`(phase-2)에 통합 — reset 목록에 신규 테이블 추가:
|
|
|
|
|
|
```python
|
|
|
def _run(s: Session, reset: bool) -> None:
|
|
|
if reset:
|
|
|
for tbl in (..., RagChunk, TripPlan, SavedTrip, TripChecklist, TripDay,
|
|
|
TripPrep, TripStay, TripRoute, Trip,
|
|
|
ResearchChart, ResearchQA, ResearchReport, ResearchSource, ResearchCollection):
|
|
|
for row in s.exec(select(tbl)).all():
|
|
|
s.delete(row)
|
|
|
s.commit()
|
|
|
# ... 기존 MVP 시드 ...
|
|
|
_seed_research(s)
|
|
|
_seed_trip(s)
|
|
|
s.commit()
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 상세 구현 — 에이전트 오케스트레이션
|
|
|
|
|
|
`backend/app/agents/` — LLM provider 위의 멀티스텝 루프(post-mvp-overview 명명: `plan→act(tool)→observe→reflect`). **tool-capable 모델** 사용, 미지원/오프라인 시 **scripted 파이프라인** 폴백으로 데모 결정성을 보장한다.
|
|
|
|
|
|
### 4.1 Tool 인터페이스 (`agents/tools/base.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/tools/base.py
|
|
|
from __future__ import annotations
|
|
|
from abc import ABC, abstractmethod
|
|
|
from dataclasses import dataclass, field
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class ToolResult:
|
|
|
ok: bool
|
|
|
data: Any = None
|
|
|
error: str = ""
|
|
|
meta: dict = field(default_factory=dict) # {source_count, latency_ms, ...}
|
|
|
|
|
|
|
|
|
class Tool(ABC):
|
|
|
name: str # "web_search" | "rag_query" | "http_fetch" | "task_create" | "calendar_write"
|
|
|
description: str # LLM tool-spec 용 한국어/영어 설명
|
|
|
schema: dict # JSON Schema (인자)
|
|
|
|
|
|
@abstractmethod
|
|
|
def run(self, **kwargs) -> ToolResult: ...
|
|
|
```
|
|
|
|
|
|
### 4.2 web_search 툴 (`agents/tools/web_search.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/tools/web_search.py
|
|
|
import os
|
|
|
from .base import Tool, ToolResult
|
|
|
from ..connectors_bridge import get_knowledge_connector # CONNECTOR_KNOWLEDGE 라우팅
|
|
|
|
|
|
|
|
|
class WebSearchTool(Tool):
|
|
|
name = "web_search"
|
|
|
description = "웹/논문/뉴스를 검색해 출처 객체 목록을 돌려준다."
|
|
|
schema = {"type": "object", "properties": {
|
|
|
"query": {"type": "string"}, "k": {"type": "integer", "default": 8}}}
|
|
|
|
|
|
def run(self, query: str, k: int = 8) -> ToolResult:
|
|
|
conn = get_knowledge_connector() # mock(기본) | real(phase-13)
|
|
|
try:
|
|
|
hits = conn.search(query, k=k) # [{title, url, kind, snippet, group}]
|
|
|
return ToolResult(ok=True, data=hits, meta={"source_count": len(hits)})
|
|
|
except Exception as e:
|
|
|
return ToolResult(ok=False, error=str(e))
|
|
|
```
|
|
|
|
|
|
> `CONNECTOR_KNOWLEDGE=mock`(기본)이면 `MockSearchConnector`가 시드 기반 출처(`research-data.js`의 출처군 — 논문 3/뉴스 5/보고서 2)를 반환해 **오프라인에서도** 종합 리포트가 생성된다. phase-13이 `real`(예: Tavily/검색 API)을 같은 인터페이스로 끼운다. `WEB_SEARCH_PROVIDER`로 provider 세부 선택.
|
|
|
|
|
|
### 4.3 다른 툴
|
|
|
|
|
|
| 툴 | 파일 | 책임 | 폴백/stub |
|
|
|
|---|---|---|---|
|
|
|
| `rag_query` | `tools/rag_query.py` | `rag/pipeline.query(q,k)` 호출 → 청크+근거 | heuristic 코사인(임베딩 미가용) |
|
|
|
| `http_fetch` | `tools/http_fetch.py` | URL → 본문 텍스트(웹/PDF) | mock 모드는 시드 소스 본문 반환, 네트워크 미가용 시 빈 결과 |
|
|
|
| `task_create` | `tools/task_create.py` | 분류된 액션을 `task`로 실체화(federation) — phase-2 `POST /api/tasks` 동일 경로 | 항상 동작(로컬 DB) |
|
|
|
| `calendar_write` | `tools/calendar_write.py` | 여행/리서치 일정 → `event`(phase-8) 쓰기 | **stub**: 미구현 구간은 결과만 기록(phase-8 연동점) |
|
|
|
|
|
|
### 4.4 Agent 루프 (`agents/base.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/base.py
|
|
|
from __future__ import annotations
|
|
|
from dataclasses import dataclass, field
|
|
|
from typing import Callable
|
|
|
from .tools.base import Tool, ToolResult
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class AgentStep:
|
|
|
phase: str # "plan" | "act" | "observe" | "reflect"
|
|
|
tool: str = ""
|
|
|
note: str = ""
|
|
|
result: ToolResult | None = None
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class AgentResult:
|
|
|
ok: bool
|
|
|
output: dict = field(default_factory=dict) # 파이프라인 산출물(리포트/플랜)
|
|
|
steps: list = field(default_factory=list) # 관측 가능성: 단계 로그
|
|
|
fallback: bool = False # scripted 폴백 사용 여부
|
|
|
model: str = "" # 사용 provider/model
|
|
|
|
|
|
|
|
|
class Agent:
|
|
|
"""plan→act(tool)→observe→reflect 멀티스텝 루프.
|
|
|
|
|
|
tool-capable 모델이면 LLM이 tool call 을 발화 → 실행 → 관측 → 반성.
|
|
|
미가용/예외 시 scripted 파이프라인으로 폴백(데모 결정성).
|
|
|
"""
|
|
|
def __init__(self, tools: dict[str, Tool], provider, scripted: Callable, max_steps: int = 6):
|
|
|
self.tools = tools
|
|
|
self.provider = provider # tool-capable LLMProvider (None 가능)
|
|
|
self.scripted = scripted # 결정적 폴백 함수(s, query)->AgentResult
|
|
|
self.max_steps = max_steps
|
|
|
|
|
|
def run(self, *, session, query: str) -> AgentResult:
|
|
|
if self.provider is None or not self.provider.tool_capable():
|
|
|
return self.scripted(session, query) # 폴백
|
|
|
try:
|
|
|
return self._loop(session, query)
|
|
|
except Exception:
|
|
|
r = self.scripted(session, query)
|
|
|
r.fallback = True
|
|
|
return r
|
|
|
|
|
|
def _loop(self, session, query: str) -> AgentResult:
|
|
|
steps: list[AgentStep] = []
|
|
|
plan = self.provider.plan(query, list(self.tools.values())) # 계획 수립
|
|
|
steps.append(AgentStep(phase="plan", note=plan.summary))
|
|
|
scratch = {}
|
|
|
for _ in range(self.max_steps):
|
|
|
action = self.provider.next_action(query, plan, scratch) # tool 선택
|
|
|
if action.final:
|
|
|
break
|
|
|
tool = self.tools[action.tool]
|
|
|
res = tool.run(**action.args) # act
|
|
|
steps.append(AgentStep(phase="act", tool=tool.name, result=res))
|
|
|
scratch[tool.name] = res.data # observe
|
|
|
steps.append(AgentStep(phase="observe", tool=tool.name,
|
|
|
note=f"{res.meta.get('source_count','')} hits"))
|
|
|
output = self.provider.synthesize(query, scratch) # reflect/synthesize
|
|
|
steps.append(AgentStep(phase="reflect", note="synthesis done"))
|
|
|
return AgentResult(ok=True, output=output, steps=steps,
|
|
|
model=self.provider.model)
|
|
|
```
|
|
|
|
|
|
### 4.5 리서치 에이전트 (`agents/research_agent.py`)
|
|
|
|
|
|
파이프라인: `web_search` + `rag_query` → **cross-analysis**(출처군별 주장/논조) → **synthesis**(종합 문단 + 편향 노트).
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/research_agent.py
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import ResearchReport, ResearchQA
|
|
|
from ..schemas import StartResearchOut, AskOut
|
|
|
from ..rag.pipeline import query as rag_query_fn
|
|
|
from .base import Agent
|
|
|
from .registry import get_agent_provider
|
|
|
from .tools.web_search import WebSearchTool
|
|
|
from .tools.rag_query import RagQueryTool
|
|
|
from . import scripted
|
|
|
|
|
|
SEED_QUERIES = {
|
|
|
"최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘": "rp1",
|
|
|
"최신 AI 반도체 시장 동향": "rp1",
|
|
|
}
|
|
|
|
|
|
|
|
|
def run_research(s: Session, query: str) -> StartResearchOut:
|
|
|
"""조사 시작. 데모: 시드 질의는 골든 리포트 즉시 반환, 그 외 queued."""
|
|
|
q = query.strip()
|
|
|
# 1) 시드(골든) 질의 → 즉시 완성 리포트(결정성)
|
|
|
for key, rid in SEED_QUERIES.items():
|
|
|
if q == key or q.startswith("최신 AI 반도체"):
|
|
|
rp = s.get(ResearchReport, rid)
|
|
|
return StartResearchOut(status="ready", report_id=rid,
|
|
|
report=_report_out(rp), queued_text=None)
|
|
|
# 2) 그 외 → 에이전트 파이프라인(tool-capable 또는 scripted)
|
|
|
agent = Agent(tools={"web_search": WebSearchTool(), "rag_query": RagQueryTool(s)},
|
|
|
provider=get_agent_provider(),
|
|
|
scripted=scripted.research_pipeline)
|
|
|
res = agent.run(session=s, query=q)
|
|
|
if res.output.get("ready"):
|
|
|
return StartResearchOut(status="ready", report=res.output["report"],
|
|
|
report_id=res.output.get("report_id"), queued_text=None)
|
|
|
# 3) 길어지면 queued — "끝나면 알림으로 알려드릴게요"(원본 rs-queued 문구)
|
|
|
return StartResearchOut(
|
|
|
status="queued", report=None, report_id=None,
|
|
|
queued_text=f"“{q}” — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요.")
|
|
|
|
|
|
|
|
|
def answer_question(s: Session, q: str, collection_id: str | None = None) -> AskOut:
|
|
|
"""지식 Q&A — RAG 검색 + 근거 답변."""
|
|
|
qn = q.strip()
|
|
|
# 시드 질문은 골든 답(결정성). refs 는 rag_query 가 찾은 청크의 part_label 로 채움.
|
|
|
seed = s.exec(select(ResearchQA)).first()
|
|
|
if seed and (qn == seed.q or "유모차" in qn):
|
|
|
return AskOut(a=seed.a, refs=seed.refs, grounded=True, model="seed")
|
|
|
hits = rag_query_fn(s, qn, k=4, collection_id=collection_id) # [{text, source_id, part_label, score}]
|
|
|
if not hits:
|
|
|
return AskOut(a="저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요.",
|
|
|
refs=[], grounded=False, model="rag")
|
|
|
prov = get_agent_provider()
|
|
|
answer = (prov.answer_with_context(qn, hits) if prov else
|
|
|
_extractive_answer(qn, hits)) # 폴백: 발췌형 답
|
|
|
refs = [{"title": h["title"], "part": h["part_label"]} for h in hits[:2]]
|
|
|
return AskOut(a=answer, refs=refs, grounded=True,
|
|
|
model=(prov.model if prov else "extractive"))
|
|
|
```
|
|
|
|
|
|
### 4.6 여행 에이전트 (`agents/travel_agent.py`)
|
|
|
|
|
|
파이프라인: 검색 → **옵션(transport/stay)** · **일정(days)** · **경비(budget)** · **준비물(checklist)** 생성. 예시 제주·도쿄는 **완성 결과를 그대로 반환**(결정성), 자유 입력은 0번(제주)으로 폴백하되 `title`을 입력에서 잘라 덮어쓴다(원본 `pickResult` 로직 미러링).
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/travel_agent.py
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import TripPlan
|
|
|
from ..schemas import PlanResultOut
|
|
|
from .base import Agent
|
|
|
from .registry import get_agent_provider
|
|
|
from .tools.web_search import WebSearchTool
|
|
|
from . import scripted
|
|
|
|
|
|
|
|
|
def _pick_plan(s: Session, text: str) -> tuple[TripPlan, bool]:
|
|
|
"""원본 trip.jsx pickResult 미러:
|
|
|
examples 와 정확히 일치하면 그 결과, 아니면 idx=0(제주) + title 덮어쓰기."""
|
|
|
t = text.strip()
|
|
|
plans = {p.example: p for p in s.exec(select(TripPlan)).all()}
|
|
|
if t in plans:
|
|
|
return plans[t], False # custom=False
|
|
|
base = s.get(TripPlan, "plan_jeju") # idx<0 폴백 → 0번
|
|
|
return base, True # custom=True
|
|
|
|
|
|
|
|
|
def run_planner(s: Session, text: str) -> PlanResultOut:
|
|
|
plan, custom = _pick_plan(s, text)
|
|
|
out = PlanResultOut.from_model(plan)
|
|
|
if custom and text.strip():
|
|
|
out.title = text.strip()[:18] # 원본: q.trim().slice(0,18)
|
|
|
out.custom = True
|
|
|
# tool-capable 모델이 켜져 있으면 검색 단계를 실제로 돌려 sources/가격을 갱신할 수 있으나,
|
|
|
# 데모 결정성 우선 — 예시 2개는 항상 골든 결과. (research 단계 4종은 프론트 애니메이션이 표현)
|
|
|
return out
|
|
|
```
|
|
|
|
|
|
### 4.7 scripted 폴백 (`agents/scripted.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/scripted.py
|
|
|
"""tool-capable 모델 미가용/오프라인/CI 용 결정적 파이프라인.
|
|
|
|
|
|
- research_pipeline: 시드 리포트 골든을 ready 로 반환. 시드 외 질의는 queued.
|
|
|
- (travel 은 travel_agent._pick_plan 이 직접 결정적 — 별도 scripted 불필요)
|
|
|
"""
|
|
|
from sqlmodel import Session
|
|
|
from ..models import ResearchReport
|
|
|
from .base import AgentResult
|
|
|
|
|
|
|
|
|
def research_pipeline(s: Session, query: str) -> AgentResult:
|
|
|
rp = s.get(ResearchReport, "rp1")
|
|
|
q = query.strip()
|
|
|
if q.startswith("최신 AI 반도체"):
|
|
|
return AgentResult(ok=True, fallback=True, model="scripted",
|
|
|
output={"ready": True, "report_id": "rp1",
|
|
|
"report": _report_dict(rp)})
|
|
|
# 그 외 — 결정적으로 'queued'(데모: 끝나면 알림)
|
|
|
return AgentResult(ok=True, fallback=True, model="scripted", output={"ready": False})
|
|
|
```
|
|
|
|
|
|
### 4.8 provider registry (`agents/registry.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/agents/registry.py
|
|
|
import os
|
|
|
from ..llm.provider import get_provider # phase-2 LLMProvider
|
|
|
|
|
|
|
|
|
def get_agent_provider():
|
|
|
"""AGENT_PROVIDER = auto | tool | scripted.
|
|
|
tool-capable 모델이 가용하고 reachable 이면 ToolCapableProvider, 아니면 None(→scripted)."""
|
|
|
mode = os.getenv("AGENT_PROVIDER", "auto")
|
|
|
if mode == "scripted":
|
|
|
return None
|
|
|
base = get_provider() # OllamaProvider | HeuristicProvider
|
|
|
if base.health().get("reachable") and getattr(base, "tool_capable", lambda: False)():
|
|
|
return base
|
|
|
if mode == "tool":
|
|
|
return base # 강제 — 단 미지원이면 Agent.run 이 예외→scripted
|
|
|
return None # auto: 미가용이면 None → scripted 폴백
|
|
|
```
|
|
|
|
|
|
> `LLMProvider`에 `tool_capable() -> bool` 메서드를 추가한다(기본 `False`). `OllamaProvider`는 `/api/chat`의 tools 지원 모델일 때만 `True`. 이로써 **특정 모델 강제 금지**(env 주입) 규약을 유지하면서 tool 사용 여부만 능력 기반으로 판정한다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 상세 구현 — RAG / 지식베이스
|
|
|
|
|
|
`backend/app/rag/` — `ingest(pdf/web/note)→chunk→embed→vector store→query`. 지식 Q&A가 **근거 링크와 함께** 답한다.
|
|
|
|
|
|
### 5.1 임베딩 추상화 (`rag/embed.py`)
|
|
|
|
|
|
```python
|
|
|
# backend/app/rag/embed.py
|
|
|
import os, math
|
|
|
from abc import ABC, abstractmethod
|
|
|
import httpx
|
|
|
from ..config import get_settings
|
|
|
|
|
|
|
|
|
class EmbeddingProvider(ABC):
|
|
|
name: str
|
|
|
dim: int
|
|
|
@abstractmethod
|
|
|
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
|
|
|
|
|
|
|
class OllamaEmbedding(EmbeddingProvider):
|
|
|
"""Ollama embeddings API. EMBED_MODEL 주입(모델 비종속). EMBED_PROVIDER=ollama 일 때 선택."""
|
|
|
def __init__(self):
|
|
|
st = get_settings()
|
|
|
self.host = st.ollama_host
|
|
|
self.model = os.getenv("EMBED_MODEL", "nomic-embed-text") # placeholder, env 교체
|
|
|
self.name = f"ollama:{self.model}"
|
|
|
self.dim = 0
|
|
|
def embed(self, texts):
|
|
|
out = []
|
|
|
with httpx.Client(timeout=20.0) as c:
|
|
|
for t in texts:
|
|
|
r = c.post(f"{self.host}/api/embeddings",
|
|
|
json={"model": self.model, "prompt": t})
|
|
|
r.raise_for_status()
|
|
|
v = r.json()["embedding"]; self.dim = len(v); out.append(v)
|
|
|
return out
|
|
|
|
|
|
|
|
|
class HeuristicEmbedding(EmbeddingProvider):
|
|
|
"""오프라인/CI 폴백 — TF 해시 임베딩(코사인 검색용). 결정적."""
|
|
|
name = "heuristic-tfhash"
|
|
|
def __init__(self, dim: int = 256): self.dim = dim
|
|
|
def embed(self, texts):
|
|
|
vecs = []
|
|
|
for t in texts:
|
|
|
v = [0.0] * self.dim
|
|
|
for tok in _tokenize(t):
|
|
|
v[hash(tok) % self.dim] += 1.0
|
|
|
n = math.sqrt(sum(x * x for x in v)) or 1.0
|
|
|
vecs.append([x / n for x in v])
|
|
|
return vecs
|
|
|
|
|
|
|
|
|
def get_embedding_provider() -> EmbeddingProvider:
|
|
|
provider = os.getenv("EMBED_PROVIDER", "ollama") # ollama | heuristic
|
|
|
if provider == "heuristic":
|
|
|
return HeuristicEmbedding()
|
|
|
try:
|
|
|
p = OllamaEmbedding()
|
|
|
p.embed(["헬스체크"]) # reachable 확인
|
|
|
return p
|
|
|
except Exception:
|
|
|
return HeuristicEmbedding() # 폴백
|
|
|
|
|
|
|
|
|
def _tokenize(t: str) -> list[str]:
|
|
|
import re
|
|
|
return re.findall(r"[가-힣]{2,}|[A-Za-z]{2,}|\d+", t.lower())
|
|
|
```
|
|
|
|
|
|
### 5.2 vector store + 파이프라인
|
|
|
|
|
|
```python
|
|
|
# backend/app/rag/store.py
|
|
|
import math
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import RagChunk
|
|
|
|
|
|
|
|
|
def cosine(a: list[float], b: list[float]) -> float:
|
|
|
if not a or not b: return 0.0
|
|
|
dot = sum(x * y for x, y in zip(a, b))
|
|
|
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
|
|
nb = math.sqrt(sum(y * y for y in b)) or 1.0
|
|
|
return dot / (na * nb)
|
|
|
|
|
|
|
|
|
def add_chunks(s: Session, chunks: list[RagChunk]) -> None:
|
|
|
for c in chunks: s.add(c)
|
|
|
s.commit()
|
|
|
|
|
|
|
|
|
def query_store(s: Session, qvec: list[float], k: int, source_ids: list[str] | None) -> list:
|
|
|
rows = s.exec(select(RagChunk)).all()
|
|
|
if source_ids:
|
|
|
rows = [r for r in rows if r.source_id in source_ids]
|
|
|
scored = [(cosine(qvec, r.embedding), r) for r in rows]
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
|
return scored[:k]
|
|
|
```
|
|
|
|
|
|
```python
|
|
|
# backend/app/rag/pipeline.py
|
|
|
from sqlmodel import Session, select
|
|
|
from ..models import RagChunk, ResearchSource
|
|
|
from .embed import get_embedding_provider
|
|
|
from .chunk import split_chunks
|
|
|
from .store import add_chunks, query_store
|
|
|
|
|
|
_SEED_BODIES = { # 학습 자료 본문(데모 시드). 실제는 http_fetch/PDF 파서.
|
|
|
"s5": [("신생아는 풀플랫(완전 평탄) 시트와 양대면 전환이 필수입니다. KC 인증과 5점식 벨트를 "
|
|
|
"최소 기준으로 보세요.", "‘인증·벨트’ 단락")],
|
|
|
"note_review": [("휴대성 비교 표: 차 트렁크 크기를 먼저 재라는 조언이 가장 많았습니다.", "휴대성 비교 표")],
|
|
|
# s1~s4: 반도체 자료 본문(생략 없이 시드)…
|
|
|
}
|
|
|
|
|
|
|
|
|
def ingest_seed_sources(s: Session) -> None:
|
|
|
emb = get_embedding_provider()
|
|
|
for src in s.exec(select(ResearchSource)).all():
|
|
|
bodies = _SEED_BODIES.get(src.id) or [(src.title, src.title)]
|
|
|
texts = [b[0] for b in bodies]
|
|
|
vecs = emb.embed(texts)
|
|
|
chunks = [RagChunk(id=f"chk_{src.id}_{i}", source_id=src.id, seq=i,
|
|
|
text=texts[i], part_label=bodies[i][1],
|
|
|
embedding=vecs[i], dim=len(vecs[i]), model=emb.name)
|
|
|
for i in range(len(texts))]
|
|
|
add_chunks(s, chunks)
|
|
|
|
|
|
|
|
|
def query(s: Session, q: str, k: int = 4, collection_id: str | None = None) -> list[dict]:
|
|
|
emb = get_embedding_provider()
|
|
|
qvec = emb.embed([q])[0]
|
|
|
src_ids = None
|
|
|
if collection_id:
|
|
|
src_ids = [r.id for r in s.exec(
|
|
|
select(ResearchSource).where(ResearchSource.col == collection_id)).all()]
|
|
|
scored = query_store(s, qvec, k, src_ids)
|
|
|
out = []
|
|
|
for score, ch in scored:
|
|
|
src = s.get(ResearchSource, ch.source_id)
|
|
|
out.append({"text": ch.text, "source_id": ch.source_id,
|
|
|
"title": src.title if src else "", "part_label": ch.part_label,
|
|
|
"score": round(score, 4)})
|
|
|
return out
|
|
|
```
|
|
|
|
|
|
> `chunk.py`의 `split_chunks(text, size=400, overlap=60)`는 한국어 문단/문장 경계 우선 분할(정규식). 데모 시드 본문은 이미 짧아 1청크.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. 상세 구현 — 프론트엔드 (리서치)
|
|
|
|
|
|
원본 `research.jsx`는 **MVP 셸과 별도 스코프**(`research.jsx` 상단 `const { useState } = React; const R = window.ResearchData;`, 독립 `P` 아이콘 맵, 자체 Topbar). Next.js 이식 시 **공용 `Topbar`(13항목)**(phase-1)와 **자체 사이드바**(`subnav` + `rs-nav`)를 쓴다. 사이드바 4뷰 전환은 `view` 로컬 상태 + localStorage(`ariR.view`).
|
|
|
|
|
|
### 6.1 페이지 (`app/research/page.tsx`)
|
|
|
|
|
|
```tsx
|
|
|
// frontend/app/research/page.tsx
|
|
|
"use client";
|
|
|
import { useState, useEffect } from "react";
|
|
|
import { Topbar } from "@/components/Topbar";
|
|
|
import { ResearchSidebar } from "@/components/research/ResearchSidebar";
|
|
|
import { Composer } from "@/components/research/Composer";
|
|
|
import { HomeGrid } from "@/components/research/HomeGrid";
|
|
|
import { ReportCard } from "@/components/research/ReportCard";
|
|
|
import { QACard } from "@/components/research/QACard";
|
|
|
import { ChartCard } from "@/components/research/ChartCard";
|
|
|
import { useResearch } from "@/lib/hooks/useResearch";
|
|
|
import "@/styles/research.css";
|
|
|
|
|
|
const RS_HEAD = {
|
|
|
home: { h: "지능형 리서치 파트너", p: "찾고, 비교하고, 그려보는 일은 아리가 — 판단은 지우님이 하세요." },
|
|
|
report: { h: "멀티소스 종합 리포트", p: "논문·뉴스·보고서를 모아 출처군별로 교차 분석해요." },
|
|
|
qa: { h: "지식 베이스 Q&A", p: "저장한 PDF·웹·메모를 학습해 근거와 함께 답해요." },
|
|
|
chart: { h: "시각화 & 추세 예측", p: "데이터를 그리고, 한계까지 짚어 예측해요." },
|
|
|
} as const;
|
|
|
type View = keyof typeof RS_HEAD;
|
|
|
|
|
|
export default function ResearchPage() {
|
|
|
const [view, setView] = useState<View>("home");
|
|
|
const { home, report, qa, chart, isLoading } = useResearch();
|
|
|
useEffect(() => {
|
|
|
const v = localStorage.getItem("ariR.view");
|
|
|
if (v) setView(JSON.parse(v));
|
|
|
}, []);
|
|
|
useEffect(() => { localStorage.setItem("ariR.view", JSON.stringify(view)); }, [view]);
|
|
|
|
|
|
return (
|
|
|
<div className="tpage">
|
|
|
<Topbar current="research" />
|
|
|
<div className="twork">
|
|
|
<ResearchSidebar view={view} onPick={setView}
|
|
|
collections={home?.collections ?? []} sources={home?.sources ?? []} />
|
|
|
<main className="rs-main">
|
|
|
<div className="rs-head"><h1>{RS_HEAD[view].h}</h1><p>{RS_HEAD[view].p}</p></div>
|
|
|
{view === "home" && (<><Composer prompts={home?.prompts ?? []} /><HomeGrid go={setView} home={home} /></>)}
|
|
|
{view === "report" && <ReportCard report={report} />}
|
|
|
{view === "qa" && <QACard qa={qa} />}
|
|
|
{view === "chart" && <ChartCard chart={chart} />}
|
|
|
</main>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 6.2 사이드바 4뷰 (원본 `RS_NAV` 그대로)
|
|
|
|
|
|
원본 `RS_NAV`(research.jsx 59~64행)의 라벨/서브/tone을 그대로 이식한다.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/research/ResearchSidebar.tsx
|
|
|
const RS_NAV = [
|
|
|
{ id: "home", icon: "spark", label: "새 조사", sub: "무엇이든 맡기기" },
|
|
|
{ id: "report", icon: "globe", label: "종합 리포트", sub: "멀티소스 교차분석", tone: "blue" },
|
|
|
{ id: "qa", icon: "brain", label: "지식 Q&A", sub: "내 자료에 질문", tone: "green" },
|
|
|
{ id: "chart", icon: "chart", label: "시각화·예측", sub: "추세 그리기", tone: "coral" },
|
|
|
] as const;
|
|
|
const KIND = { pdf: { icon: "file", label: "PDF" }, web: { icon: "globe", label: "웹" }, note: { icon: "pen", label: "메모" } };
|
|
|
// 컬렉션 dot 색: style={{ background: `var(--${c.tone})` }}
|
|
|
// 학습 완료: <span className="rs-learned"><Icon name="tick" w={2.8}/></span>
|
|
|
// 학습 중: <span className="rs-learning" /> (amber blink 애니메이션 — 원본 rs-blink)
|
|
|
// 하단 힌트: "저장하면 아리가 내용을 학습해요 — 북마크가 아니라 답을 주는 자료가 돼요."
|
|
|
```
|
|
|
|
|
|
### 6.3 Composer (조사 명령 — 원본 그대로)
|
|
|
|
|
|
`rs-composer` — input + `조사 시작` 버튼(`rs-go`, `Icon name="send"`) + 예시 칩(`R.prompts`) + **queued 배너**(`rs-queued`, `rs-qpulse` 점멸). placeholder: `"무엇이든 조사를 맡겨보세요 — 전문 분야가 아니어도 괜찮아요"`. queued 문구는 백엔드 `POST /api/research/start`의 `queued_text`를 사용한다(원본 하드코딩 문구와 동일).
|
|
|
|
|
|
```tsx
|
|
|
// 핵심: go() → POST /research/start → status==="ready"면 view="report"로,
|
|
|
// status==="queued"면 rs-queued 배너 표시("끝나면 알림으로 알려드릴게요").
|
|
|
```
|
|
|
|
|
|
### 6.4 ReportCard / QACard / ChartCard
|
|
|
|
|
|
- **ReportCard**(`rs-report`): `rs-ci tone-blue`(globe) + 제목 + `rs-counts`(논문/뉴스/보고서 dot pill) + `rs-synth`(HTML `dangerouslySetInnerHTML`, 새니타이즈) + **교차분석표**(`rs-cross`: head `출처군/핵심 주장/논조` + rows, grid `150px 1fr 170px`) + `rs-note`(shield 아이콘, amber).
|
|
|
- **QACard**: `rs-q`(msg 아이콘, 질문) + `rs-a`(HTML 답) + `rs-refs`(dashed 버튼, link 아이콘 + 제목 + part). 근거는 백엔드 `refs`(title/part).
|
|
|
- **ChartCard**: `rs-chart` 막대(높이 `(v/max)*100%`) + **forecast 막대**(점선 `repeating-linear-gradient`, `rs-btag` "예측" pill) + `rs-insight`(HTML) + `rs-note`(clock, "단순 추세 외삽").
|
|
|
|
|
|
```tsx
|
|
|
// ChartCard 막대 — 원본 research.jsx 167~195행 그대로
|
|
|
const max = Math.max(...chart.bars.map(b => b.v));
|
|
|
chart.bars.map((b, i) => (
|
|
|
<div className={"rs-bar" + (b.forecast ? " forecast" : "")} key={i}>
|
|
|
<span className="rs-bv mono">{b.v}</span>
|
|
|
<div className="rs-bcol" style={{ height: Math.round((b.v/max)*100) + "%" }}>
|
|
|
{b.forecast && <span className="rs-btag">예측</span>}
|
|
|
</div>
|
|
|
<span className="rs-by">{b.y}</span>
|
|
|
</div>
|
|
|
));
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 상세 구현 — 프론트엔드 (여행)
|
|
|
|
|
|
원본 `trip.jsx`는 **공용 셸 사용**(`window.AriShell.Icon/Topbar/SubRail`). Next.js 이식은 phase-1의 `Icon/Topbar/SubRail`을 그대로 쓰고, 페이지 전용 아이콘(`train/bed/food/pin/rain/plane/star/down/up2`)은 `Icon` 맵에 병합하거나 로컬 `TI` 컴포넌트로 둔다(원본 `TP_P`).
|
|
|
|
|
|
### 7.1 페이지 (`app/trip/page.tsx`) — SubRail 3뷰
|
|
|
|
|
|
```tsx
|
|
|
// frontend/app/trip/page.tsx
|
|
|
"use client";
|
|
|
import { useState, useEffect } from "react";
|
|
|
import { Topbar, SubRail, Icon } from "@/components/shell";
|
|
|
import { UpcomingView } from "@/components/trip/UpcomingView";
|
|
|
import { PlanView } from "@/components/trip/PlanView";
|
|
|
import { SavedView } from "@/components/trip/SavedView";
|
|
|
import { useTrip } from "@/lib/hooks/useTrip";
|
|
|
import "@/styles/trip.css";
|
|
|
|
|
|
const TP_RAIL = [
|
|
|
{ id: "upcoming", icon: "pin", label: "다가오는 출장" },
|
|
|
{ id: "plan", icon: "plus", label: "새 여행 계획" },
|
|
|
{ sep: true },
|
|
|
{ id: "saved", icon: "heart", label: "찜 · 가격 추적" },
|
|
|
];
|
|
|
const TP_HEAD = {
|
|
|
upcoming: { title: "여행", em: "준비는 아리가 해둘게요" },
|
|
|
plan: { title: "새 여행 계획", em: "말하면 아리가 다 조사해요" },
|
|
|
saved: { title: "찜한 여행", em: "가격이 내리면 알려드려요" },
|
|
|
};
|
|
|
|
|
|
export default function TripPage() {
|
|
|
const [view, setView] = useState<"upcoming"|"plan"|"saved">("upcoming");
|
|
|
const [day, setDay] = useState("d1");
|
|
|
const [done, setDone] = useState<Record<string, boolean>>({});
|
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
|
const { upcoming } = useTrip();
|
|
|
|
|
|
// 체크 상태 localStorage (원본 TKEY="ariTp.check")
|
|
|
useEffect(() => {
|
|
|
try { setDone(JSON.parse(localStorage.getItem("ariTp.check") || "{}")); } catch {}
|
|
|
}, []);
|
|
|
const flip = (id: string) => {
|
|
|
const n = { ...done, [id]: !done[id] };
|
|
|
setDone(n); localStorage.setItem("ariTp.check", JSON.stringify(n));
|
|
|
};
|
|
|
const allItems = (upcoming?.check ?? []).flatMap(g => g.items);
|
|
|
const doneN = allItems.filter(it => done[it.id]).length;
|
|
|
const h = TP_HEAD[view];
|
|
|
|
|
|
return (
|
|
|
<div className="dash" data-screen-label="여행">
|
|
|
<Topbar current="trip" />
|
|
|
<div className="pagehead">
|
|
|
<div>
|
|
|
<div className="ph-eyebrow">
|
|
|
<span>다음 일정 {upcoming?.trip.dday} · {upcoming?.trip.title}</span>
|
|
|
<span className="sep" />
|
|
|
<span>준비물 {doneN}/{allItems.length} 챙김</span>
|
|
|
</div>
|
|
|
<h1 className="ph-title">{h.title} <em>{h.em}</em></h1>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="work">
|
|
|
<SubRail items={TP_RAIL} active={view} onPick={setView} />
|
|
|
{view === "upcoming" && <UpcomingView day={day} setDay={setDay} done={done} flip={flip} doneN={doneN} allN={allItems.length} data={upcoming} />}
|
|
|
{view === "plan" && <PlanView onSave={(r) => { setToast(`‘${r.title}’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.`); setTimeout(() => setToast(null), 3600); }} />}
|
|
|
{view === "saved" && <SavedView />}
|
|
|
</div>
|
|
|
{toast && <div className="au-toast"><Icon name="tick" />{toast}</div>}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 7.2 PlanView — input → research(애니메이션) → result
|
|
|
|
|
|
원본 `PlanView`(trip.jsx 206~412행)의 3-phase 상태머신을 그대로 이식한다. 핵심은 **조사 진행 애니메이션**(setTimeout 650ms 간격으로 step 증가, 마지막+1에서 result)과 **예시 결정성**.
|
|
|
|
|
|
```tsx
|
|
|
// frontend/components/trip/PlanView.tsx (핵심)
|
|
|
const [phase, setPhase] = useState<"input"|"research"|"result">("input");
|
|
|
const [step, setStep] = useState(0);
|
|
|
const [result, setResult] = useState<PlanResult | null>(null);
|
|
|
|
|
|
const run = async () => {
|
|
|
if (!text.trim()) return;
|
|
|
// 1) 백엔드 결정성 결과 미리 받아둠 (예시 2개 골든, 자유입력은 제주 폴백+title)
|
|
|
const r = await api.post("/trip/plan", { text }); // PlanResultOut
|
|
|
setResult(r); setPhase("research"); setStep(0);
|
|
|
// 2) 조사 진행 애니메이션 — PLANNER_RESEARCH 4단계 650ms 간격 (원본 타이밍)
|
|
|
const research = r.research ?? PLANNER_RESEARCH; // [{icon,label,detail}]
|
|
|
research.forEach((_, i) => setTimeout(() => setStep(i + 1), 650 * (i + 1)));
|
|
|
setTimeout(() => setPhase("result"), 650 * (research.length + 1));
|
|
|
};
|
|
|
```
|
|
|
|
|
|
> **결정성 핵심**: 원본은 프론트(`pickResult`)가 결과를 골랐지만, 이식본은 **백엔드 `/trip/plan`이 결정**(예시 정확 일치 → 해당 plan, 자유 입력 → 제주 plan + `title` 18자 잘라 덮어쓰기, `custom:true`). 애니메이션은 순수 UI 표현이므로 결과 자체는 입력→출력이 1:1 결정적이다. `prefers-reduced-motion`이면 애니메이션 생략하고 즉시 result(원본 `rs-blink`/`pl-spin` 패턴 존중).
|
|
|
|
|
|
result 화면 구성(원본 그대로):
|
|
|
- 히어로(`hero`): `아리의 추천 계획` + `다시 짜기`(`pl-redo`, swap) + 제목/meta + summary(HTML) + 날씨 태그 + **액션**(`pl-save` "이 계획으로 만들기"→저장 시 "내 여행에 추가됨" green / `pl-ghost` "계획 공유") + `pl-src`("…를 종합했어요", `sources.join(" · ")`).
|
|
|
- 이동·숙소(`opt-row`, pick=추천 강조 `opt-pick`).
|
|
|
- 일정 초안(`tp-tabs` 일차 탭 + `sched` `ev`).
|
|
|
- 예상 경비(`fin-big`/`fin-bar`/`fin-range` + `xp-rows`).
|
|
|
- 준비물(`pp-list` tick rows).
|
|
|
|
|
|
### 7.3 UpcomingView / SavedView
|
|
|
|
|
|
- **UpcomingView**: 히어로(`tp-head` 제목 "부산 출장" + dates + brief + `tp-tags` 날씨 + `tp-route` `Leg` 2개 + `tp-stay`) → 아리가 미리 해둔 일(`pp-list`, doing=clock/amber, done=tick/green, 하단 "예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.") → 체크리스트(`tp-gp` 그룹 + `task` 클릭 토글, `tp-auto` 자동 라벨, count `doneN/allN`) → 일정표(`tp-tabs` 16일/17일 + `KindIcon` + `핵심` 뱃지) → 경비(`fin-big` `(planned/10000).toFixed(1)만`, `fin-bar` width `pct%`, `xp-row` state paid/hold/est).
|
|
|
- **SavedView**: `saved` 각 카드 — `sv-note` + `sv-price`(`nx-price mono` + `sv-delta` down=green/up=coral) + `nx-hint` + **`Spark`**(SVG polyline, down=green/up=coral) + `nx-watch`(bell + 토글 `sw`). 토글은 `POST /trip/saved/{id}/watch`로 영속.
|
|
|
|
|
|
```tsx
|
|
|
// Spark — 원본 trip.jsx 54~70행 그대로 (viewBox 100x48, 마지막 점 circle)
|
|
|
function Spark({ vals, color }: { vals: number[]; color: string }) {
|
|
|
const w = 100, h = 48, min = Math.min(...vals), max = Math.max(...vals);
|
|
|
const span = max - min || 1;
|
|
|
const pts = vals.map((v, i) => {
|
|
|
const x = (i / (vals.length - 1)) * w;
|
|
|
const y = h - 6 - ((v - min) / span) * (h - 12);
|
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
|
}).join(" ");
|
|
|
const [lx, ly] = pts.split(" ").pop()!.split(",");
|
|
|
return (
|
|
|
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
|
|
|
<polyline points={pts} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
<circle cx={lx} cy={ly} r="3" fill={color} />
|
|
|
</svg>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 데이터 / 타입 / API 계약
|
|
|
|
|
|
### 8.1 엔드포인트 표 (prefix `/api`)
|
|
|
|
|
|
| 메서드 | 경로 | 용도 | 응답 |
|
|
|
|---|---|---|---|
|
|
|
| GET | `/api/research/home` | 사이드바 + 입구 카드 요약 | `{collections[], sources[], prompts[], entries[]}` |
|
|
|
| GET | `/api/research/report` | 종합 리포트 | `ReportOut` |
|
|
|
| GET | `/api/research/qa` | 지식 Q&A 시드(데모 질문/답) | `QAOut` |
|
|
|
| GET | `/api/research/chart` | 시각화·예측 차트 | `ChartOut` |
|
|
|
| POST | `/api/research/start` | 자연어 조사 시작(에이전트) | `StartResearchOut` |
|
|
|
| POST | `/api/research/ask` | 지식 Q&A 질의(RAG) | `AskOut` |
|
|
|
| GET | `/api/trip/upcoming` | 다가오는 출장 전체 | `UpcomingTripOut` |
|
|
|
| GET | `/api/trip/saved` | 찜·가격추적 목록 | `SavedTripOut[]` |
|
|
|
| POST | `/api/trip/saved/{id}/watch` | 가격 알림 토글 | `SavedTripOut` |
|
|
|
| POST | `/api/trip/plan` | AI 플래너 실행 | `PlanResultOut` |
|
|
|
|
|
|
### 8.2 요청/응답 JSON 예시
|
|
|
|
|
|
`GET /api/research/report` (원본 `report` 시드 1:1)
|
|
|
```json
|
|
|
{
|
|
|
"id": "rp1",
|
|
|
"title": "최신 AI 반도체 시장 동향",
|
|
|
"asked": "“보고서, 논문, 뉴스 기사를 종합해서 알려줘”",
|
|
|
"meta": "출처 10곳 수집 · 3분 전 완성",
|
|
|
"counts": [
|
|
|
{ "k": "논문", "n": 3, "tone": "violet" },
|
|
|
{ "k": "뉴스", "n": 5, "tone": "blue" },
|
|
|
{ "k": "보고서·블로그", "n": 2, "tone": "green" }
|
|
|
],
|
|
|
"synthesis": "세 출처군 모두 <b>추론(inference) 수요로의 무게 이동</b>에는 동의해요. …",
|
|
|
"cross": [
|
|
|
{ "src": "학술 논문 (3편)", "tone": "violet", "claim": "추론 비용의 병목은 연산이 아니라 메모리 대역폭", "stance": "신중 — 벤치마크 근거" },
|
|
|
{ "src": "뉴스 기사 (5건)", "tone": "blue", "claim": "HBM4 양산 시점이 올해 시장 점유율을 가른다", "stance": "낙관 — 업계 발표 인용" },
|
|
|
{ "src": "산업 보고서 (2건)", "tone": "green", "claim": "데이터센터 전력 규제가 칩 설계 방향을 바꾼다", "stance": "중립 — 정책 시나리오" }
|
|
|
],
|
|
|
"note": "출처마다 이해관계가 달라요 — 뉴스 5건 중 3건은 제조사 발표 기반이라 교차 확인을 권해요."
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`POST /api/research/start`
|
|
|
```json
|
|
|
// 요청 (시드 질의)
|
|
|
{ "query": "최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘" }
|
|
|
// 응답 (ready — 골든 리포트 즉시)
|
|
|
{ "status": "ready", "report_id": "rp1", "report": { /* ReportOut */ }, "queued_text": null }
|
|
|
|
|
|
// 요청 (시드 외 자유 질의)
|
|
|
{ "query": "전고체 배터리 상용화 시점 정리해줘" }
|
|
|
// 응답 (queued — 끝나면 알림)
|
|
|
{ "status": "queued", "report_id": null, "report": null,
|
|
|
"queued_text": "“전고체 배터리 상용화 시점 정리해줘” — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요." }
|
|
|
```
|
|
|
|
|
|
`POST /api/research/ask`
|
|
|
```json
|
|
|
// 요청
|
|
|
{ "q": "유모차, 신생아 때부터 쓰려면 뭘 봐야 하지?", "collection_id": "stroller" }
|
|
|
// 응답 (RAG — 근거 함께)
|
|
|
{
|
|
|
"a": "저장해두신 자료 기준으로는 <b>풀플랫(완전 평탄) 시트</b>와 <b>양대면 전환</b>이 신생아 필수 조건이에요. …",
|
|
|
"refs": [
|
|
|
{ "title": "신생아 유모차 안전 기준 정리", "part": "‘인증·벨트’ 단락" },
|
|
|
{ "title": "유모차 실사용 후기 모음 (메모)", "part": "휴대성 비교 표" }
|
|
|
],
|
|
|
"grounded": true,
|
|
|
"model": "seed"
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`POST /api/trip/plan`
|
|
|
```json
|
|
|
// 요청 (예시 정확 일치 → 골든 결정성)
|
|
|
{ "text": "도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘" }
|
|
|
// 응답 (PlanResultOut — 원본 plan_tokyo 1:1)
|
|
|
{
|
|
|
"id": "plan_tokyo", "idx": 1, "custom": false,
|
|
|
"title": "도쿄 출장", "meta": "2박 3일 · 9월 8일(월) – 10일(수) · 성인 1",
|
|
|
"summary": "시부야 도보권 비즈니스 호텔로 동선을 좁혔어요. <b>엔저가 이어지는 동안</b>이 항공·숙박 모두 유리합니다.",
|
|
|
"weather": "초가을 · 한낮 28° · 9일 오후 비 50% (우산 권장)",
|
|
|
"transport": [
|
|
|
{ "mode": "항공", "name": "인천 → 하네다 (왕복)", "desc": "08:40 출발 · 2시간 20분 · 직항", "price": "287,000", "pick": true, "note": "공항이 시내와 가까워요" },
|
|
|
{ "mode": "교통", "name": "스이카 충전 + 공항버스", "desc": "시부야까지 환승 없이", "price": "32,000", "pick": true }
|
|
|
],
|
|
|
"stay": [
|
|
|
{ "name": "시부야 스테이션 호텔", "desc": "역 도보 4분 · 회의장 2정거장 · 평점 4.6", "price": "142,000", "unit": "/박", "pick": true },
|
|
|
{ "name": "신주쿠 비즈니스 호텔", "desc": "역 도보 6분 · 조식 포함 · 평점 4.3", "price": "118,000", "unit": "/박" }
|
|
|
],
|
|
|
"days": [ { "tab": "1일차", "items": [ { "t": "오전", "title": "인천 → 하네다 · 호텔 체크인", "tone": "blue" } ] } ],
|
|
|
"budget": { "total": "713,000", "cap": "900,000", "rows": [ { "name": "항공 왕복", "amt": "287,000" } ] },
|
|
|
"checklist": ["여권 (유효기간 6개월+)", "노트북 · 발표자료 백업", "해외로밍 / eSIM", "접이식 우산 · 명함"],
|
|
|
"sources": ["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`GET /api/trip/upcoming` (요약 — 전체는 route/stay/prep/days/checklist/expense 포함)
|
|
|
```json
|
|
|
{
|
|
|
"trip": { "id": "tp_busan", "dday": "D-4", "title": "부산 출장",
|
|
|
"dates": "6월 16일(월) – 17일(화) · 1박 2일",
|
|
|
"purpose": "부산 지사 · 분기 리포트 발표",
|
|
|
"brief": "표·숙소·일정 정리는 끝났어요. 발표자료 최종본만 챙기면 돼요.",
|
|
|
"weather": [ { "d": "16일", "t": "맑음 26°", "icon": "sun" }, { "d": "17일", "t": "오후 비 40% · 24°", "icon": "rain" } ] },
|
|
|
"route": { "out": { "mode": "KTX 101", "from": "서울역", "ft": "07:00", "to": "부산역", "tt": "09:42", "seat": "4호차 4A · 창측", "note": "승차권 발급 완료" },
|
|
|
"back": { "mode": "KTX 158", "from": "부산역", "ft": "18:00", "to": "서울역", "tt": "20:40", "seat": "7호차 11C", "note": "변경 가능 좌석" } },
|
|
|
"stay": { "name": "스테이 부산역", "desc": "부산역 도보 5분 · 지사 도보 12분 · 체크인 15:00", "conf": "예약 확정" },
|
|
|
"prep": [ { "text": "KTX 왕복 예매 — 환승 없는 가장 빠른 편", "state": "done" }, { "text": "법인카드 한도 확인 → 정산서 양식 준비", "state": "doing" } ],
|
|
|
"days": [ { "id": "d1", "tab": "16일 (월)", "items": [ { "t": "11:00", "kind": "talk", "tone": "coral", "hot": true, "title": "분기 리포트 발표", "meta": "대회의실 · 45분 · 발표자료 v3 연결됨" } ] } ],
|
|
|
"check": [ { "name": "서류 · 결제", "items": [ { "id": "c1", "text": "신분증 · 법인카드" }, { "id": "c2", "text": "출장 신청서", "auto": "아리가 제출 완료" } ] } ],
|
|
|
"expense": { "budget": 400000, "planned": 331600,
|
|
|
"rows": [ { "name": "KTX 왕복", "amt": "119,600", "state": "paid" }, { "name": "숙소 1박", "amt": "132,000", "state": "hold" }, { "name": "식비 · 이동 예상", "amt": "80,000", "state": "est" } ],
|
|
|
"note": "법인카드 결제 영수증은 아리가 모아서 돌아온 다음 날 정산서 초안을 만들어둘게요." }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 응답 형태는 **원본 `T.route`/`T.check` 구조**(중첩 `{out,back}`, `check[].items[]`)를 그대로 미러링해 프론트(`UpcomingView`)가 변환 없이 소비한다. `source.from`만 alias `from`으로 직렬화(모델은 `from_label`).
|
|
|
|
|
|
### 8.3 Pydantic 스키마(핵심)
|
|
|
|
|
|
```python
|
|
|
# backend/app/schemas.py (phase-10 추가분, 발췌)
|
|
|
from pydantic import BaseModel, Field as PField
|
|
|
|
|
|
class StartResearchRequest(BaseModel):
|
|
|
query: str
|
|
|
|
|
|
class StartResearchOut(BaseModel):
|
|
|
status: str # "ready" | "queued"
|
|
|
report_id: str | None = None
|
|
|
report: "ReportOut | None" = None
|
|
|
queued_text: str | None = None
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
|
q: str
|
|
|
collection_id: str | None = None
|
|
|
|
|
|
class AskOut(BaseModel):
|
|
|
a: str
|
|
|
refs: list[dict] = [] # [{title, part}]
|
|
|
grounded: bool = True
|
|
|
model: str = ""
|
|
|
|
|
|
class SourceOut(BaseModel):
|
|
|
id: str; kind: str; title: str
|
|
|
from_: str = PField(alias="from") # 직렬화 키 "from"
|
|
|
col: str | None = None; learned: bool = False
|
|
|
model_config = {"populate_by_name": True}
|
|
|
|
|
|
class PlanRequest(BaseModel):
|
|
|
text: str
|
|
|
|
|
|
class PlanResultOut(BaseModel):
|
|
|
id: str; idx: int; custom: bool = False
|
|
|
title: str; meta: str; summary: str; weather: str
|
|
|
transport: list[dict]; stay: list[dict]; days: list[dict]
|
|
|
budget: dict; checklist: list[str]; sources: list[str]
|
|
|
research: list[dict] | None = None # 조사 진행 단계(프론트 애니메이션)
|
|
|
|
|
|
class WatchToggleRequest(BaseModel):
|
|
|
watch: bool | None = None # None 이면 토글
|
|
|
```
|
|
|
|
|
|
> `frontend/lib/types.ts`는 위와 1:1(snake_case 유지, `from`은 직렬화 키). phase-2 §3.2의 types.ts 대응 규약을 따른다.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. 디자인 충실도 노트
|
|
|
|
|
|
> 원본 토큰/레이아웃/문구를 **그대로** 인용. REF 경로 명시.
|
|
|
|
|
|
### 9.1 리서치 (REF/assets/research.css, research.jsx)
|
|
|
|
|
|
| 요소 | 원본 값 | REF |
|
|
|
|---|---|---|
|
|
|
| 사이드바 뷰 행 | `.rs-navrow` padding `9px 10px`, radius `11px`, on=`var(--fill)` | research.css 6~12 |
|
|
|
| 뷰 아이콘 tone | blue `color-mix(in oklab, var(--blue) 15%, transparent)`, green, coral | research.css 19~21 |
|
|
|
| 입구 카드 | `.rs-entry` radius `var(--radius)`(22px), hover `translateY(-2px)` | research.css 30~51 |
|
|
|
| 컴포저 박스 | `.rs-cbox` padding `8px 9px 8px 16px`, radius `16px`, spark `var(--coral)` | research.css 91~97 |
|
|
|
| `조사 시작` 버튼 | `.rs-go` `var(--fill)`/`var(--on-fill)`, radius `11px` | research.css 100~107 |
|
|
|
| queued 배너 | `.rs-queued` 테두리 `color-mix(..., var(--blue) 30%)`, pulse blue 점멸 `rs-blink 1.3s` | research.css 115~124 |
|
|
|
| 카드 | `.rs-card` glass + radius `var(--radius)`, padding `20px 22px` | research.css 127~131 |
|
|
|
| counts pill | `.rs-count` `var(--card-2)`, dot 7px | research.css 143~144 |
|
|
|
| 교차분석표 | `.rs-cross` border radius `13px`; head/row grid `150px 1fr 170px` gap `14px` | research.css 150~157 |
|
|
|
| 주의 노트 | `.rs-note` 아이콘 `var(--amber)` (shield/clock) | research.css 159~160 |
|
|
|
| Q&A 질문 | `.rs-q` `var(--card-2)`, msg 아이콘 `var(--green)` | research.css 163~164 |
|
|
|
| 근거 버튼 | `.rs-ref` dashed `var(--line-2)`, hover green solid | research.css 168~176 |
|
|
|
| 차트 막대 | `.rs-bcol` gradient coral 78%→48%, radius `9px 9px 4px 4px`, max-width `52px` | research.css 183~187 |
|
|
|
| 예측 막대 | `.rs-bar.forecast .rs-bcol` `repeating-linear-gradient(-45deg, coral 38%…14%)` + dashed; `.rs-btag` `var(--coral)` "예측" pill | research.css 188~198 |
|
|
|
| 진입 애니메이션 | `.entered .rs-main > *` `rs-rise 0.45s`, nth-child delay 0.04/0.08/0.12s | research.css 204~208 |
|
|
|
| 헤더 | `.rs-head h1` `var(--font-disp)` 26px 800 letter-spacing `-0.03em` | research.css 86~87 |
|
|
|
|
|
|
문구 인용(그대로): 사이드바 4뷰 라벨/서브(`새 조사`/`무엇이든 맡기기`, `종합 리포트`/`멀티소스 교차분석`, `지식 Q&A`/`내 자료에 질문`, `시각화·예측`/`추세 그리기`), 헤더(`RS_HEAD`), 컴포저 placeholder, 하단 힌트(`저장하면 아리가 내용을 학습해요 — 북마크가 아니라 답을 주는 자료가 돼요.`).
|
|
|
|
|
|
### 9.2 여행 (REF/assets/trip.css, trip.jsx)
|
|
|
|
|
|
| 요소 | 원본 값 | REF |
|
|
|
|---|---|---|
|
|
|
| 토글 스위치 | `.sw` 42x24, on=`var(--green)`, knob `translateX(18px)` | trip.css 6~22 |
|
|
|
| 히어로 제목 | `.tp-title` `var(--font-disp)` 26px 700 letter-spacing `-0.02em` | trip.css 26 |
|
|
|
| 날씨 태그 | `.tp-tag.wx .ic` `var(--amber)` | trip.css 36 |
|
|
|
| 이동 구간 | `.tp-leg` glass-2, `.tp-mode` `var(--blue)` pill #fff, `.tp-line` 2px dashed | trip.css 38~59 |
|
|
|
| 숙소 | `.tp-stay .st-ic` `var(--violet)` 14% bg, `.st-conf` `var(--green)` "예약 확정" | trip.css 60~81 |
|
|
|
| 미리 해둔 일 | `.pp-row` border-top `var(--line)`, doing=`var(--amber)`, done=`var(--green)` | trip.css 84~101 |
|
|
|
| 일정 탭 | `.tp-tab.on` `var(--fill)`/`var(--on-fill)` | trip.css 108~116 |
|
|
|
| 자동 라벨 | `.tp-auto` `var(--violet)` 12% pill | trip.css 133~140 |
|
|
|
| 경비 상태 | `.xp-state.paid` green, `.hold` amber | trip.css 151~158 |
|
|
|
| 가격 | `.nx-price` `var(--font-disp)` 28px; `.sv-delta.down` green / `.up` coral | trip.css 162~163, 309~315 |
|
|
|
| 플래너 캡처 그리드 | `.pl-cap-ic` `var(--coral)` 13% | trip.css 177~194 |
|
|
|
| 조사 진행 | `.pl-step.now` border coral, `.pl-step.done .pl-step-ic` `var(--green)`; `.pl-spin` 0.7s linear | trip.css 196~225 |
|
|
|
| 결과 액션 | `.pl-save` `var(--fill)`→done `var(--green)`; `.pl-ghost` glass-2 | trip.css 238~258 |
|
|
|
| 옵션 추천 | `.opt-row.pick` coral 11% gradient + `.opt-pick` coral #fff pill | trip.css 272~297 |
|
|
|
| 토스트 | `.au-toast` `var(--fill)`, tick `var(--lime)`, `toastin 0.3s` | trip.css 318~328 |
|
|
|
|
|
|
문구 인용(그대로): `SubRail` 라벨(`다가오는 출장`/`새 여행 계획`/`찜 · 가격 추적`), `TP_HEAD` em(`준비는 아리가 해둘게요` 등), `아리가 미리 해둔 일`, `예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.`, 플래너 placeholder(`예: 다음 달 말 제주 가족여행 3박 4일, 예산 150만원`), 토스트(`‘…’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.`).
|
|
|
|
|
|
### 9.3 KindIcon / 페이지 전용 아이콘
|
|
|
|
|
|
원본 `TP_KIND`(trip.jsx 31~40): `move`=로컬 `train`, `meet`=공용 `users`, `talk`=공용 `mic`, `meal`=로컬 `food`. 페이지 전용 path(`TP_P`: train/bed/food/pin/rain/plane/star/down/up2/sun)는 phase-1 `Icon` 중앙 맵에 병합하거나 로컬 `TI`로 둔다(누락 시 렌더 깨짐 — overview §11.5 가드).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. 상태 처리 & 엣지 케이스
|
|
|
|
|
|
overview §15 전역 원칙 + 본 페이지 특수 상태.
|
|
|
|
|
|
| 상태 | 처리 |
|
|
|
|---|---|
|
|
|
| **로딩** | 글래스 스켈레톤(카드 형태 유지). `useResearch`/`useTrip`의 `isLoading`. |
|
|
|
| **조사 진행(streaming)** | 리서치: `rs-queued` 배너(blue pulse) 또는 향후 SSE 스트리밍. 여행: `pl-prog` 4단계(650ms 간격) + `pl-spin`. `prefers-reduced-motion`이면 애니메이션 생략, 즉시 결과. |
|
|
|
| **타임아웃** | 에이전트 루프 `max_steps`/툴 `LLM_TIMEOUT` 초과 → scripted 폴백 또는 `queued`("끝나면 알림"). 사용자에게 에러 대신 "조사 중" 유지. |
|
|
|
| **에이전트 폴백** | tool-capable 미가용/예외 → `Agent.run`이 `scripted`로 폴백, `AgentResult.fallback=true`, `model="scripted"`. UI는 동일(데모 결정성). |
|
|
|
| **RAG 근거 없음** | `AskOut.grounded=false` + "저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요." 안내. `refs=[]`. |
|
|
|
| **학습 중 자료** | `source.learned=false`(s5) → 사이드바 `rs-learning`(amber blink), Q&A 근거 후보에서 가중치 하향 또는 제외 가능. |
|
|
|
| **임베딩 미가용** | `HeuristicEmbedding`(TF 해시) 폴백 — 오프라인/CI에서도 코사인 검색 동작(결정적). |
|
|
|
| **플래너 자유 입력** | examples 불일치 → 제주(idx 0) 폴백 + `title` 18자 잘라 덮어쓰기 + `custom:true`(원본 `pickResult`). |
|
|
|
| **체크리스트 영속** | `localStorage["ariTp.check"]` — 서버 미저장(원본과 동일). 초기화/충돌 시 `{}` 폴백. |
|
|
|
| **HTML 시드** | `synthesis`/`a`/`summary`/`insight`는 `<b>` 포함 HTML — 신뢰 시드지만 렌더 전 새니타이즈(overview §15, DOMPurify). |
|
|
|
| **빈(empty)** | 컬렉션/소스 없음: "저장하면 아리가 학습해요" 힌트. 찜 없음: "찜한 여행이 아직 없어요" 안내. |
|
|
|
| **에러** | `lib/api.ts` 비2xx throw → 카드 단위 에러 + 재시도. 셸(Topbar/테마) 유지. |
|
|
|
| **반응형** | 리서치 `@media(max-width:1080px)` `.subnav` 숨김; 760px `.rs-cross` 1열·`.rs-counts` 숨김. 여행 720px `.pl-grid` 1열(원본 미디어쿼리 그대로). |
|
|
|
|
|
|
---
|
|
|
|
|
|
## 11. 연합 이벤트 (발행/구독)
|
|
|
|
|
|
post-mvp-overview의 `event_bus`(phase-7) 이벤트 모델을 사용. 본 페이지가 발행/구독하는 이벤트:
|
|
|
|
|
|
### 11.1 발행(publish)
|
|
|
|
|
|
| 이벤트 | 발행 시점 | 페이로드 | 구독자 |
|
|
|
|---|---|---|---|
|
|
|
| `research.completed` | 조사 종합 리포트 완성(`/research/start` ready 또는 worker 완료) | `{report_id, title, source_count}` | 알림(phase-9, "리서치에 정리해둘게요"), 라이프 지식(phase-11) |
|
|
|
| `knowledge.ingested` | 자료 학습(RAG ingest) 완료 | `{source_id, collection_id, chunks}` | 리서치 사이드바(learned 갱신), 라이프 `knowledge_item` |
|
|
|
| `task.created` | 플래너/리서치 액션 → `task_create` 툴 실체화 | `{task_id, project_id, source:"agent"}` | 작업(phase-3), 대시보드(phase-5) |
|
|
|
| `trip.plan.saved` | 플래너 "이 계획으로 만들기" | `{plan_id, title}` | 찜·가격추적(저장), 일정(phase-8 일정 초안→event) |
|
|
|
| `price.target.hit` | 찜 목표가 도달(worker 가격추적) | `{saved_trip_id, now, target}` | 알림(phase-9), 결재함(phase-7 — 예약 제안) |
|
|
|
|
|
|
### 11.2 구독(subscribe)
|
|
|
|
|
|
| 이벤트 | 출처 | 본 페이지 반응 |
|
|
|
|---|---|---|
|
|
|
| `notification.triaged` | phase-9 알림 | 조사 요청을 리서치 컬렉션으로 라우팅("리서치에 정리해둘게요") |
|
|
|
| `meeting.ended` | phase-8 회의 | 회의에서 나온 리서치 거리를 새 조사로 제안(선택) |
|
|
|
| `mail.received` | phase-9 메일 | 첨부 PDF/링크 → RAG ingest 후보(자료 자동 학습 제안) |
|
|
|
|
|
|
### 11.3 연합 가치 시나리오 (PROJECT-README §5)
|
|
|
|
|
|
1. **여행 플래너 액션 → 작업/일정**: PlanView "이 계획으로 만들기" → `trip.plan.saved` → `calendar_write`(일정 초안 days→event, phase-8) + `task_create`(준비물 checklist→task, phase-3). 결제성 옵션(항공 예약)은 **결재함**(phase-7, risk=high)으로 enqueue.
|
|
|
2. **리서치 리포트 → 지식 컬렉션 저장**: `research.completed` → 리포트를 `research_collection`에 보관, `knowledge_item`(phase-11)과 공유.
|
|
|
3. **"리서치에 정리해둘게요"(알림 연계)**: 알림(phase-9)에서 조사거리 감지 → `notification.triaged` 구독 → 백그라운드 조사 enqueue(worker) → 완료 시 `research.completed` → 알림으로 "정리 끝났어요".
|
|
|
4. **가격추적 → 알림/결재함**: worker가 `saved_trip` 가격 모니터 → `price.target.hit`(목표가 도달) → 알림 + (예약 제안 시) 결재함.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 12. 테스팅 & 검증
|
|
|
|
|
|
### 12.1 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드 (backend/)
|
|
|
uv run pytest # 전체
|
|
|
uv run pytest tests/test_agents_travel_examples.py # 플래너 예시 2개 결정성
|
|
|
uv run pytest tests/test_rag_qa.py # RAG Q&A 근거
|
|
|
uv run pytest -k "research or trip" # 페이지 API
|
|
|
uv run python -m app.seed # 시드(리서치+여행 포함)
|
|
|
uv run uvicorn app.main:app --reload # 개발 서버 :8000
|
|
|
|
|
|
# 프론트엔드 (frontend/)
|
|
|
pnpm dev # :3000
|
|
|
pnpm test # Vitest + RTL (컴포넌트)
|
|
|
pnpm playwright test --grep research # E2E 리서치
|
|
|
pnpm playwright test --grep trip # E2E 여행
|
|
|
pnpm playwright test --grep @a11y # axe
|
|
|
```
|
|
|
|
|
|
### 12.2 백엔드 테스트 케이스
|
|
|
|
|
|
| # | 파일 | 케이스 | 통과 기준 |
|
|
|
|---|---|---|---|
|
|
|
| RT-1 | `test_seed_research_trip.py` | 시드 후 카운트 | collection 3, source 5(learned 4), report 1, qa 1, chart 1; trip 1, route 2, prep 5(doing 1), day 2, checklist 7, saved 3, plan 2 |
|
|
|
| RT-2 | `test_api_research.py` | `GET /research/report` | `counts`=[논문3,뉴스5,보고서2], `cross` 3행, `synthesis`에 `<b>추론(inference)…` 포함, `note` 일치 |
|
|
|
| RT-3 | `test_api_research.py` | `GET /research/chart` | bars 6개, 마지막 `forecast:true v=412`, `insight`에 `28.9%`/`412억(±9%)` 포함 |
|
|
|
| RT-4 | `test_agents_research.py` | `POST /research/start` 시드 질의 | `status="ready"`, `report_id="rp1"`, report 1:1 |
|
|
|
| RT-5 | `test_agents_research.py` | `POST /research/start` 자유 질의 | `status="queued"`, `queued_text`에 입력 질의 + "끝나면 알림으로 알려드릴게요" |
|
|
|
| RT-6 | `test_rag_qa.py` | `POST /research/ask` 유모차 질문 | `grounded=true`, `refs` 2건(`‘인증·벨트’ 단락`,`휴대성 비교 표`), `a`에 `풀플랫`/`KC 인증` |
|
|
|
| RT-7 | `test_rag_qa.py` | RAG heuristic 임베딩 폴백 | `EMBED_PROVIDER=heuristic`(또는 `EMBED_MODEL` 미설정/오프라인)에서도 `query()`가 청크 반환(코사인>0), 결정적 |
|
|
|
| RT-8 | `test_rag_qa.py` | 근거 없음 | 무관 질의 시 `grounded=false`, `refs=[]` |
|
|
|
| RT-9 | `test_agents_travel_examples.py` | **예시2 결정성** | `POST /trip/plan {"text":"다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원"}` → `id=plan_jeju` budget total `1,287,000`, transport pick 2건; `도쿄 출장 2박 3일…` → `id=plan_tokyo` total `713,000` |
|
|
|
| RT-10 | `test_agents_travel_examples.py` | 자유 입력 폴백 | `{"text":"부산 당일치기"}` → `id=plan_jeju`, `custom=true`, `title="부산 당일치기"`(≤18자) |
|
|
|
| RT-11 | `test_api_trip.py` | `GET /trip/upcoming` | route `{out,back}` 중첩, expense budget 400000/planned 331600, check 3그룹, day d1에 `hot:true` "분기 리포트 발표" |
|
|
|
| RT-12 | `test_api_trip.py` | `POST /trip/saved/sv3/watch` | `watch` false→true 토글 영속, 응답 `SavedTripOut` |
|
|
|
| RT-13 | `test_api_trip.py` | saved spark | sv1 `[120,…,89]` `down=true` `delta="−12%"` |
|
|
|
| RT-14 | `test_agents_research.py` | scripted 폴백 강제 | `AGENT_PROVIDER=scripted`에서도 시드 질의 ready(골든) — tool-capable 없이 동작 |
|
|
|
|
|
|
`test_agents_travel_examples.py` 예시(결정성 골든):
|
|
|
|
|
|
```python
|
|
|
def test_plan_jeju_golden(client):
|
|
|
r = client.post("/api/trip/plan", json={
|
|
|
"text": "다음 달 말 제주 가족여행 3박 4일, 렌터카 빌리고 예산 150만원"})
|
|
|
p = r.json()
|
|
|
assert p["id"] == "plan_jeju" and p["custom"] is False
|
|
|
assert p["budget"]["total"] == "1,287,000" and p["budget"]["cap"] == "1,500,000"
|
|
|
picks = [o for o in p["transport"] if o.get("pick")]
|
|
|
assert len(picks) == 2 and p["transport"][0]["price"] == "356,000"
|
|
|
assert p["checklist"][-1] == "카시트 (렌터카 옵션 신청 완료)"
|
|
|
|
|
|
def test_plan_tokyo_golden(client):
|
|
|
r = client.post("/api/trip/plan", json={"text": "도쿄 출장 2박 3일, 시부야 근처 호텔로 잡아줘"})
|
|
|
p = r.json()
|
|
|
assert p["id"] == "plan_tokyo" and p["title"] == "도쿄 출장"
|
|
|
assert p["budget"]["total"] == "713,000"
|
|
|
assert p["sources"] == ["항공사 3곳 실시간 운임", "숙박앱 평점·후기", "환율·엔저 추이", "현지 교통 안내"]
|
|
|
|
|
|
def test_plan_freeform_fallback(client):
|
|
|
r = client.post("/api/trip/plan", json={"text": "부산 당일치기로 바닷바람 쐬고 오기"})
|
|
|
p = r.json()
|
|
|
assert p["id"] == "plan_jeju" and p["custom"] is True
|
|
|
assert p["title"] == "부산 당일치기로 바닷바람 쐬고"[:18]
|
|
|
```
|
|
|
|
|
|
### 12.3 프론트 테스트 케이스 (Vitest/RTL + Playwright)
|
|
|
|
|
|
| # | 종류 | 케이스 | 통과 기준 |
|
|
|
|---|---|---|---|
|
|
|
| F-1 | RTL | `ChartCard` 막대 높이 | max=412 기준 v/max*100% 적용, forecast 막대에 `rs-btag` "예측" |
|
|
|
| F-2 | RTL | `ReportCard` 교차분석표 | head `출처군/핵심 주장/논조` + 3 rows, dot 색 `var(--violet/blue/green)` |
|
|
|
| F-3 | RTL | `Composer` queued | 자유 질의 go → `rs-queued` 배너 + 입력 질의 표시 |
|
|
|
| F-4 | RTL | `Spark` polyline | 7값 입력 시 polyline points 7개 + 마지막 circle, down=green |
|
|
|
| F-5 | RTL | 체크리스트 토글 | task 클릭 → `.done` 클래스 + localStorage `ariTp.check` 갱신 |
|
|
|
| F-6 | E2E | 리서치 뷰 전환 | 사이드바 4뷰 클릭 → 헤더 h1/p 변경, localStorage `ariR.view` 영속 |
|
|
|
| F-7 | E2E | 플래너 풀 플로우 | 예시 칩 클릭 → "계획 만들기" → `pl-prog` 4단계 → result(제주) → "이 계획으로 만들기" → 토스트 |
|
|
|
| F-8 | E2E | 가격알림 토글 | saved sv3 `sw` 클릭 → on, 새로고침 후 유지(서버 영속) |
|
|
|
| F-9 | a11y | axe | 리서치/여행 양 페이지 serious 이상 위반 0 |
|
|
|
| F-10 | E2E | reduced-motion | `prefers-reduced-motion`에서 플래너 즉시 result(애니메이션 생략) |
|
|
|
|
|
|
### 12.4 수동 QA 체크리스트
|
|
|
|
|
|
리서치:
|
|
|
- [ ] 상단 13항목 내비에서 "리서치" active, 좌측 사이드바 4뷰 + 컬렉션 3 + 학습 자료 5(학습 완료 4 tick, s5 amber blink).
|
|
|
- [ ] 새 조사 홈: 컴포저 + 예시 칩 3개 + 입구 카드 3개. 예시 칩 클릭 시 입력창 채움.
|
|
|
- [ ] "최신 AI 반도체…" 조사 시작 → 종합 리포트(논문3/뉴스5/보고서2, 교차분석표 3행, 편향 노트).
|
|
|
- [ ] 자유 질의 조사 시작 → queued 배너("끝나면 알림으로 알려드릴게요").
|
|
|
- [ ] 지식 Q&A: 유모차 질문 → 근거 2건(인증·벨트 단락 / 휴대성 비교 표) 함께 답.
|
|
|
- [ ] 시각화·예측: 막대 6개, 마지막 점선+"예측" pill(412), CAGR 28.9% 인사이트, "단순 추세 외삽" 주의.
|
|
|
- [ ] 라이트/다크 토글, 줄바꿈 `word-break: keep-all`.
|
|
|
|
|
|
여행:
|
|
|
- [ ] 페이지 헤드 "다음 일정 D-4 · 부산 출장 · 준비물 N/7 챙김". SubRail 3뷰.
|
|
|
- [ ] 다가오는 출장: 히어로(KTX 왕복 Leg 2 + 스테이 부산역 + 날씨 2), 미리 해둔 일(doing 1 amber), 체크리스트(그룹 3, 자동 라벨), 일정표(16/17일 탭, "분기 리포트 발표" 핵심 뱃지), 경비(331,600/400,000 · 82% · paid/hold/est).
|
|
|
- [ ] 체크리스트 항목 토글 → 새로고침 후 유지(localStorage).
|
|
|
- [ ] 새 여행 계획: 예시 입력 → 조사 4단계 애니메이션 → 제주/도쿄 결과(이동·숙소 추천 pick, 일정 일차 탭, 경비 바, 준비물). "이 계획으로 만들기" → 토스트.
|
|
|
- [ ] 찜·가격추적: 3카드(제주 −12% green / 강릉 +4% coral / 도쿄 −7% green), spark 그래프, 알림 토글.
|
|
|
|
|
|
### 12.5 통과 기준(요약)
|
|
|
|
|
|
- 백엔드 `pytest` 전건 green: 시드 카운트(RT-1), 리포트/차트 1:1(RT-2/3), 조사 ready/queued(RT-4/5), RAG 근거(RT-6~8), **플래너 예시2 결정성(RT-9)** + 폴백(RT-10), 출장/찜 API(RT-11~13), scripted 폴백(RT-14).
|
|
|
- 프론트 `pnpm test` + `pnpm playwright test` green: 뷰 전환/플래너 플로우/체크리스트 영속/가격토글.
|
|
|
- axe serious 이상 0. 라이트/다크 토큰, 13항목 내비 일치.
|
|
|
- **데모 결정성**: tool-capable·임베딩 모델 미가용(오프라인/CI)에서도 시드 리포트·예시 2 플래너·유모차 Q&A가 골든으로 동작.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 13. 완료 기준 (Definition of Done)
|
|
|
|
|
|
- [ ] 신규 테이블 11종(research_collection/source, research_report, knowledge_qa, research_chart, trip, trip_route/stay/prep/day/checklist, saved_trip, trip_plan, rag_chunk) 모델 + 마이그레이션 + 시드(원본 `*-data.js` 1:1).
|
|
|
- [ ] `backend/app/agents/`: Agent 루프(plan→act→observe→reflect) + tools(web_search/rag_query/http_fetch/task_create/calendar_write) + research_agent/travel_agent + scripted 폴백 + registry.
|
|
|
- [ ] `backend/app/rag/`: pipeline(ingest→chunk→embed→store→query) + EmbeddingProvider(Ollama/heuristic) + VectorStore(코사인/sqlite-vec).
|
|
|
- [ ] 엔드포인트 10종(`/research/*` 6, `/trip/*` 4) — phase-2 패턴(라우터 내부 prefix 없음, main.py에서 `/api`).
|
|
|
- [ ] 리서치 페이지: 사이드바 4뷰 + Composer + HomeGrid + ReportCard(교차분석표) + QACard(근거) + ChartCard(예측 막대). 원본 토큰/문구 충실.
|
|
|
- [ ] 여행 페이지: SubRail 3뷰 — UpcomingView(route/stay/prep/체크리스트/일정표/경비) + PlanView(input→research 애니메이션→result, 예시2 결정성) + SavedView(Spark·가격알림). 체크 상태 localStorage.
|
|
|
- [ ] 연합: 플래너 액션→작업/일정, 리포트→컬렉션 저장, "리서치에 정리해둘게요" 알림 연계 이벤트(§11) 정의·발행/구독 명시.
|
|
|
- [ ] 상태 처리: 조사 진행/스트리밍/타임아웃/폴백/RAG 근거없음/임베딩 폴백/HTML 새니타이즈/반응형.
|
|
|
- [ ] 테스트: 백엔드 RT-1~14, 프론트 F-1~10 green. 데모 결정성(오프라인) 보장.
|
|
|
- [ ] 디자인 충실도: research.css/trip.css 토큰·클래스·문구 인용 일치. 라이트/다크·13항목 내비.
|
|
|
- [ ] 상호 참조: post-mvp-overview, phase-2, phase-7/8/9, phase-11/13/14 정확한 파일명.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 14. 다음 단계
|
|
|
|
|
|
다음 문서: **`phase-11-life-care.md`** — 라이프 케어(건강·금융·지식, 커넥터). 본 phase의 `knowledge_item`(리서치/라이프 공유), RAG 파이프라인, 커넥터 추상화를 그대로 이어받아 `connector_source`(health/finance/knowledge) + `health`(rings/vitals/sleep/coach/habits) + `finance`(budget/cats/subs/coach/insights) + `knowledge_item`(article|note|idea|highlight + ai)를 구현한다. 이후 `phase-12-daily-narrative.md`(여정+하루 마감 집계) → `phase-13-integrations.md`(web_search/embedding mock→real 교체) → `phase-14-proactive-agent.md`(심부름 에이전트·능동 알림·멀티모달) → `phase-15-production.md`.
|
|
|
|
|
|
*끝.*
|