diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94181e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# Database Configuration +DB_USER=tva +DB_PASSWORD=tva_password +DB_NAME=tva_db +DATABASE_URL=postgresql+asyncpg://tva:tva_password@postgres:5432/tva_db + +# ChromaDB Configuration +CHROMADB_HOST=chromadb +CHROMADB_PORT=8000 +CHROMA_DB_PATH=/chroma_data + +# Redis Configuration +REDIS_URL=redis://redis:6379/0 +ENABLE_CACHING=true +CACHE_TTL=3600 + +# API Keys +UPSTAGE_API_KEY=your_upstage_api_key_here +# Upstage API 키를 얻으려면: https://www.upstage.ai/ + +# JWT Configuration +JWT_SECRET_KEY=your_super_secret_key_change_in_production +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# LLM Configuration +LLM_MODEL=upstage-solar-pro +LLM_TEMPERATURE=0.3 +LLM_MAX_TOKENS=2000 +LLM_TOP_P=0.9 + +# Agent Configuration +AGENT_TIMEOUT=60 +AGENT_RETRY_COUNT=3 + +# Server Configuration +ENVIRONMENT=development +DEBUG=true +LOG_LEVEL=INFO + +# CORS Configuration +# 개발: * 허용 가능 +# 프로덕션: 구체적인 도메인만 지정 +ALLOWED_ORIGINS=* + +# Upload Configuration +MAX_UPLOAD_SIZE=52428800 # 50MB in bytes +UPLOAD_DIRECTORY=./uploads + +# Frontend Configuration +VITE_API_BASE_URL=http://localhost:8001 +VITE_API_VERSION=v1 +VITE_APP_NAME=TVA +VITE_APP_DESCRIPTION=Target Validation Assistant +VITE_ENABLE_DEBUG=true +VITE_SESSION_TIMEOUT_MINUTES=30 +VITE_TOKEN_REFRESH_BUFFER_MINUTES=5 diff --git a/.env.example_ec2 b/.env.example_ec2 new file mode 100644 index 0000000..999c5d5 --- /dev/null +++ b/.env.example_ec2 @@ -0,0 +1,82 @@ +# ======================================== +# EC2 Production Environment Configuration +# ======================================== +# 사용법: +# 1. EC2 인스턴스에서 이 파일을 .env로 복사 +# 2. YOUR_EC2_PUBLIC_IP를 실제 EC2 Public IP로 변경 +# 3. YOUR_DOMAIN을 실제 도메인으로 변경 (도메인 사용시) +# 4. 모든 보안 키를 강력한 값으로 변경 +# ======================================== + +# Database Configuration +DB_USER=tva +DB_PASSWORD=tva_password +DB_NAME=tva_db +DATABASE_URL=postgresql+asyncpg://tva:tva_password@postgres:5432/tva_db + +# ChromaDB Configuration +CHROMADB_HOST=chromadb +CHROMADB_PORT=8000 +CHROMA_DB_PATH=/chroma_data + +# Redis Configuration (선택사항) +REDIS_URL=redis://redis:6379/0 +ENABLE_CACHING=true +CACHE_TTL=3600 + +# API Keys +# Upstage API 키를 발급받아 입력: https://www.upstage.ai/ +UPSTAGE_API_KEY=your_upstage_api_key_here + +# JWT Configuration - 반드시 강력한 키로 변경하세요! +# 생성 방법: openssl rand -hex 32 +JWT_SECRET_KEY=CHANGE_THIS_TO_VERY_STRONG_SECRET_KEY_AT_LEAST_32_CHARACTERS_LONG +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# LLM Configuration +LLM_MODEL=upstage-solar-pro +LLM_TEMPERATURE=0.3 +LLM_MAX_TOKENS=2000 +LLM_TOP_P=0.9 + +# Agent Configuration +AGENT_TIMEOUT=60 +AGENT_RETRY_COUNT=3 + +# Server Configuration +ENVIRONMENT=production +DEBUG=false +LOG_LEVEL=INFO + +# CORS Configuration +# ⚠️ 프로덕션에서는 반드시 특정 도메인만 허용하세요! +# 예: ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com +# EC2 Public IP 사용시: http://YOUR_EC2_PUBLIC_IP:3000 +ALLOWED_ORIGINS=http://YOUR_EC2_PUBLIC_IP:3000,https://YOUR_DOMAIN + +# Upload Configuration +MAX_UPLOAD_SIZE=52428800 # 50MB in bytes +UPLOAD_DIRECTORY=./uploads + +# Frontend Configuration +# ⚠️ YOUR_EC2_PUBLIC_IP 또는 YOUR_DOMAIN을 실제 값으로 변경하세요! +# Public IP 사용시: http://YOUR_EC2_PUBLIC_IP:8001 +# 도메인 사용시: https://api.yourdomain.com +VITE_API_BASE_URL=http://YOUR_EC2_PUBLIC_IP:8001 +VITE_API_VERSION=v1 +VITE_APP_NAME=TVA +VITE_APP_DESCRIPTION=Target Validation Assistant +VITE_ENABLE_DEBUG=false +VITE_SESSION_TIMEOUT_MINUTES=30 +VITE_TOKEN_REFRESH_BUFFER_MINUTES=5 + +# ======================================== +# EC2 보안 그룹 설정 필요 포트: +# - 22 (SSH) +# - 80 (HTTP) - 선택사항 +# - 443 (HTTPS) - 선택사항, SSL 인증서 사용시 +# - 3000 (Frontend) +# - 8001 (Backend API) +# ======================================== diff --git a/.gitignore b/.gitignore index 7b53a6f..f0bea1f 100644 --- a/.gitignore +++ b/.gitignore @@ -115,5 +115,3 @@ chroma_data/ # Keep alembic.ini !alembic.ini !backend/alembic.ini - -k8s-secrets.env diff --git a/README.md b/README.md index b9fafe5..f25a69e 100644 --- a/README.md +++ b/README.md @@ -1,344 +1,612 @@ -# 🚀 ByO Target Platform (TVA) +# TVA - AI Research Platform -> **Build your Own Target Validation Assistant** -> 논문은 많지만, 판단에 필요한 구조는 없는 연구 초기 단계를 위해 -> **Multi-Agent 기반 Target Validation 플랫폼**을 제공합니다. -> 🏁 This project was built during a 24-hour hackathon. +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.12+](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://www.python.org/) +[![React 18](https://img.shields.io/badge/React-18-61DAFB.svg)](https://react.dev/) +[![FastAPI 0.109](https://img.shields.io/badge/FastAPI-0.109-009688.svg)](https://fastapi.tiangolo.com/) --- -## ✨ 프로젝트 소개 +## 📋 프로젝트 개요 -ByO Target Platform (TVA)은 -🎯 **연구 초기(Target Validation) 단계**에서 -📚 방대한 문헌을 **구조화된 근거와 판단 단위**로 정리해주는 -**AI 기반 의사결정 보조 플랫폼**입니다. +**TVA (Target Validation Assistant)**는 AI 에이전트 기반의 학술 연구 분석 플랫폼입니다. -Retriever → Extractor → Synthesizer로 이어지는 -**Multi-Agent 파이프라인**을 통해 -논문 검색, 정보 추출, 종합 리포트 생성을 자동화합니다. +**주요 기능:** +- 📚 PDF 문서 업로드 및 임베딩 (페이지 추적) +- 🔍 arXiv 논문 검색 및 자동 다운로드 +- 💬 RAG 기반 문서 분석 (근거 제시) +- 🤖 4개 독립 AI Agent (General, Search, Embedding, Analysis) -본 프로젝트는 해커톤 환경에서 -빠른 의사결정과 확장 가능성을 중심으로 설계되었습니다. +--- + +## 🏗️ 프로젝트 구조 + +``` +tva/ +├── frontend/ # React + Vite (3-Panel UI) +├── backend/ # FastAPI + PostgreSQL + ChromaDB +│ └── app/agents/ # 모듈화된 AI Agent 시스템 +└── Specification/ # 설계 문서 +``` --- -## 🧑‍🤝‍🧑 팀 ByO +## ⚡ 빠른 시작 -| 이름 | 역할 | GitHub | -|------|------|--------| -| 강유비 | Frontend / Backend | https://github.com/rocodrama | -| 권태성 | AI | https://github.com/TaeSeongkwon0521 | -| 우서연 | AI | https://github.com/SYWoo02 | -| 이상화 | AI | https://github.com/lsanghwa72 | -| 김지훈 | Backend | https://github.com/Life-00 | +### 요구사항 +- **Backend**: Python 3.12+, Docker, Docker Compose +- **Frontend**: Node.js 18+ +- **API Key**: Upstage API Key (LLM + Embedding) -> 각 파트는 역할을 분리하되, -> **PR 기반 협업**을 통해 코드 품질과 변경 이력을 관리합니다. +### 1. 백엔드 실행 ---- +```bash +cd backend + +# 환경 변수 설정 +cp .env.example .env +# .env 파일에서 UPSTAGE_API_KEY 설정 -## 🎯 프로젝트 개요 +# Docker로 실행 (PostgreSQL + ChromaDB + FastAPI) +docker-compose up -d -- **프로젝트 목적** - 연구 초기 단계에서 타깃(Myostatin 등)에 대한 근거 수준을 - 빠르게 파악하고, “어디까지 검증되었는가”를 구조적으로 제시 +# 로그 확인 +docker-compose logs -f backend +``` + +**백엔드 접속:** +- API: http://127.0.0.1:8001 +- Swagger UI: http://127.0.0.1:8001/docs -- **문제 정의** - - 논문은 많지만 판단 기준이 흩어져 있음 - - 실험 단계(in vitro / in vivo / clinical)가 혼재 - - 연구자가 직접 정리·비교해야 하는 비용이 큼 +### 2. 프론트엔드 실행 -- **해결 접근 방식** - - 문헌 자동 수집 (PubMed / ArXiv 등) - - LLM 기반 정보 추출 및 검증 - - Target Dossier 형태의 구조화된 결과 제공 +```bash +cd frontend + +# 패키지 설치 +npm install + +# 개발 서버 실행 +npm run dev +``` + +**프론트엔드 접속:** http://127.0.0.1:5173 --- -## ✨ 주요 기능 +## 🤖 AI Agent 시스템 -- 🔍 **Retriever Agent** - - 문헌 검색 - - Query expansion 및 semantic ranking +현재 **4개 독립 Agent**가 운영 중입니다. 각 Agent는 완전히 독립적이며, 표준화된 구조를 따릅니다. -- 🧠 **Extractor Agent** - - 논문에서 핵심 실험 조건 및 결과 추출 - - 근거 타입(in vitro / in vivo / clinical) 구조화 +### 1. GeneralChatAgent +**용도**: LLM 기반 일반 대화 +**특징**: 선택적 문서 컨텍스트 제공 (RAG) +**API**: `/api/v1/agents/general/message` -- 🧩 **Synthesizer Agent** - - 다수 논문의 결과를 종합 - - Target Dossier 및 요약 리포트 생성 +### 2. SearchAgent +**용도**: arXiv 논문 검색 및 다운로드 +**특징**: LLM 기반 요청 개수 추출, 중복 제거, 관련성 필터링 +**API**: `/api/v1/agents/search` -- 💬 **Research / Chat API** - - 세션 기반 대화형 분석 - - Research / Extract / Report 플로우 지원 +### 3. EmbeddingAgent +**용도**: PDF 문서 처리 및 임베딩 +**특징**: 페이지 번호 추적, 토큰 기반 청킹, 자동 요약 생성 +**API**: `/api/v1/agents/embedding` -- 📊 **Frontend Dashboard** - - 연구 결과 시각화 - - PDF 업로드 및 분석 결과 확인 +### 4. AnalysisAgent +**용도**: RAG 기반 문서 분석 +**특징**: Vector search + 근거 제시 (문서명, 페이지, 텍스트 발췌) +**API**: `/api/v1/agents/analysis` + +**자세한 내용:** [`backend/app/agents/README.md`](backend/app/agents/README.md) --- -## 🛠️ 기술 스택 +## 🎨 Frontend 기능 -### Frontend -- React -- Vite -- Nginx (Production build) -- Tailwind CSS +### 3-Panel Workspace +- **Library Panel**: 문서 목록, 필터링, 업로드 +- **PDF Viewer**: react-pdf 기반 문서 뷰어 +- **Chat Panel**: 4가지 Agent 모드 전환 -### Backend -- Python 3.12 -- FastAPI -- SQLAlchemy -- Alembic (DB migration) -- JWT 기반 인증 - -### AI / ML -- Upstage Solar LLM (`solar-pro2`) -- Upstage Embedding (`solar-embedding-1-large`) -- LangChain -- ChromaDB (Vector DB) -- FAISS / Sentence-Transformers - -### Infrastructure -- Docker / Docker Compose -- PostgreSQL 15 -- GitHub Actions (CI/CD) +### Agent 모드 +- **General**: 일반 대화 (선택적 문서 컨텍스트) +- **Search**: arXiv 논문 검색 +- **Analysis**: 선택된 문서 RAG 분석 (근거 포함) +- **Report**: 준비 중 + +### 주요 기술 +- React 18 + Vite +- Zustand (상태 관리) +- TailwindCSS (스타일링) +- React-PDF (PDF 렌더링) + +**자세한 내용:** [`frontend/README.md`](frontend/README.md) + +--- + +## 🗄️ 백엔드 아키텍처 + +### 기술 스택 +- **Framework**: FastAPI (비동기) +- **Database**: PostgreSQL (문서 메타데이터, 청크, 채팅 기록) +- **Vector DB**: ChromaDB (임베딩 벡터) +- **LLM**: Upstage Solar-1-mini-chat +- **Embedding**: Upstage embedding-passage (4096-dim) + +### 주요 컴포넌트 +- **Agent 시스템**: 모듈화된 독립 Agent (BaseAgent 상속) +- **서비스 계층**: LLMService, EmbeddingService, ChatService +- **API 라우터**: `/api/v1/agents/*`, `/api/v1/documents`, `/api/v1/sessions` +- **인증**: JWT 기반 (준비 완료) + +**자세한 내용:** [`backend/README.md`](backend/README.md) --- -## 🧱 아키텍처 - -```text -[Frontend (React)] - ↓ -[Backend API (FastAPI)] - ↓ -[Multi-Agent Pipeline] - Retriever → Extractor → Synthesizer - ↓ -[LLM (Upstage Solar)] -[Vector DB (Chroma)] -[PostgreSQL] +## 📊 데이터 흐름 + +### 1. 문서 업로드 → 임베딩 +``` +사용자 → PDF 업로드 + → EmbeddingAgent 실행 + → 페이지별 텍스트 추출 + → 토큰 기반 청킹 (2800 토큰, 오버랩 150) + → 임베딩 생성 (Upstage) + → PostgreSQL (청크 + 페이지 번호) + → ChromaDB (벡터) +``` + +### 2. 논문 검색 +``` +사용자 → 검색 쿼리 + → SearchAgent 실행 + → LLM이 요청 개수 추출 + → arXiv API 호출 + → 관련성 필터링 + → PDF 다운로드 (/uploads/{session_id}/) + → DB 등록 (is_indexed=False) +``` + +### 3. 문서 분석 +``` +사용자 → 질문 + 문서 선택 + → AnalysisAgent 실행 + → Vector search (ChromaDB, 상위 5개 청크) + → 메타데이터 보강 (PostgreSQL, 페이지 번호) + → LLM 답변 생성 + → 근거 추출 (문서명, p.X, 텍스트) + → 응답 반환 ``` --- -## 📁 레포지토리 구조 (상세) +## 🔧 개발 가이드 + +### 신규 Agent 개발 +Agent 개발자를 위한 상세 가이드는 다음을 참조하세요: +- **Agent 개발 표준**: [`backend/app/agents/README.md`](backend/app/agents/README.md) +- **필수 구조**: `agent.py`, `schemas.py`, `prompt.py` +- **BaseAgent 상속**: `async execute(request) -> response` +### API 테스트 ```bash -ByO-Target-Platform -├── .dockerignore -├── .gitignore -├── .python-version -├── docker-compose.yml -├── env_example -├── CONTRIBUTING.md -├── README.md -├── README_Docker.md -├── .github/ -│ └── workflows/ -│ └── deploy.yml -├── backend/ -│ ├── .python-version -│ ├── Dockerfile -│ ├── README.md -│ ├── main.py -│ ├── pyproject.toml -│ ├── uv.lock -│ ├── alembic.ini -│ ├── requirementst.txt -│ ├── alembic/ -│ │ ├── README -│ │ ├── env.py -│ │ ├── script.py.mako -│ │ └── versions/ -│ │ └── 0fc328c2ff1b_init_schema.py -│ └── app/ -│ ├── __init__.py -│ ├── agents/ -│ │ ├── extractor/ -│ │ │ ├── agent.py -│ │ │ ├── parser.py -│ │ │ ├── prompts.py -│ │ │ ├── claim_filter.py -│ │ │ ├── claim_type_classifier.py -│ │ │ ├── outcome_claim_builder.py -│ │ │ └── outcome_sentence_selector.py -│ │ ├── retriever/ -│ │ │ ├── agent.py -│ │ │ ├── pipeline.py -│ │ │ ├── prompts.py -│ │ │ ├── state.py -│ │ │ ├── types.py -│ │ │ ├── query_expander.py -│ │ │ ├── semantic_ranker.py -│ │ │ ├── paper_filter.py -│ │ │ ├── pubmed_fetcher.py -│ │ │ ├── arxiv_fetcher.py -│ │ │ └── pdf_fetcher.py -│ │ ├── synthesizer/ -│ │ │ ├── __init__.py -│ │ │ ├── agent.py -│ │ │ ├── assembler.py -│ │ │ ├── guards.py -│ │ │ ├── prompts.py -│ │ │ ├── renderer.py -│ │ │ ├── renderer_markdown.py -│ │ │ ├── renderer_pdf.py -│ │ │ └── test_dossier_shape.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ ├── test_retriever_integration.py -│ │ └── test_retriever_preview.py -│ ├── api/ -│ │ ├── __init__.py -│ │ ├── deps.py -│ │ └── v1/ -│ │ ├── __init__.py -│ │ ├── auth.py -│ │ ├── chat.py -│ │ ├── extract.py -│ │ ├── files.py -│ │ ├── report.py -│ │ ├── research.py -│ │ ├── selections.py -│ │ └── sessions.py -│ ├── core/ -│ │ ├── __init__.py -│ │ ├── config.py -│ │ ├── database.py -│ │ ├── embeddings.py -│ │ ├── llm.py -│ │ └── tokenizer.py -│ ├── models/ -│ │ ├── __init__.py -│ │ ├── base.py -│ │ ├── chat.py -│ │ ├── pipeline.py -│ │ └── user.py -│ ├── schemas/ -│ │ ├── __init__.py -│ │ ├── auth.py -│ │ ├── chat.py -│ │ ├── dossier.py -│ │ ├── extract.py -│ │ ├── files.py -│ │ ├── knowledge.py -│ │ ├── messages.py -│ │ ├── query.py -│ │ ├── report.py -│ │ ├── research.py -│ │ ├── retrieval.py -│ │ ├── selections.py -│ │ ├── sessions.py -│ │ ├── users.py -│ │ └── vector_hit.py -│ └── service/ -│ ├── __init__.py -│ ├── auth_service.py -│ ├── rag_service.py -│ ├── solar_service.py -│ ├── chromadb/ -│ │ ├── __init__.py -│ │ └── ingest_chunk.py -│ └── pubmed/ -│ ├── client.py -│ ├── parser.py -│ ├── service.py -│ └── (non-ascii filename).txt -└── frontend/ - ├── .gitignore - ├── Dockerfile - ├── README.md - ├── eslint.config.js - ├── index.html - ├── package.json - ├── package-lock.json - ├── vite.config.js - ├── public/ - │ └── TVA.png - └── src/ - ├── main.jsx - ├── App.jsx - ├── App.css - ├── index.css - ├── api/ - │ └── index.js - └── components/ - ├── Auth/ - │ ├── AuthContainer.jsx - │ └── AuthContainer.css - └── Dashboard/ - ├── Dashboard.jsx - ├── Dashboard.css - ├── PdfAnalyzer.jsx - └── PdfAnalyzer.css +# Swagger UI +http://127.0.0.1:8001/docs +# 건강 체크 +curl http://127.0.0.1:8001/api/v1/health ``` +--- + +## 📝 로드맵 + +### ✅ 완료 +- [x] 프론트엔드 3-Panel UI +- [x] 백엔드 Agent 시스템 (4개) +- [x] PDF 임베딩 (페이지 추적) +- [x] arXiv 검색 +- [x] RAG 문서 분석 (근거 제시) +- [x] 채팅 히스토리 (모든 Agent 독립) + +### 🔨 진행 중 +- [ ] ChromaDB numpy 호환성 해결 +- [ ] Analysis Agent 프론트엔드 UI 개선 +- [ ] PDF 텍스트 하이라이트 기능 + +### 📅 예정 +- [ ] Report Agent (자동 보고서 생성) +- [ ] PubMed 통합 +- [ ] 다국어 지원 +- [ ] 사용자 관리 (초대, 권한) --- -## ⚙️ 설치 및 실행 -요구사항 +## 📄 라이선스 -- Docker & Docker Compose +MIT License + +--- + +## 👥 기여 + +프로젝트 구조와 표준을 준수하여 기여해주세요: +1. Agent 개발: [`backend/app/agents/README.md`](backend/app/agents/README.md) 참조 +2. Frontend 컴포넌트: [`frontend/README.md`](frontend/README.md) 참조 +3. Pull Request 시 변경사항 명확히 기술 + +--- + +## 📞 문의 + +프로젝트 관련 문의는 Issue를 생성해주세요. + - React.memo & useMemo + - Code splitting with React Router + +### 기술 스택 +```json +{ + "framework": "React 19.2.3", + "bundler": "Vite 7.3.1", + "styling": "TailwindCSS v4", + "state": "Zustand", + "routing": "React Router v6", + "virtualization": "TanStack React Virtual" +} +``` + +### 실행 방법 +```bash +cd frontend +npm install +npm run dev +# http://localhost:5173 +``` + +--- + +## 🚀 Backend (Phase 1: 프로젝트 초기화 완료) + +### 완성된 Phase 1 +- ✅ 프로젝트 폴더 구조 생성 +- ✅ UV 패키지 매니저 설정 (pyproject.toml) +- ✅ Docker + Docker Compose 구성 +- ✅ FastAPI 기본 설정 +- ✅ 중앙화된 LLM 프롬프트 관리 시스템 +- ✅ Environment 설정 자동화 + +### 4가지 AI Agent 설계 + +#### 1️⃣ Search Indexer (논문 검색) +- arXiv API 통합 +- PubMed Central API 통합 +- 논문 메타데이터 추출 +- 유사도 기반 랭킹 + +#### 2️⃣ PDF Analyzer (문서 분석) +- PDF 텍스트 추출 +- 의미 기반 청킹 (512 토큰) +- Upstage Embedding API +- ChromaDB 자동 임베딩 + +#### 3️⃣ RAG Agent (질의응답) +- 의미론적 검색 +- LLM 기반 답변 생성 +- 자동 인용 생성 +- Few-shot 프롬프팅 + +#### 4️⃣ Report Writer (보고서 생성) +- 자동 Literature Review +- Gap Analysis +- Feasibility Assessment +- 학술 양식 준수 + +### 중앙화된 LLM 설정 + +모든 Agent의 프롬프트, 페르소나, Few-shot 예제를 한 곳에서 관리: + +``` +app/config/ +├── settings.py # 환경 변수 & LLM 설정 +├── llm_prompts.py # ✨ 프롬프트 + 페르소나 + Few-shot +└── __init__.py +``` + +**장점:** +- 🔄 프롬프트 수정 시 모든 Agent에 자동 반영 +- 📚 페르소나/Few-shot을 한 파일에서 관리 +- 🧪 A/B 테스트 용이 +- 📊 성능 추적 용이 + +### 데이터베이스 설계 + +#### PostgreSQL (9개 정규화 테이블) +- **users**: 사용자 계정 +- **sessions**: 연구 세션 +- **documents**: PDF 메타데이터 +- **document_annotations**: 분석 결과 +- **chat_messages**: Q&A 기록 +- **analysis_reports**: 생성된 보고서 +- **agent_logs**: Agent 실행 추적 +- **api_usage**: API 사용량 추적 +- **migrations**: 스키마 버전 관리 + +#### ChromaDB (단일 컬렉션) +- **documents_chunks**: 모든 문서 임베딩 + - 메타데이터 필터링 (session_id, document_id, source_section) + - 의미론적 검색 지원 + +### 기술 스택 + +```json +{ + "framework": "FastAPI 0.109.0", + "database": "PostgreSQL 16", + "vectordb": "ChromaDB 0.4.18", + "orm": "SQLAlchemy 2.0.23", + "llm": "Upstage API", + "packageManager": "UV", + "python": "3.12+", + "deployment": "Docker + Docker Compose" +} +``` + +### 폴더 구조 + +``` +backend/ +├── .env # 환경 변수 +├── docker-compose.yml # 3개 서비스 (PostgreSQL, ChromaDB, Redis) +├── Dockerfile +├── pyproject.toml # UV 패키지 설정 +├── README.md +│ +├── app/ +│ ├── config/ # ✨ 중앙 설정 +│ │ ├── settings.py +│ │ └── llm_prompts.py +│ ├── main.py # FastAPI 앱 +│ ├── db/ # ORM & 마이그레이션 +│ ├── services/ # 비즈니스 로직 +│ ├── agents/ # 4개 독립 Agent +│ │ ├── search_indexer/ +│ │ ├── pdf_analyzer/ +│ │ ├── rag_agent/ +│ │ ├── report_writer/ +│ │ └── common/ # 공용 유틸리티 +│ └── api/v1/ # API 라우터 +│ +└── tests/ # 테스트 스위트 +``` -- (로컬 실행 시) Python 3.12, Node.js 20+ +### 실행 방법 -- 환경 변수 설정 +#### 로컬 개발 ```bash +cd backend + +# 1. 환경 변수 설정 cp .env.example .env -# .env 파일에 API KEY 등 필수 값 입력 +# .env 파일에서 UPSTAGE_API_KEY 입력 + +# 2. 의존성 설치 +pip install uv # UV 설치 (처음 1회) +uv pip install -e . + +# 3. FastAPI 서버 실행 +uvicorn app.main:app --reload +# http://localhost:8000/docs ``` -- Docker 실행 (권장) +#### Docker (권장) ```bash -docker compose up --build +cd backend + +# 1. 환경 변수 설정 +cp .env.example .env + +# 2. 서비스 실행 +docker-compose up -d + +# 3. 로그 확인 +docker-compose logs -f backend + +# 접속 주소 +# API: http://localhost:8001 +# Docs: http://localhost:8001/docs ``` -- Frontend: http://localhost -- Backend API: http://localhost:8000 -- ChromaDB: http://localhost:8001 -- PostgreSQL: http://localhost:5432 --- -```env -## 🔐 주요 환경 변수 -# Upstage -UPSTAGE_API_KEY= -UPSTAGE_MODEL=solar-pro2 -UPSTAGE_EMBED_MODEL=solar-embedding-1-large - -# JWT -JWT_SECRET_KEY= -ACCESS_TOKEN_EXPIRE_MINUTES=60 - -# Database -DB_USER=postgres -DB_PASSWORD=password -DB_NAME=tva-db - -# Chroma -CHROMA_HOST=localhost -CHROMA_PORT=8000 - -# Frontend -VITE_API_BASE_URL=http://localhost:8000/api/v1 -CORS_ORIGINS=http://localhost:5173,http://localhost:80 +## 📚 설계 문서 + +### Specification 폴더 +``` +Specification/ +├── frontend_ROADMAP.md # Frontend 로드맵 (✅ 완료) +├── backend_ROADMAP.md # Backend 로드맵 (🔨 진행 중) +├── agent_list.md # 4 Agent 상세 설계 +├── db_postgresql.md # PostgreSQL 설계 +├── db_chromadb.md # ChromaDB 설계 +├── llm_config_management.md # LLM 설정 시스템 설계 +└── frontend.md # Frontend 기본 명세 +``` + +### 문서 다운로드 경로 +- [Frontend ROADMAP](/Specification/frontend_ROADMAP.md) +- [Backend ROADMAP](/Specification/backend_ROADMAP.md) +- [Agent 설계](/Specification/agent_list.md) +- [LLM 설정 관리](/Specification/llm_config_management.md) + +--- + +## 🔄 프로젝트 진행 상황 + +### 완료 (✅) +- ✅ Frontend Phase 1-4 (100%) +- ✅ 전체 설계 문서 +- ✅ Backend Phase 1 (프로젝트 초기화) + +### 진행 중 (🔨) +- 🔨 Backend Phase 2 (PostgreSQL ORM 모델) +- 🔨 Backend Phase 3 (Agent 1: Search Indexer) + +### 예정 (📋) +- 📋 Backend Phase 4 (Agent 2: PDF Analyzer) +- 📋 Backend Phase 5 (Agent 3: RAG Agent) +- 📋 Backend Phase 6 (Agent 4: Report Writer) +- 📋 Frontend-Backend 통합 +- 📋 Docker 배포 최적화 + +--- + +## 🛠️ 개발 환경 설정 + +### 필수 요구사항 +- Python 3.12+ +- Node.js 18+ +- Docker & Docker Compose +- Upstage API Key (https://console.upstage.ai/) + +### 로컬 개발 셋업 + +```bash +# 1. 저장소 클론 +git clone +cd tva + +# 2. Frontend 설정 +cd frontend +npm install +npm run dev # http://localhost:5173 + +# 3. Backend 설정 (다른 터미널) +cd backend +cp .env.example .env +# .env에서 UPSTAGE_API_KEY 설정 +docker-compose up -d +# 또는 로컬: uv pip install -e . && uvicorn app.main:app --reload + +# 4. 다 함께 실행 +# Frontend: http://localhost:5173 +# Backend API: http://localhost:8000 (로컬) 또는 :8001 (Docker) +# Docs: http://localhost:8000/docs ``` --- -## 📌 협업 규칙(Hackathon) +## 📊 기술 선택 이유 + +### Frontend +- **React 19**: 최신 기능, 성능 최적화 +- **Vite**: 빠른 개발 경험 +- **TailwindCSS**: 유지보수 가능한 스타일 +- **Zustand**: 간단한 상태 관리 + +### Backend +- **FastAPI**: 비동기, 자동 문서화, 타입 검증 +- **SQLAlchemy**: 강력한 ORM, 마이그레이션 +- **ChromaDB**: 간단한 벡터 DB, 메타데이터 필터링 +- **Upstage API**: 한국어 최적화, 안정적 서비스 +- **UV**: 빠른 패키지 설치, 결정적 lockfile + +### Database +- **PostgreSQL**: 안정성, 확장성, JSON 지원 +- **ChromaDB**: 의미론적 검색, 메타데이터 관리 + +--- + +## 🎯 핵심 기능 + +### 1. 의미론적 검색 +- 사용자 쿼리를 임베딩 +- ChromaDB에서 유사 청크 검색 +- 페이지/섹션 참조 제공 + +### 2. 자동 보고서 생성 +- 선택된 문서 분석 +- Literature Review 자동 작성 +- Gap Analysis & Feasibility Assessment +- 학술 양식 준수 + +### 3. 다중 출처 지원 +- 로컬 PDF 업로드 +- arXiv 논문 검색 +- PubMed Central 논문 검색 + +### 4. Agent 추적 +- 각 Agent의 실행 로그 +- API 사용량 통계 +- 실행 시간 측정 + +--- + +## 📈 성능 목표 + +| 메트릭 | 목표 | +|--------|------| +| Frontend FCP | < 1s | +| API 응답 | < 500ms | +| PDF 분석 (10개) | < 45s | +| 보고서 생성 | < 60s | +| 동시 사용자 | 100+ | + +--- + +## 🔒 보안 고려사항 + +- ✅ JWT 기반 인증 +- ✅ HTTPS 준비 (배포 시) +- ✅ CORS 설정 +- ✅ Rate limiting (향후) +- ✅ API key 보안 (환경변수) + +--- + +## 📝 라이선스 + +MIT License - 자유롭게 사용하세요. + +--- + +## 👥 기여 + +TVA는 학술 연구를 위한 오픈소스 프로젝트입니다. +기여는 언제든 환영합니다! + +--- + +## 📞 연락처 - - main 브랜치는 PR로 병합 +- 📧 이메일: [프로젝트 연락처] +- 🐛 이슈: [GitHub Issues] - - 기능 단위 feature 브랜치 사용 +--- + +## 🗺️ 로드맵 + +### 2026 Q1 +- ✅ Frontend 완성 +- ✅ Backend Phase 1 +- 🔨 Backend Phase 2-4 + +### 2026 Q2 +- 🔨 Backend Phase 5-6 +- 📋 통합 테스트 +- 📋 성능 최적화 + +### 2026 Q3 +- 📋 프로덕션 배포 +- 📋 모니터링 & 로깅 +- 📋 추가 기능 (Webhooks, API 확장) - - 리뷰는 가능한 범위 내에서 간단히 +--- + +**마지막 업데이트**: 2026-01-17 +**상태**: 🟡 진행 중 +**Frontend**: ✅ 완료 | **Backend**: 🔨 진행 중 | **배포**: 📋 예정 + +--- - - 데모 안정성을 우선하여 merge 관리 +Made with ❤️ for Academic Research diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..9e48fbd --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,25 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov +.env +.env.local +.venv +venv +env +.DS_Store +*.log +tests +.git +.gitignore +README.md +alembic.ini diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 67a2100..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - libpq-dev \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . - -# Install dependencies using pip -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Create uploads directory -RUN mkdir -p /app/uploads - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic/versions/006_add_section_split_confidence.py b/backend/alembic/versions/006_add_section_split_confidence.py new file mode 100644 index 0000000..82d0078 --- /dev/null +++ b/backend/alembic/versions/006_add_section_split_confidence.py @@ -0,0 +1,35 @@ +"""Add section_split_confidence column to documents table + +Revision ID: 006 +Revises: 005 +Create Date: 2026-01-19 + +Changes: +- Add section_split_confidence column to documents table to track whether section splitting used LLM or fallback +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "006" +down_revision = "005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add section_split_confidence column to documents table.""" + + # Add section_split_confidence column with default value + op.add_column( + "documents", + sa.Column("section_split_confidence", sa.String(50), nullable=False, server_default="unknown") + ) + + +def downgrade() -> None: + """Remove section_split_confidence column from documents table.""" + + op.drop_column("documents", "section_split_confidence") diff --git a/backend/app/agents/analysis_agent/agent.py b/backend/app/agents/analysis_agent/agent.py index 2f77b80..8dc89d3 100644 --- a/backend/app/agents/analysis_agent/agent.py +++ b/backend/app/agents/analysis_agent/agent.py @@ -12,6 +12,7 @@ import json from typing import List, Dict, Any, Optional from datetime import datetime +from sqlalchemy import select from app.agents.base_agent import BaseAgent from app.agents.analysis_agent.schemas import ( @@ -32,6 +33,12 @@ from sqlalchemy import select, and_ from app.config import settings +# ReAct Reasoning Tool용 +from app.tools.reasoning.react_quality_gate import ( + react_quality_gate, + EvidenceItem, +) + logger = logging.getLogger(__name__) @@ -56,14 +63,17 @@ def _get_chroma_collection(self): if self.collection is None: try: import chromadb + self.chroma_client = chromadb.HttpClient( host=settings.chromadb_host, port=settings.chromadb_port ) + self.collection = self.chroma_client.get_or_create_collection( name="document_embeddings" ) logger.info("[AnalysisAgent] ChromaDB connected") + except Exception as e: logger.error(f"[AnalysisAgent] ChromaDB connection failed: {str(e)}") self.collection = None @@ -83,54 +93,156 @@ async def execute(self, request: AnalysisAgentRequest) -> AnalysisAgentResponse: logger.info(f"[AnalysisAgent] Analyzing: {request.content[:50]}...") logger.info(f"[AnalysisAgent] Selected documents: {len(request.selected_documents)}") - if not request.selected_documents: + # Step 1: Get document IDs (allow empty for "search all documents" mode) + document_ids = [] + if request.selected_documents: + document_ids = [doc.get('id') for doc in request.selected_documents if doc.get('id')] + + if request.selected_documents and not document_ids: return AnalysisAgentResponse( success=False, answer="", - error="No documents selected for analysis" + error="No valid document IDs found" ) - # Step 1: Get document IDs - document_ids = [doc.get('id') for doc in request.selected_documents if doc.get('id')] - if not document_ids: - return AnalysisAgentResponse( - success=False, - answer="", - error="No valid document IDs found" + if not document_ids and request.selected_documents: + logger.warning("[AnalysisAgent] Specific documents selected but no valid IDs found") + elif not document_ids: + logger.info("[AnalysisAgent] No documents specified - searching across all available documents") + + logger.info(f"[AnalysisAgent] Analyzing document IDs: {document_ids if document_ids else 'ALL'}") + + # Step 2: Retrieve relevant chunks from ChromaDB + ReAct loop + MAX_REACT_ATTEMPTS = 5 # 무한 루프 방지 + current_query = request.content + current_top_k = request.top_k + relevant_chunks: List[Dict[str, Any]] = [] + last_gate_result = None + no_chunks_attempts = 0 + + for attempt in range(MAX_REACT_ATTEMPTS): + logger.info(f"[AnalysisAgent][ReAct] Attempt {attempt + 1}") + + relevant_chunks = await self._retrieve_relevant_chunks( + query=current_query, + document_ids=document_ids, + top_k=current_top_k, + min_score=request.min_relevance_score ) - logger.info(f"[AnalysisAgent] Analyzing document IDs: {document_ids}") + if not relevant_chunks: + no_chunks_attempts += 1 + logger.info(f"[AnalysisAgent][ReAct] No chunks retrieved (attempt {no_chunks_attempts})") + + # Aggressively increase search scope if no results + if no_chunks_attempts == 1: + current_top_k = max(50, current_top_k * 3) + logger.info(f"[AnalysisAgent][ReAct] Aggressive increase: top_k → {current_top_k}") + continue + elif no_chunks_attempts == 2: + current_query = await self._rewrite_query_with_llm( + request.content, + ["검색 결과 없음 - 쿼리 재작성 필요"], + ) + logger.info(f"[AnalysisAgent][ReAct] Rewriting query due to no results: {current_query}") + continue + else: + # Give up and break after 2+ attempts + break + + evidence_items = [ + EvidenceItem( + content=chunk["text"], + metadata={ + "document_id": chunk.get("document_id"), + "document_title": chunk.get("document_title"), + "filename": chunk.get("filename", chunk.get("document_title", "Unknown")), + "section_type": chunk.get("section_type"), + } + ) + for chunk in relevant_chunks + ] + + gate_result = await react_quality_gate( + task_goal=request.analysis_goal or "RAG-based document analysis", + query=request.content, + evidence_items=evidence_items, + llm_service=self.llm_service, + ) - # Step 2: Retrieve relevant chunks from ChromaDB - relevant_chunks = await self._retrieve_relevant_chunks( - query=request.content, - document_ids=document_ids, - top_k=request.top_k - ) - - if not relevant_chunks: + last_gate_result = gate_result + + if gate_result.accept: + logger.info("[AnalysisAgent][ReAct] Gate accepted") + break + + logger.info( + f"[AnalysisAgent][ReAct] Gate rejected: " + f"{gate_result.failure_reasons}, next={gate_result.next_action}" + ) + + # 🔹 Act 단계 (상태 변화 필수) + if gate_result.next_action == "increase_top_k": + old_top_k = current_top_k + current_top_k = current_top_k * 2 # Double the top_k for more results + logger.info(f"[AnalysisAgent][ReAct] Increasing top_k from {old_top_k} to {current_top_k}") + + elif gate_result.next_action == "rewrite_query": + current_query = await self._rewrite_query_with_llm( + request.content, + gate_result.failure_reasons, + ) + logger.info(f"[AnalysisAgent][ReAct] Query rewritten to: {current_query}") + + elif gate_result.next_action == "ask_user_clarification": + # Try increasing top_k as an alternative to asking user + old_top_k = current_top_k + current_top_k = current_top_k * 2 + logger.info(f"[AnalysisAgent][ReAct] User clarification requested, increasing top_k from {old_top_k} to {current_top_k}") + + elif gate_result.next_action == "stop": + break + + else: + break + + # 🔒 ReAct 최종 실패 → 답변 생성 차단 + if not relevant_chunks or not last_gate_result or not last_gate_result.accept: return AnalysisAgentResponse( success=True, - answer="선택된 문서에서 관련 내용을 찾을 수 없습니다. 다른 문서를 선택하거나 질문을 더 구체적으로 작성해주세요.", + answer=( + "선택된 문서만으로는 현재 질문에 답하기에 " + "근거가 충분하지 않습니다.\n\n" + f"사유: {', '.join(last_gate_result.failure_reasons) if last_gate_result else '근거 부족'}" + ), citations=[], documents_analyzed=len(document_ids), - chunks_retrieved=0 + chunks_retrieved=len(relevant_chunks), + metadata={ + "react_attempts": attempt + 1, + "react_confidence": last_gate_result.confidence if last_gate_result else None, + "react_rationale": last_gate_result.rationale if last_gate_result else None, + } ) - logger.info(f"[AnalysisAgent] Retrieved {len(relevant_chunks)} relevant chunks") # Step 3: Enrich chunks with document metadata enriched_chunks = await self._enrich_chunks_with_metadata(relevant_chunks) - # Step 4: Generate answer using LLM + + # Step 4: Generate answer using LLM (gate 통과 시만) answer, tokens_used = await self._generate_answer( question=request.content, analysis_goal=request.analysis_goal, chunks=enriched_chunks ) - # Step 5: Extract citations - citations = self._extract_citations(enriched_chunks) + + # Step 5: Extract citations (use indices from answer or top chunks) + citations = self._extract_citations( + enriched_chunks, + getattr(self, '_last_used_indices', set(range(min(len(enriched_chunks), 3)))) + ) return AnalysisAgentResponse( success=True, @@ -157,52 +269,219 @@ async def _retrieve_relevant_chunks( self, query: str, document_ids: List[int], - top_k: int + top_k: int, + min_score: float = 0.0 ) -> List[Dict[str, Any]]: """ Retrieve relevant chunks from ChromaDB for selected documents + Uses semantic search via embeddings for meaningful retrieval + Falls back to PostgreSQL if ChromaDB is unavailable """ try: collection = self._get_chroma_collection() if not collection: - raise Exception("ChromaDB collection not available") + logger.warning("[AnalysisAgent] ChromaDB not available, using PostgreSQL fallback") + return await self._retrieve_from_postgresql(query, document_ids, top_k) - # Generate query embedding + # Generate query embedding for semantic search + logger.info(f"[AnalysisAgent] Searching for: {query}") embed_result = await self.embedding_service.embed(query, use_cache=True) query_embedding = embed_result["embedding"] - - # Query ChromaDB with document_id filter - # Get more results since we'll filter by document_ids + logger.info(f"[AnalysisAgent] Query embedding generated (dim={len(query_embedding)})") + + # Normalize document_ids: convert all to integers for comparison + document_ids_int = set(int(doc_id) if isinstance(doc_id, str) else doc_id for doc_id in document_ids) + logger.info(f"[AnalysisAgent] Looking for documents: {document_ids_int}") + + # Query ChromaDB with where filter for selected documents + # ChromaDB returns results sorted by distance (semantic similarity) + where_filter = None + if document_ids_int: + # Build where filter to only search in selected documents + where_filter = { + "document_id": {"$in": list(document_ids_int)} + } + results = collection.query( query_embeddings=[query_embedding], - n_results=top_k * len(document_ids), # Get enough for all documents - include=["documents", "metadatas", "distances"] + n_results=min(100, top_k * max(5, len(document_ids))), # Get enough results + where=where_filter, + include=["documents", "metadatas", "distances", "embeddings"] + ) + + logger.info(f"[AnalysisAgent] ChromaDB returned {len(results['ids'][0])} results") + # Process results and convert to chunk data + relevant_chunks = [] + + for i, chroma_id in enumerate(results["ids"][0]): + metadata = results["metadatas"][0][i] + doc_id_raw = metadata.get("document_id") + # Normalize to integer for comparison + doc_id = int(doc_id_raw) if isinstance(doc_id_raw, str) else doc_id_raw + + distance = results["distances"][0][i] + relevance_score = 1.0 / (1.0 + distance) # Convert L2 distance to similarity score + + # Very relaxed minimum score threshold - allow more results through + # We'll filter quality in the ReAct gate + if relevance_score < 0.3: # Only filter truly low-relevance results + continue + + chunk_data = { + "chroma_id": chroma_id, + "document_id": doc_id, + "chunk_index": metadata.get("chunk_index"), + "filename": metadata.get("filename", metadata.get("document_title", "Unknown")), + "section_title": metadata.get("section_title", "Full Document"), + "text": results["documents"][0][i], + "distance": distance, + "relevance_score": relevance_score + } + relevant_chunks.append(chunk_data) + + # Sort by relevance score (descending) and limit to top_k + relevant_chunks.sort(key=lambda x: x["relevance_score"], reverse=True) + selected_chunks = relevant_chunks[:top_k] + + logger.info(f"[AnalysisAgent] Retrieved {len(selected_chunks)} relevant chunks via ChromaDB semantic search") + if selected_chunks: + logger.info(f"[AnalysisAgent] Top result scores: {[f'{c['relevance_score']:.3f}' for c in selected_chunks[:3]]}") + + return selected_chunks + + except Exception as e: + logger.error(f"[AnalysisAgent] ChromaDB query error: {type(e).__name__}: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + logger.warning("[AnalysisAgent] Falling back to PostgreSQL keyword matching") + return await self._retrieve_from_postgresql(query, document_ids, top_k) + + async def _rewrite_query_with_llm( + self, + original_query: str, + failure_reasons: List[str], + ) -> str: + """ + Rewrite user query to improve semantic search retrieval + Uses LLM to generate more specific search query based on failure reasons + """ + try: + prompt = f"""당신은 정보 검색 전문가입니다. + +원래 질문: {original_query} +검색 실패 사유: {', '.join(failure_reasons) if failure_reasons else '관련 자료 없음'} + +위의 실패 사유를 고려하여 원래 질문을 더 구체적이고 +검색 엔진이 이해하기 쉽도록 한 문장으로 다시 작성하세요. +다시 작성한 질문만 출력하세요.""" + + response = await self.llm_service.generate( + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + max_tokens=128, + ) + rewritten = response.get("content", original_query).strip() + return rewritten if rewritten else original_query + + except Exception as e: + logger.warning(f"[AnalysisAgent] Query rewriting failed: {str(e)}, using original") + return original_query + + async def _retrieve_from_postgresql( + self, + query: str, + document_ids: List[int], + top_k: int + ) -> List[Dict[str, Any]]: + """ + Fallback: Retrieve chunks directly from PostgreSQL when ChromaDB is unavailable + Uses improved keyword matching with fuzzy matching for better recall + """ + try: + if not self.db: + logger.error("[AnalysisAgent] No database session available for fallback") + return [] + + # Convert document_ids to integers (in case they're strings) + doc_ids_int = [int(doc_id) for doc_id in document_ids] + + # Get chunks with document metadata for filename + from sqlalchemy.orm import joinedload + result = await self.db.execute( + select(DocumentChunk) + .options(joinedload(DocumentChunk.document)) + .where(DocumentChunk.document_id.in_(doc_ids_int)) + .order_by(DocumentChunk.document_id, DocumentChunk.chunk_index) + .limit(top_k * 3) # Get more than needed ) + chunks = result.scalars().all() - # Filter and sort by document_ids + if not chunks: + logger.warning(f"[AnalysisAgent] No chunks found in PostgreSQL for documents: {document_ids}") + return [] + + # Improved keyword matching with substring and jamo matching + query_lower = query.lower() + query_words = query_lower.split() relevant_chunks = [] - for i, metadata in enumerate(results["metadatas"][0]): - doc_id = metadata.get("document_id") - if doc_id in document_ids: + + for chunk in chunks: + text_lower = (chunk.text_content or "").lower() + score = 0.0 + + # 1. Exact phrase match (highest priority) + if query_lower in text_lower: + score += 10.0 + + # 2. Substring matches (handles spacing variations like "연구방법" vs "연구 방법") + for word in query_words: + if len(word) > 1: + # Check if word appears as substring + if word in text_lower: + score += 3.0 + # Check if word appears with spaces removed + text_no_space = text_lower.replace(" ", "") + if word in text_no_space: + score += 2.0 + + # 3. Individual character matches + for char in query_lower: + if char not in [' ', ',', '.', '(', ')', '!', '?']: + if char in text_lower: + score += 0.1 + + if score > 0: chunk_data = { - "chroma_id": results["ids"][0][i], - "document_id": doc_id, - "chunk_index": metadata.get("chunk_index"), - "page_number": metadata.get("page_number"), - "text": results["documents"][0][i], - "distance": results["distances"][0][i], - "relevance_score": 1.0 / (1.0 + results["distances"][0][i]) # Convert distance to score + "chroma_id": chunk.chroma_id, + "document_id": chunk.document_id, + "chunk_index": chunk.chunk_index, + "filename": chunk.document.file_name if chunk.document else "Unknown", + "document_title": chunk.document.title if chunk.document else "Unknown", + "text": chunk.text_content, + "distance": 1.0 / (score + 1), # Lower distance for higher score + "relevance_score": score } relevant_chunks.append(chunk_data) - # Sort by relevance and limit to top_k + # Sort by relevance score and return top_k relevant_chunks.sort(key=lambda x: x["relevance_score"], reverse=True) + logger.info(f"[AnalysisAgent] Retrieved {len(relevant_chunks[:top_k])} chunks from PostgreSQL fallback (scores: {[f'{c['relevance_score']:.1f}' for c in relevant_chunks[:3]]})") return relevant_chunks[:top_k] except Exception as e: - logger.error(f"[AnalysisAgent] Chunk retrieval failed: {str(e)}") + logger.error(f"[AnalysisAgent] PostgreSQL fallback failed: {str(e)}") + # Rollback transaction on error + if self.db: + await self.db.rollback() return [] + def _get_document_id(self, chunk: Dict[str, Any]) -> Optional[int]: + return ( + chunk.get("document_id") + or chunk.get("metadata", {}).get("document_id") + ) + + async def _enrich_chunks_with_metadata( self, chunks: List[Dict[str, Any]] @@ -212,7 +491,16 @@ async def _enrich_chunks_with_metadata( """ try: # Get unique document IDs - doc_ids = list(set(chunk["document_id"] for chunk in chunks)) + doc_ids = list( + set( + self._get_document_id(chunk) + for chunk in chunks + if self._get_document_id(chunk) is not None + ) + ) + + if not doc_ids: + return chunks # Fetch document metadata result = await self.db.execute( @@ -223,16 +511,19 @@ async def _enrich_chunks_with_metadata( # Enrich each chunk enriched = [] for chunk in chunks: - doc_id = chunk["document_id"] + doc_id = self._get_document_id(chunk) + base = { + **chunk, + "document_id": doc_id, # 🔥 여기서 top-level로 승격 + } + if doc_id in documents: doc = documents[doc_id] - enriched.append({ - **chunk, + base.update({ "document_title": doc.title, - "document_filename": doc.file_name + "document_filename": doc.file_name, }) - else: - enriched.append(chunk) + enriched.append(chunk) return enriched @@ -254,11 +545,11 @@ async def _generate_answer( context_parts = [] for i, chunk in enumerate(chunks, 1): doc_title = chunk.get("document_title", "Unknown Document") - page_num = chunk.get("page_number", "?") + filename = chunk.get("filename", chunk.get("document_title", "Unknown")) text = chunk.get("text", "") context_parts.append( - f"[{i}] 문서: {doc_title}, 페이지: {page_num}\n{text}\n" + f"[{i}] 파일: {filename}\n{text}\n" ) context_text = "\n---\n".join(context_parts) @@ -280,6 +571,17 @@ async def _generate_answer( answer = response["content"] tokens_used = response["usage"]["total_tokens"] + + # Extract citation indices from answer (e.g., [1], [2], [3]) + import re + used_indices = set() + for match in re.finditer(r'\[(\d+)\]', answer): + idx = int(match.group(1)) - 1 # Convert to 0-based index + if 0 <= idx < len(chunks): + used_indices.add(idx) + + # Store indices for citation extraction + self._last_used_indices = used_indices if used_indices else set(range(min(len(chunks), 3))) return answer, tokens_used @@ -287,17 +589,33 @@ async def _generate_answer( logger.error(f"[AnalysisAgent] Answer generation failed: {str(e)}") return f"답변 생성 중 오류가 발생했습니다: {str(e)}", 0 - def _extract_citations(self, chunks: List[Dict[str, Any]]) -> List[CitationInfo]: + def _extract_citations(self, chunks: List[Dict[str, Any]], used_indices: set = None) -> List[CitationInfo]: """ Extract citation information from chunks + Only include citations for chunks referenced in used_indices """ + if used_indices is None: + used_indices = set(range(len(chunks))) + citations = [] - for chunk in chunks: + for idx, chunk in enumerate(chunks): + # Skip if this chunk index was not used in the answer + if idx not in used_indices: + continue + try: + doc_id = ( + chunk.get("document_id") + or chunk.get("metadata", {}).get("document_id") + ) + + if doc_id is None: + raise KeyError("document_id") + citation = CitationInfo( - document_id=chunk["document_id"], + document_id=doc_id, document_title=chunk.get("document_title", "Unknown"), - page_number=chunk.get("page_number", 0), + filename=chunk.get("filename", chunk.get("document_title", "Unknown")), chunk_index=chunk.get("chunk_index", 0), text_excerpt=chunk["text"][:200] + "..." if len(chunk["text"]) > 200 else chunk["text"], relevance_score=chunk.get("relevance_score", 0.0) diff --git a/backend/app/agents/analysis_agent/analysis_agent_content.md b/backend/app/agents/analysis_agent/analysis_agent_content.md new file mode 100644 index 0000000..22e9e40 --- /dev/null +++ b/backend/app/agents/analysis_agent/analysis_agent_content.md @@ -0,0 +1,74 @@ +# 분석 에이전트 + +## 개요 + +분석 에이전트는 RAG(Retrieval-Augmented Generation) 기반의 문서 분석 에이전트로, 관련 문서에서 정확한 인용을 포함한 증거 기반의 답변을 제공합니다. + +## 작동 방식 + +1. **관련 청크 검색**: 선택된 문서를 기반으로 ChromaDB에서 관련 청크를 추출합니다. +2. **메타데이터 추가**: PostgreSQL에서 페이지 번호와 메타데이터를 추가합니다. +3. **증거 기반 답변 생성**: LLM을 사용하여 인용이 포함된 답변을 생성합니다. +4. **정확한 출처 제공**: 문서 제목, 페이지 번호, 텍스트 발췌를 출력합니다. + +## 사용 기술 및 도구 + +- **ChromaDB**: 문서 청크 저장 및 검색. +- **PostgreSQL**: 페이지 번호와 같은 메타데이터 저장. +- **LLM 서비스**: 증거 기반 답변 생성. +- **ReAct Reasoning Tool**: 품질 게이트 확인 및 증거 항목 생성. +- **SQLAlchemy**: 데이터베이스 상호작용. + +## 주요 구성 요소 + +- **스키마(Schemas)**: 요청 및 응답 구조 정의. +- **프롬프트(Prompts)**: 시스템 및 분석 프롬프트 포함. +- **서비스(Services)**: LLM 및 임베딩 서비스와 통합. +- **데이터베이스 모델**: 문서 및 청크 데이터 관리. + +## 주요 파일 + +- `agent.py`: 분석 에이전트의 주요 구현 파일. + +## 평가 방법 + +### 결과물 평가 기준 + +1. **인용 정확성 (Citation Accuracy)** + - 모든 주장에 [논문 제목] 형식의 인용이 포함되어 있는가? + - 인용된 논문이 실제 선택된 문서와 일치하는가? + - 평가 지표: 인용 포함률, 인용 정확도 + +2. **증거 기반 답변 (Evidence-Based Response)** + - 답변이 검색된 문서 청크의 내용을 기반으로 하는가? + - 환각(hallucination)이 없이 실제 문서 내용만 사용하는가? + - 평가 지표: Faithfulness score, 문서 일치도 + +3. **관련성 (Relevance)** + - 사용자 질문에 적절한 답변을 제공하는가? + - 검색된 청크가 질문과 관련이 있는가? + - 평가 지표: 의미적 유사도, 관련성 점수 + +4. **완전성 (Completeness)** + - 질문에 대한 모든 주요 측면을 다루는가? + - 충분한 컨텍스트와 세부 정보를 제공하는가? + - 평가 지표: 답변 길이, 커버리지 범위 + +### 평가 프로세스 + +```python +# 평가 예시 +result = { + "citation_accuracy": 0.95, # 95% 인용 정확도 + "faithfulness": 0.92, # 92% 문서 충실도 + "relevance": 0.88, # 88% 관련성 + "completeness": 0.85, # 85% 완전성 + "overall_score": 0.90 # 종합 점수 +} +``` + +### 품질 기준 + +- **후수 (>90%)**: 모든 인용이 정확하고 증거 기반 답변 +- **양호 (70-90%)**: 대부분의 인용이 정확하나 일부 개선 필요 +- **개선 필요 (<70%)**: 인용 오류 또는 관련성 부족 diff --git a/backend/app/agents/analysis_agent/prompt.py b/backend/app/agents/analysis_agent/prompt.py index e1e495a..5dfbbbc 100644 --- a/backend/app/agents/analysis_agent/prompt.py +++ b/backend/app/agents/analysis_agent/prompt.py @@ -13,7 +13,7 @@ **답변 형식:** - 명확하고 간결한 한국어 -- 각 주장 뒤에 [문서명, p.X] 형식으로 출처 표기 +- 각 주장 뒤에 [파일명] 형식으로 출처 표기 - 여러 문서의 정보를 종합할 경우 모든 출처 명시 - 불확실한 경우 "문서에 따르면..." 같은 표현 사용 @@ -34,7 +34,8 @@ {context_chunks} 위 문서들을 기반으로 사용자의 질문에 답변해주세요. -반드시 각 주장마다 [문서명, p.페이지번호] 형식으로 출처를 명시하세요. +반드시 각 주장마다 [파일명] 형식으로 출처를 명시하세요. +고유명사(브랜드명, 기술명, 논문 제목 등)를 제외하고는 모두 한글로 작성해주세요. 문서에 없는 내용은 답변하지 마세요.""" @@ -42,4 +43,4 @@ DEFAULT_TOP_K = 5 DEFAULT_MIN_RELEVANCE = 0.5 DEFAULT_TEMPERATURE = 0.3 # Low temperature for factual accuracy -DEFAULT_MAX_TOKENS = 2048 +DEFAULT_MAX_TOKENS = 4096 # 한글 토큰 수 고려하여 증가 diff --git a/backend/app/agents/analysis_agent/schemas.py b/backend/app/agents/analysis_agent/schemas.py index 351e936..c6565b4 100644 --- a/backend/app/agents/analysis_agent/schemas.py +++ b/backend/app/agents/analysis_agent/schemas.py @@ -22,7 +22,7 @@ class CitationInfo(BaseModel): """Information about a citation/source""" document_id: int = Field(..., description="Document ID") document_title: str = Field(..., description="Document title") - page_number: int = Field(..., description="Page number where evidence was found") + filename: str = Field(..., description="Original filename") chunk_index: int = Field(..., description="Chunk index within document") text_excerpt: str = Field(..., description="Relevant text excerpt") relevance_score: float = Field(..., description="Relevance score (0-1)") diff --git a/backend/app/agents/embedding_agent/agent.py b/backend/app/agents/embedding_agent/agent.py index 5ebe192..623a2ac 100644 --- a/backend/app/agents/embedding_agent/agent.py +++ b/backend/app/agents/embedding_agent/agent.py @@ -1,270 +1,281 @@ """ Embedding Agent Implementation -This agent is responsible for: -- Extracting text from PDF files -- Performing token-based text chunking using tokenizer -- Generating embeddings using Upstage Embedding API -- Storing embeddings in ChromaDB -- Updating the PostgreSQL database with the document's status +Responsibilities: +- Extract text from PDF +- Split text into logical sections using LLM +- Chunk text by tokens +- Generate embeddings via EmbeddingService +- Store chunks in PostgreSQL +- Store embeddings in ChromaDB """ -from app.agents.base_agent import BaseAgent -from app.services.embedding_service import EmbeddingService -from app.db.models import Document +import json +import uuid +import re +from typing import List, Dict, Tuple + +from pypdf import PdfReader from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from pathlib import Path -from pypdf import PdfReader + +from app.agents.base_agent import BaseAgent +from app.services.embedding_service import EmbeddingService +from app.services.llm_service import get_llm_service +from app.db.models import Document, DocumentChunk +from app.utils.tokenizer import chunk_text_by_tokens, _truncate_to_tokens + from .schemas import EmbeddingAgentInputSchema, EmbeddingAgentOutputSchema -from app.utils.tokenizer import chunk_text_by_tokens, count_tokens, _truncate_to_tokens +from app.agents.embedding_agent.prompt import ( + SECTION_SPLIT_SYSTEM_PROMPT, + SECTION_SPLIT_USER_PROMPT, + SUMMARY_PROMPT, +) class EmbeddingAgent(BaseAgent): - """Agent for processing PDFs and generating embeddings""" - def __init__(self, db: AsyncSession = None, embedding_service: EmbeddingService = None): super().__init__() self.agent_type = "embedding_agent" self.db = db self.embedding_service = embedding_service + self.llm_service = get_llm_service() - async def extract_text(self, file_path: str) -> tuple[str, list[tuple[int, str]]]: - """ - Extract text from a PDF file with page tracking. - - Returns: - tuple: (full_text, page_texts) - - full_text: All text concatenated - - page_texts: List of (page_number, page_text) tuples - """ + # ------------------------------------------------------------------ + # 1. PDF TEXT EXTRACTION + # ------------------------------------------------------------------ + async def extract_text(self, file_path: str) -> Tuple[str, List[Tuple[int, str]]]: reader = PdfReader(file_path) full_text = "" page_texts = [] - + for page_num, page in enumerate(reader.pages, start=1): - page_text = page.extract_text() + page_text = page.extract_text() or "" full_text += page_text + "\n" page_texts.append((page_num, page_text)) - + return full_text, page_texts - async def chunk_text(self, text: str, max_tokens: int = 2800, overlap_tokens: int = 150) -> list: - """ - Split text into token-based chunks. - - Args: - text: Full text to chunk - max_tokens: Maximum tokens per chunk (default: 2800 for Upstage 4000 limit) - overlap_tokens: Overlap between chunks for context continuity (default: 150) + # ------------------------------------------------------------------ + # 2. SECTION SPLITTING + # ------------------------------------------------------------------ + def _safe_json_loads(self, text: str) -> List[Dict]: + start = text.find("[") + end = text.rfind("]") + + if start == -1 or end == -1 or end <= start: + raise ValueError("No JSON array found") + + candidate = text[start:end + 1] + + try: + return json.loads(candidate) + except json.JSONDecodeError: + repaired = re.sub(r'(? List[Dict]: + lower_text = full_text.lower() + positions = [] + + for title in titles: + idx = lower_text.find(title.lower()) + if idx != -1: + positions.append((title, idx)) + + positions.sort(key=lambda x: x[1]) + + sections = [] + for i, (title, start) in enumerate(positions): + end = positions[i + 1][1] if i + 1 < len(positions) else len(full_text) + sections.append({ + "section_title": title, + "text": full_text[start:end] + }) + + return sections + + async def split_into_sections_with_llm(self, text: str) -> List[Dict]: + truncated = _truncate_to_tokens(text, max_tokens=3000) + user_prompt = SECTION_SPLIT_USER_PROMPT.format(text=truncated) + + try: + response = await self.llm_service.generate( + messages=[{"role": "user", "content": user_prompt}], + system_prompt=SECTION_SPLIT_SYSTEM_PROMPT, + temperature=0.0, + max_tokens=1500, + ) + + content = response.get("content", "") + parsed = self._safe_json_loads(content) + + titles = [ + item["section_title"] + for item in parsed + if isinstance(item, dict) and "section_title" in item + ] + + if not titles: + raise ValueError("No valid section titles") + + sections = self._slice_text_by_titles(text, titles) + + # Ensure at least one section exists + if not sections: + return [{"section_title": "Full Document", "text": text}] - Returns: - List of text chunks - """ - chunks = chunk_text_by_tokens( + return sections + + except Exception as e: + self.logger.warning(f"[EmbeddingAgent] Section split fallback: {e}") + return [{"section_title": "Full Document", "text": text}] + + # ------------------------------------------------------------------ + # 3. CHUNKING + # ------------------------------------------------------------------ + async def chunk_text(self, text: str, max_tokens: int, overlap_tokens: int = 150) -> List[str]: + return chunk_text_by_tokens( text=text, max_tokens=max_tokens, overlap_tokens=overlap_tokens ) - return chunks + # ------------------------------------------------------------------ + # 4. SUMMARY + # ------------------------------------------------------------------ async def _generate_summary(self, text: str) -> str: - """ - Generate a summary of the PDF using LLM. - - Args: - text: Full extracted text from PDF - - Returns: - str: Generated summary in Korean (300-500 words) - """ try: - import httpx - from app.config import settings - from app.agents.embedding_agent.prompt import SUMMARY_PROMPT - - # Truncate text to avoid token limit (2000 tokens for safety) - # Upstage Chat API has 4096 token limit, keep margin for prompt + response - truncated_text = _truncate_to_tokens(text, max_tokens=2000) - token_count = count_tokens(truncated_text) - print(f"[EmbeddingAgent] Summary input: {token_count} tokens") - - # Format prompt with text - prompt = SUMMARY_PROMPT.format(text=truncated_text) - - # Call Upstage Chat API directly - headers = { - "Authorization": f"Bearer {settings.upstage_api_key}", - "Content-Type": "application/json", - } - - payload = { - "model": "solar-1-mini-chat", - "messages": [{"role": "user", "content": prompt}], - "temperature": 0.3, - "max_tokens": 1000, - } - - async with httpx.AsyncClient() as client: - response = await client.post( - "https://api.upstage.ai/v1/chat/completions", - json=payload, - headers=headers, - timeout=30.0, - ) - - if response.status_code == 200: - data = response.json() - if data and "choices" in data and len(data["choices"]) > 0: - summary = data["choices"][0].get("message", {}).get("content", "") - return summary.strip() if summary else "" - - return "" + truncated = _truncate_to_tokens(text, max_tokens=2000) + prompt = SUMMARY_PROMPT.format(text=truncated) + + response = await self.llm_service.generate( + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + max_tokens=800, + ) + return response.get("content", "").strip() except Exception as e: - print(f"[EmbeddingAgent] Failed to generate summary: {str(e)}") + self.logger.warning(f"[EmbeddingAgent] Summary failed: {e}") return "" - async def process_pdf(self, document_id: int, file_path: str, max_tokens: int = 2800): - """ - Process a PDF document: extract text, chunk it, generate embeddings, and store in ChromaDB. - - Args: - document_id: Document ID in database - file_path: Path to PDF file - max_tokens: Maximum tokens per chunk (default: 2800) - """ - from app.db.models import DocumentChunk - import uuid + # ------------------------------------------------------------------ + # 5. MAIN PIPELINE + # ------------------------------------------------------------------ + async def process_pdf(self, document_id: int, file_path: str, max_tokens: int): + if not self.embedding_service: + raise RuntimeError("EmbeddingService not initialized") + + # Get document details for metadata + result = await self.db.execute( + select(Document).where(Document.id == document_id) + ) + document = result.scalar_one_or_none() - # Extract text from PDF with page tracking + if not document: + raise ValueError(f"Document with ID {document_id} not found") + full_text, page_texts = await self.extract_text(file_path) + sections = await self.split_into_sections_with_llm(full_text) - # Chunk the text (token-based) - chunks = await self.chunk_text(full_text, max_tokens=max_tokens) + # sections should always have at least one element due to fallback + if not sections: + self.logger.warning(f"[EmbeddingAgent] No sections generated, using full text") + sections = [{"section_title": "Full Document", "text": full_text}] - # Map each chunk to its page number - chunk_page_mapping = [] - char_position = 0 - - for chunk_idx, chunk_text in enumerate(chunks): - # Find which page this chunk starts in - page_num = 1 - cumulative_chars = 0 - - for page_number, page_text in page_texts: - if char_position < cumulative_chars + len(page_text): - page_num = page_number - break - cumulative_chars += len(page_text) + 1 # +1 for newline - - chunk_page_mapping.append({ - "chunk_index": chunk_idx, - "page_number": page_num, - "text": chunk_text, - "char_count": len(chunk_text) - }) - - char_position += len(chunk_text) + chunk_records = [] + for section_idx, section in enumerate(sections): + chunks = await self.chunk_text(section["text"], max_tokens) + for chunk in chunks: + chunk_records.append({ + "section_title": section["section_title"], + "section_index": section_idx, + "text": chunk, + }) + + if not chunk_records: + raise ValueError("No chunks generated") - # Generate embeddings for each chunk using embed_batch - embedding_result = await self.embedding_service.embed_batch(chunks) + texts = [c["text"] for c in chunk_records] + embedding_result = await self.embedding_service.embed_batch(texts) embeddings = embedding_result["embeddings"] - # Generate summary from the first 2000 tokens of text + if len(embeddings) != len(chunk_records): + raise ValueError("Embedding count mismatch") + summary = await self._generate_summary(full_text) - # Store chunks in PostgreSQL with page numbers db_chunks = [] - for i, chunk_info in enumerate(chunk_page_mapping): - chroma_id = str(uuid.uuid4()) - + for idx, record in enumerate(chunk_records): db_chunk = DocumentChunk( document_id=document_id, - chunk_index=chunk_info["chunk_index"], - page_number=chunk_info["page_number"], - text_content=chunk_info["text"], - char_count=chunk_info["char_count"], - chroma_id=chroma_id, - embedding_model=self.embedding_service.model + chunk_index=idx, + page_number=1, # TEMP: page mapping not implemented + text_content=record["text"], + char_count=len(record["text"]), + chroma_id=str(uuid.uuid4()), + embedding_model=self.embedding_service.model, ) self.db.add(db_chunk) db_chunks.append(db_chunk) - - await self.db.flush() # Get IDs for chunks - - # Store embeddings in ChromaDB with metadata - chroma_ids = [chunk.chroma_id for chunk in db_chunks] - metadatas = [ - { - "document_id": document_id, - "chunk_index": chunk.chunk_index, - "page_number": chunk.page_number, - "char_count": chunk.char_count, - } - for chunk in db_chunks - ] - - # Add to ChromaDB + + await self.db.flush() + await self.embedding_service.add_documents( - ids=chroma_ids, + ids=[c.chroma_id for c in db_chunks], embeddings=embeddings, - documents=chunks, - metadatas=metadatas + documents=texts, + metadatas=[ + { + "document_id": document_id, + "chunk_index": c.chunk_index, + "section_title": r["section_title"], + "char_count": c.char_count, + "filename": document.file_name, + "document_title": document.title, + } + for c, r in zip(db_chunks, chunk_records) + ], + ) + + result = await self.db.execute( + select(Document).where(Document.id == document_id) ) - - # Update document status in PostgreSQL - query = select(Document).where(Document.id == document_id) - result = await self.db.execute(query) document = result.scalar_one_or_none() if document: document.is_indexed = True - document.page_count = len(page_texts) # Actual page count - document.summary = summary # Store the generated summary + document.page_count = len(page_texts) + document.summary = summary + # Track whether section split used LLM or fallback + section_split_used_fallback = ( + len(sections) == 1 and sections[0]["section_title"] == "Full Document" + ) + document.section_split_confidence = ( + "fallback" if section_split_used_fallback else "llm" + ) await self.db.commit() return { "status": "success", "document_id": document_id, - "chunk_count": len(chunks), + "chunk_count": len(db_chunks), "embedding_count": len(embeddings), - "embedding_dim": self.embedding_service.embedding_dim, "summary": summary, - "page_count": len(page_texts), } + # ------------------------------------------------------------------ + # 6. EXECUTE + # ------------------------------------------------------------------ async def execute(self, request: EmbeddingAgentInputSchema) -> EmbeddingAgentOutputSchema: - """ - Main execution method required by BaseAgent. - - Args: - request: EmbeddingAgentInputSchema with document_id and chunk_size - - Returns: - EmbeddingAgentOutputSchema with processing results - """ try: - # Validate session if provided - if request.session_id and not self.validate_session(request.session_id): - return EmbeddingAgentOutputSchema( - success=False, - document_id=request.document_id, - status="failed", - error="Invalid session ID" - ) - - # Fetch document from database if not self.db: - return EmbeddingAgentOutputSchema( - success=False, - document_id=request.document_id, - status="failed", - error="Database session not initialized" - ) - - query = select(Document).where(Document.id == request.document_id) - result = await self.db.execute(query) + raise RuntimeError("Database session not initialized") + + result = await self.db.execute( + select(Document).where(Document.id == request.document_id) + ) document = result.scalar_one_or_none() if not document: @@ -272,18 +283,13 @@ async def execute(self, request: EmbeddingAgentInputSchema) -> EmbeddingAgentOut success=False, document_id=request.document_id, status="failed", - error="Document not found" + error="Document not found", ) - # Process the PDF - file_path = document.file_path - result = await self.process_pdf(request.document_id, file_path, request.chunk_size) - - # Log execution - self.log_execution( - request.session_id or "unknown", - "completed", - f"Processed document {request.document_id} with {result['chunk_count']} chunks" + result = await self.process_pdf( + document_id=request.document_id, + file_path=document.file_path, + max_tokens=request.chunk_size, ) return EmbeddingAgentOutputSchema( @@ -292,28 +298,15 @@ async def execute(self, request: EmbeddingAgentInputSchema) -> EmbeddingAgentOut chunk_count=result["chunk_count"], embedding_count=result["embedding_count"], status=result["status"], - data={ - "file_path": file_path, - "metadata": { - "document_id": request.document_id, - "chunk_size": request.chunk_size - } - } + data={"summary": result["summary"]}, ) - except FileNotFoundError as e: - error_info = await self.handle_error(e, f"File not found: {str(e)}") - return EmbeddingAgentOutputSchema( - success=False, - document_id=request.document_id, - status="failed", - error=error_info["error_message"] - ) except Exception as e: error_info = await self.handle_error(e, "PDF processing error") return EmbeddingAgentOutputSchema( success=False, document_id=request.document_id, status="failed", - error=error_info["error_message"] + error=error_info["error_message"], ) + diff --git a/backend/app/agents/embedding_agent/embedding_agent_content.md b/backend/app/agents/embedding_agent/embedding_agent_content.md new file mode 100644 index 0000000..7fbd778 --- /dev/null +++ b/backend/app/agents/embedding_agent/embedding_agent_content.md @@ -0,0 +1,83 @@ +# 임베딩 에이전트 + +## 개요 + +임베딩 에이전트는 문서를 처리하여 임베딩을 생성하고 효율적인 검색을 위해 저장하는 역할을 합니다. + +## 작동 방식 + +1. **텍스트 추출**: PDF 파일에서 텍스트를 추출합니다. +2. **텍스트 분할**: LLM을 사용하여 텍스트를 논리적인 섹션으로 나눕니다. +3. **텍스트 청크화**: 토큰 기반 청크로 텍스트를 분할합니다. +4. **임베딩 생성**: EmbeddingService를 사용하여 임베딩을 생성합니다. +5. **데이터 저장**: PostgreSQL에 청크를 저장하고 ChromaDB에 임베딩을 저장합니다. + +## 사용 기술 및 도구 + +- **PyPDF**: PDF 파일에서 텍스트 추출. +- **LLM 서비스**: 논리적 섹션 분할 및 요약. +- **EmbeddingService**: 임베딩 생성. +- **PostgreSQL**: 문서 청크 저장. +- **ChromaDB**: 임베딩 저장. +- **SQLAlchemy**: 데이터베이스 상호작용. + +## 주요 구성 요소 + +- **스키마(Schemas)**: 입력 및 출력 구조 정의. +- **프롬프트(Prompts)**: 섹션 분할 및 요약 프롬프트 포함. +- **토크나이저(Tokenizer)**: 토큰 기반 텍스트 청크화 처리. + +## 주요 파일 + +- `agent.py`: 임베딩 에이전트의 주요 구현 파일. + +## 평가 방법 + +### 결과물 평가 기준 + +1. **텍스트 추출 품질 (Text Extraction Quality)** + - PDF에서 텍스트를 정확하게 추출했는가? + - 포맷팅, 테이블, 수식 등을 올바르게 처리했는가? + - 평가 지표: 추출된 텍스트의 가독성, 문자 정확도 + +2. **청크 분할 품질 (Chunking Quality)** + - 논리적 섹션으로 적절하게 분할되었는가? + - 토큰 기반 청크가 의미 단위를 유지하는가? + - 평가 지표: 청크 크기 분포, 의미 일관성 + +3. **메타데이터 정확성 (Metadata Accuracy)** + - filename, document_title이 정확하게 저장되었는가? + - document_id가 올바르게 연결되었는가? + - 평가 지표: 메타데이터 완성도, 정확도 + +4. **임베딩 품질 (Embedding Quality)** + - 생성된 임베딩이 의미적 유사도를 잘 표현하는가? + - ChromaDB에 올바르게 저장되었는가? + - 평가 지표: 임베딩 차원, 검색 성능 + +5. **처리 성공률 (Processing Success Rate)** + - 전체 페이지 중 성공적으로 처리된 비율 + - 오류 없이 완료되었는가? + - 평가 지표: 처리 성공률, 오류율 + +### 평가 프로세스 + +```python +# 평가 예시 +result = { + "total_pages": 150, # 총 페이지 수 + "processed_pages": 148, # 처리된 페이지 + "total_chunks": 450, # 생성된 청크 수 + "embeddings_generated": 450, # 생성된 임베딩 수 + "avg_chunk_size": 512, # 평균 청크 크기 (토큰) + "processing_success_rate": 0.987, # 148/150 = 98.7% + "metadata_completeness": 1.0, # 모든 메타데이터 포함 + "overall_score": 0.95 # 종합 점수 +} +``` + +### 품질 기준 + +- **후수 (>95%)**: 거의 모든 페이지 처리 성공, 정확한 메타데이터 +- **양호 (85-95%)**: 대부분 처리 성공, 일부 메타데이터 누락 +- **개선 필요 (<85%)**: 상당한 처리 실패 또는 메타데이터 문제 diff --git a/backend/app/agents/embedding_agent/prompt.py b/backend/app/agents/embedding_agent/prompt.py index 18338aa..0f314e7 100644 --- a/backend/app/agents/embedding_agent/prompt.py +++ b/backend/app/agents/embedding_agent/prompt.py @@ -3,6 +3,68 @@ Prompt templates and instructions for document embedding agent """ +# 섹션 분해용 SYSTEM 프롬프트 +SECTION_SPLIT_SYSTEM_PROMPT = """ +You are an academic paper parser. + +Your task is to segment the paper into logical sections. + +CRITICAL RULES: +- Output MUST be valid JSON only. +- Do NOT include any explanation, comments, or extra text. +- Do NOT wrap with markdown. +- The response must start with '[' and end with ']'. +- Each item must strictly follow this schema: + { + "section_title": string, + "text": string + } + +If you violate any rule, the output is considered invalid. +""" + +# 섹션 분해용 USER 프롬프트 +# SECTION_SPLIT_USER_PROMPT = """ +# Below is the full text of an academic paper. +# +# Split it into logical sections. +# +# Rules: +# - Output JSON only (no markdown, no explanation) +# - Each item must have: +# - section_title +# - text +# - Each section_title must appear at most once +# - Use normalized section titles only: +# Introduction, Related Work, Methods, Results, Discussion, Conclusion, Other +# - Do NOT invent or paraphrase content +# - Preserve original text verbatim +# - If unsure about a boundary, merge rather than split +# +# Paper text: +# {text} +# """ + +# 섹션 분해용 USER 프롬프트 - 제목만 뽑 +SECTION_SPLIT_USER_PROMPT = """ +Below is the full text of an academic paper. + +Identify the logical section titles in order. + +Rules: +- Output JSON only +- Each item must have: + - section_title +- Do NOT include section text +- Use normalized titles: + Introduction, Related Work, Methods, Results, Discussion, Conclusion, Other + +Paper text: +{text} +""" + + + # Summary generation prompt (Korean) SUMMARY_PROMPT = """다음 문서의 핵심 요약을 300-500단어의 한국어로 작성해주세요. 주요 내용, 핵심 결과, 중요한 발견사항을 포함하세요. diff --git a/backend/app/agents/general_chat/general_chat_content.md b/backend/app/agents/general_chat/general_chat_content.md new file mode 100644 index 0000000..d0e6de0 --- /dev/null +++ b/backend/app/agents/general_chat/general_chat_content.md @@ -0,0 +1,25 @@ +# 일반 대화 에이전트 + +## 개요 + +일반 대화 에이전트는 문서 컨텍스트를 선택적으로 포함하여 표준 LLM 기반 대화를 제공합니다. + +## 작동 방식 + +1. **대화 초기화**: 시스템 프롬프트와 LLM 서비스를 설정합니다. +2. **사용자 입력 처리**: 사용자 메시지와 선택적 문서 컨텍스트를 처리합니다. +3. **응답 생성**: LLM을 사용하여 대화 응답을 생성합니다. + +## 사용 기술 및 도구 + +- **LLM 서비스**: 대화 응답 생성. +- **로깅(Logging)**: 대화 추적 및 디버깅. + +## 주요 구성 요소 + +- **스키마(Schemas)**: 요청 및 응답 구조 정의. +- **프롬프트(Prompts)**: 대화를 위한 시스템 프롬프트 포함. + +## 주요 파일 + +- `agent.py`: 일반 대화 에이전트의 주요 구현 파일. diff --git a/backend/app/agents/general_chat/prompt.py b/backend/app/agents/general_chat/prompt.py index 6478192..9a4af40 100644 --- a/backend/app/agents/general_chat/prompt.py +++ b/backend/app/agents/general_chat/prompt.py @@ -4,19 +4,21 @@ """ # Default system prompt for general chat with optional RAG capability -SYSTEM_PROMPT = """You are a helpful and intelligent AI assistant. - -Your role: -- Engage in natural, helpful conversations on any topic -- When documents are provided, use them as reference to support your answers -- Always respond in Korean (한국어로 답변하세요) - -Guidelines: -- Be conversational and friendly for general questions -- Use document context when available and relevant -- Be clear, concise, and accurate -- Admit when you don't know something -- Maintain natural conversation flow""" +SYSTEM_PROMPT = """당신은 도움이 되고 지능형 AI 어시스턴트입니다. + +당신의 역할: +- 모든 주제에 대해 자연스럽고 도움이 되는 대화를 나누기 +- 문서가 제공될 때 이를 참고하여 답변 지원하기 +- 모든 답변을 반드시 한국어로 하기 (매우 중요) + +지침: +- 일반 질문에 대해 친근하고 대화체로 답변하기 +- 문서 컨텍스트가 있을 때 관련성 있게 사용하기 +- 명확하고 간결하며 정확한 답변하기 +- 모르는 것은 인정하기 +- 자연스러운 대화 흐름 유지하기 + +⭐ 중요: 모든 답변은 반드시 한국어로 해야 합니다. 영어로 답변하면 안 됩니다. 영어는 고유명사만 사용하세요.""" # Document context template DOCUMENT_CONTEXT_TEMPLATE = """Based on the following context documents: diff --git a/backend/app/agents/report_agent/__init__.py b/backend/app/agents/report_agent/__init__.py new file mode 100644 index 0000000..13eedde --- /dev/null +++ b/backend/app/agents/report_agent/__init__.py @@ -0,0 +1,31 @@ +"""Report Agent Package""" + +from .agent import ReportAgent +from .schemas import ( + ReportAgentRequest, + ReportAgentResponse, + ResearchReport, + ResearchValidation, + DocumentReference, + ResearchTopicData, +) +from .document_processor import DocumentProcessor +from .data_normalizer import DataNormalizer +from .llm_integration import LLMIntegration +from .report_builder import ReportBuilder +from .visualizer import Visualizer + +__all__ = [ + "ReportAgent", + "ReportAgentRequest", + "ReportAgentResponse", + "ResearchReport", + "ResearchValidation", + "DocumentReference", + "ResearchTopicData", + "DocumentProcessor", + "DataNormalizer", + "LLMIntegration", + "ReportBuilder", + "Visualizer", +] diff --git a/backend/app/agents/report_agent/agent.py b/backend/app/agents/report_agent/agent.py new file mode 100644 index 0000000..f06fba7 --- /dev/null +++ b/backend/app/agents/report_agent/agent.py @@ -0,0 +1,685 @@ +""" +Report Agent Implementation +Generates comprehensive research feasibility reports with Intent-based execution +""" + +import logging +import re +import json +from enum import Enum +from typing import Optional, List, Dict, Any +from datetime import datetime +from zoneinfo import ZoneInfo + +from app.agents.base_agent import BaseAgent +from app.agents.report_agent.schemas import ( + ReportAgentRequest, + ReportAgentResponse, + ResearchReport, + ResearchValidation, + ReportSection, +) +from app.agents.report_agent.prompt import ( + SYSTEM_PROMPT, + REPORT_GENERATION_PROMPT, + EVIDENCE_SYNTHESIS_PROMPT, +) +from app.agents.report_agent.llm_integration import LLMIntegration +from app.agents.report_agent.data_normalizer import DataNormalizer +from app.agents.report_agent.report_builder import ReportBuilder +from app.agents.report_agent.visualizer import Visualizer +from app.services.llm_service import get_llm_service +from app.services.embedding_service import get_embedding_service + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Intent Classification +# ============================================================================ + + +class ExecutionIntent(Enum): + """사용자 의도 분류""" + FULL_REPORT = "full_report" # 전체 보고서 생성 + DATA_PROCESSING = "data_processing" # 데이터 정리/변환 + VISUALIZATION = "visualization" # 시각화만 + QUICK_ANALYSIS = "quick_analysis" # 빠른 LLM 분석만 + + +# ============================================================================ +# Report Agent +# ============================================================================ + + +class ReportAgent(BaseAgent): + """ + Report Agent + Generates comprehensive research feasibility reports with Intent-based execution + Automatically selects execution strategy based on input parameters + """ + + def __init__(self): + """Initialize report agent""" + super().__init__() + self.agent_type = "report_agent" + self.system_prompt = SYSTEM_PROMPT + self.llm_service = get_llm_service() + self.llm_integration = LLMIntegration() + self.embedding_service = get_embedding_service() + + async def execute(self, request: ReportAgentRequest) -> ReportAgentResponse: + """ + Execute comprehensive report generation with all features + + 항상 완전한 보고서를 생성합니다: + - 8개 섹션 포함 완전한 보고서 + - 자동 시각화 포함 + - 근거 분석 및 타당성 평가 + - Markdown & PDF 생성 + + Args: + request: ReportAgentRequest with research topic and optional parameters + + Returns: + ReportAgentResponse with complete report including visualizations + """ + try: + # 항상 완전한 보고서 생성 (시각화 자동 포함) + logger.info(f"[ReportAgent] Generating comprehensive report with all features") + + # 시각화와 네트워크 그래프를 자동으로 포함 + request.include_visualizations = True + request.include_network_graph = True + + return await self._execute_full_report(request) + + except Exception as e: + logger.error(f"[ReportAgent] Error: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Intent Classification + # ============================================================================ + + async def _classify_intent(self, request: ReportAgentRequest) -> ExecutionIntent: + """ + Classify user intent based on request content and structure + + Classification order: + 1. Keyword detection in research_topic + 2. Structure detection (presence of optional parameters) + 3. Default to QUICK_ANALYSIS + + Args: + request: ReportAgentRequest + + Returns: + ExecutionIntent enum value + """ + # Step 1: Keyword-based detection + intent = self._keyword_based_intent(request.research_topic) + if intent: + return intent + + # Step 2: Structure-based detection + if request.research_data and request.research_data.related_documents: + return ExecutionIntent.FULL_REPORT + elif hasattr(request, "data_to_normalize") and request.data_to_normalize: + return ExecutionIntent.DATA_PROCESSING + + # Default: Quick analysis + return ExecutionIntent.QUICK_ANALYSIS + + def _keyword_based_intent(self, text: str) -> Optional[ExecutionIntent]: + """ + Detect intent using keyword matching in user input + + Args: + text: User input text + + Returns: + ExecutionIntent or None + """ + text_lower = text.lower() + + keywords = { + ExecutionIntent.DATA_PROCESSING: [ + "정리", "변환", "정규화", "단위", "표", "데이터", "통합", "정렬", + ], + ExecutionIntent.VISUALIZATION: [ + "그래프", "차트", "시각화", "네트워크", "도식", "그림", "플롯", "대시보드", + ], + ExecutionIntent.QUICK_ANALYSIS: [ + "분석해줘", "평가해줘", "어때", "가능해", "어떻게", "의견", "생각", "판단", + ], + ExecutionIntent.FULL_REPORT: [ + "보고서", "타당성", "평가", "종합", "전체", "완전한", "상세", + ], + } + + for intent, kws in keywords.items(): + if any(kw in text_lower for kw in kws): + logger.info(f"[ReportAgent] Intent detected by keyword: {intent.value}") + return intent + + return None + + # ============================================================================ + # Intent Execution: FULL_REPORT + # ============================================================================ + + async def _execute_full_report(self, request: ReportAgentRequest) -> ReportAgentResponse: + """ + 🔵 Full Report Generation + + Execution pipeline: + 1. Prepare document context + 2. Generate main report via LLM + 3. Extract validation (feasibility score) + 4. Generate sections + 5. Create evidence summary + 6. Extract recommendations & limitations + 7. Compile final report + 8. Build Markdown & PDF formats + 9. Generate visualizations + """ + try: + logger.info(f"[ReportAgent] Executing FULL_REPORT intent") + + # Step 1: Prepare document context + documents_text = await self._prepare_documents_context( + request.research_data.related_documents + ) + logger.info(f"[ReportAgent] Prepared context for {len(request.research_data.related_documents)} documents") + + # Step 2: Generate main report via LLM + report_content = await self._generate_main_report( + request.research_topic, + request.research_data.description, + request.research_data.analysis_goal, + documents_text, + request.temperature, + request.max_tokens + ) + logger.info(f"[ReportAgent] Main report content generated") + + # Step 3: Parse validation + validation = await self._extract_validation(report_content) + logger.info(f"[ReportAgent] Feasibility score: {validation.feasibility_score:.1f}/100") + + # Step 4: Generate sections + sections = await self._generate_sections(report_content) + logger.info(f"[ReportAgent] Generated {len(sections)} report sections") + + # Step 5: Generate evidence summary + evidence_summary = await self._generate_evidence_summary( + documents_text, + request.temperature, + request.max_tokens + ) + logger.info(f"[ReportAgent] Evidence summary generated") + + # Step 6: Extract recommendations + recommendations = await self.llm_integration.extract_recommendations(report_content) + logger.info(f"[ReportAgent] Extracted {len(recommendations)} recommendations") + + # Step 7: Extract limitations + limitations = await self.llm_integration.extract_limitations(report_content) + logger.info(f"[ReportAgent] Extracted {len(limitations)} limitations") + + # Step 8: Compile final report + final_report = ResearchReport( + title=f"연구주제 타당성 평가 보고서: {request.research_topic}", + research_topic=request.research_topic, + validation=validation, + sections=sections, + evidence_summary=evidence_summary, + recommendations=recommendations, + limitations=limitations, + related_papers=request.research_data.related_documents + ) + logger.info(f"[ReportAgent] Final report compiled") + + # Step 9: Generate report formats + markdown = await ReportBuilder.build_markdown(final_report) + pdf = await ReportBuilder.build_pdf(final_report) + logger.info(f"[ReportAgent] Report formats generated (Markdown + PDF)") + + # Step 10: Parse visualization data from report content + viz_data = self._extract_visualization_data(report_content) + logger.info(f"[ReportAgent] Extracted visualization data: {bool(viz_data)}") + + # Step 11: Generate visualizations using extracted data + visualizations = {} + try: + if viz_data: + visualizations = await Visualizer.create_all_visualizations(final_report, viz_data) + logger.info(f"[ReportAgent] Visualizations generated: {len(visualizations)} charts") + else: + logger.warning(f"[ReportAgent] No visualization data found, using defaults") + visualizations = await Visualizer.create_all_visualizations(final_report, None) + except Exception as viz_error: + logger.warning(f"[ReportAgent] Visualization generation failed: {str(viz_error)}") + visualizations = {} + + # Markdown에 시각화 섹션 추가 + if visualizations: + viz_section = "\n\n## 📊 시각화\n\n" + for viz_name, viz_html in visualizations.items(): + viz_section += f"### {viz_name}\n{viz_html}\n\n" + markdown += viz_section + + return ReportAgentResponse( + report=final_report, + visualizations=visualizations, # 시각화 데이터 포함 + metadata={ + "generated_at": datetime.now(ZoneInfo("Asia/Seoul")).isoformat(), + "report_type": request.report_type, + "documents_count": len(request.research_data.related_documents), + "intent": "full_report", + "visualizations": list(visualizations.keys()), + "visualization_count": len(visualizations), + "markdown_length": len(markdown), + "pdf_bytes": len(pdf), + }, + tokens_used=0, # TODO: Sum actual token usage + report_format="json" + ) + + except Exception as e: + logger.error(f"[ReportAgent] Error in FULL_REPORT: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Intent Execution: DATA_PROCESSING + # ============================================================================ + + async def _execute_data_processing(self, request: ReportAgentRequest) -> ReportAgentResponse: + """ + 🟢 Data Processing + Executes data normalization and unit conversion + """ + try: + logger.info(f"[ReportAgent] Executing DATA_PROCESSING intent") + + if not request.data_to_normalize: + raise ValueError("data_to_normalize required for DATA_PROCESSING") + + result = {"success": False, "data": None} + + # TODO: Implement actual data processing + # For now, return placeholder + result["success"] = True + result["data"] = request.data_to_normalize + + logger.info(f"[ReportAgent] Data processing completed") + + return ReportAgentResponse( + report=ResearchReport( + title="데이터 정리 결과", + research_topic=request.research_topic, + validation=ResearchValidation( + is_feasible=True, + feasibility_score=0, + reasoning="데이터 정리 완료" + ), + sections=[], + evidence_summary="", + recommendations=[], + limitations=[], + related_papers=[] + ), + metadata={ + "intent": "data_processing", + "result": result, + }, + tokens_used=0, + report_format="json" + ) + + except Exception as e: + logger.error(f"[ReportAgent] Error in DATA_PROCESSING: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Intent Execution: VISUALIZATION + # ============================================================================ + + async def _execute_visualization(self, request: ReportAgentRequest) -> ReportAgentResponse: + """ + 🟡 Visualization + Creates visualizations based on requested type + """ + try: + logger.info(f"[ReportAgent] Executing VISUALIZATION intent") + + # Create a minimal report from request data + final_report = ResearchReport( + title=f"시각화: {request.research_topic}", + research_topic=request.research_topic, + validation=ResearchValidation( + is_feasible=True, + feasibility_score=75, + reasoning="시각화 생성" + ), + sections=[], + evidence_summary="", + recommendations=[], + limitations=[], + related_papers=request.research_data.related_documents or [] + ) + + # Generate visualizations + visualizations = await Visualizer.create_all_visualizations(final_report) + logger.info(f"[ReportAgent] Visualizations created successfully") + + return ReportAgentResponse( + report=final_report, + metadata={ + "intent": "visualization", + "visualizations": visualizations, + }, + tokens_used=0, + report_format="html" + ) + + except Exception as e: + logger.error(f"[ReportAgent] Error in VISUALIZATION: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Intent Execution: QUICK_ANALYSIS + # ============================================================================ + + async def _execute_quick_analysis(self, request: ReportAgentRequest) -> ReportAgentResponse: + """ + ⚪ Quick Analysis + Simple LLM call without full report generation + """ + try: + logger.info(f"[ReportAgent] Executing QUICK_ANALYSIS intent") + + # Simple LLM call + analysis = await self.llm_integration.call_llm( + prompt=request.research_topic, + system_prompt=self.system_prompt, + temperature=request.temperature, + max_tokens=request.max_tokens + ) + + logger.info(f"[ReportAgent] Quick analysis completed: {len(analysis)} chars") + + return ReportAgentResponse( + report=ResearchReport( + title="빠른 분석", + research_topic=request.research_topic, + validation=ResearchValidation( + is_feasible=True, + feasibility_score=0, + reasoning=analysis[:200] if analysis else "" + ), + sections=[ + ReportSection( + title="분석 결과", + content=analysis, + citations=[] + ) + ] if analysis else [], + evidence_summary="", + recommendations=[], + limitations=[], + related_papers=[] + ), + metadata={ + "intent": "quick_analysis", + "analysis_length": len(analysis), + }, + tokens_used=0, + report_format="json" + ) + + except Exception as e: + logger.error(f"[ReportAgent] Error in QUICK_ANALYSIS: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Helper Methods + # ============================================================================ + + async def _prepare_documents_context(self, documents: List) -> str: + """ + Prepare formatted document context for LLM with semantic search + + For each document, retrieves the most relevant chunks from ChromaDB + to provide rich, meaningful context rather than just metadata + """ + try: + context_parts = [] + + # Extract document IDs and research topic/goal from the request + document_ids = [doc.id for doc in documents] + + logger.info(f"[ReportAgent] Preparing context for {len(documents)} documents") + + # For report agent, we want broader context, so use the document titles as search queries + for idx, doc in enumerate(documents, 1): + try: + # Semantic search for relevant chunks from this document + # Use document title as initial query for broader context + embed_result = await self.embedding_service.embed(doc.title, use_cache=True) + query_embedding = embed_result["embedding"] + + # Get ChromaDB collection + collection = self.embedding_service.get_collection() + + if not collection: + logger.warning(f"[ReportAgent] ChromaDB unavailable for document {idx}, using metadata") + doc_header = f"[{doc.title}]\n저자: {doc.authors or 'Unknown'}\n연도: {doc.year or 'Unknown'}\n\n" + context_parts.append(doc_header) + continue + + # 중요: 특정 문서의 청크만 검색하도록 where 필터 추가 + where_filter = { + "document_id": {"$in": [doc.id, str(doc.id)]} + } + + logger.info(f"[ReportAgent] Searching ChromaDB for document ID: {doc.id}") + results = collection.query( + query_embeddings=[query_embedding], + n_results=10, # Get up to 10 chunks per document for report context + where=where_filter, + include=["documents", "metadatas", "distances"] + ) + + if results and results["ids"] and len(results["ids"]) > 0: + logger.info(f"[ReportAgent] ChromaDB returned {len(results['ids'][0])} chunks for document {idx}") + chunks_text = [] + document_title = doc.title # 논문 제목 우선 + document_filename = None + for i, result_id in enumerate(results["ids"][0]): + metadata = results["metadatas"][0][i] + chunk_text = results["documents"][0][i] + chunk_doc_id = metadata.get("document_id") + + logger.debug(f"[ReportAgent] Chunk {i}: doc_id={chunk_doc_id}, expected={doc.id}, match={chunk_doc_id == doc.id or str(chunk_doc_id) == str(doc.id)}") + + # Check if this chunk belongs to the current document + if chunk_doc_id == doc.id or str(chunk_doc_id) == str(doc.id): + chunks_text.append(chunk_text) + if not document_filename: + document_filename = metadata.get("filename") + + if chunks_text: + doc_content = "\n\n".join(chunks_text[:5]) # Use top 5 chunks + # 제목 우선, 파일명은 부가 정보로 + title_display = document_title or document_filename or "Unknown" + doc_header = f"[{title_display}]\n" + if document_filename and document_title and document_filename != document_title: + doc_header += f"파일명: {document_filename}\n" + doc_header += f"저자: {doc.authors or 'Unknown'}\n연도: {doc.year or 'Unknown'}\n\n" + context_parts.append(f"{doc_header}{doc_content}") + logger.info(f"[ReportAgent] Successfully retrieved {len(chunks_text)} chunks for document {idx} (ID: {doc.id})") + else: + # Fallback to metadata if no matching chunks found + doc_header = f"[{doc.title}]\n저자: {doc.authors or 'Unknown'}\n연도: {doc.year or 'Unknown'}\n\n" + context_parts.append(doc_header) + logger.warning(f"[ReportAgent] No matching chunks found for document {idx} (ID: {doc.id}), using metadata only") + else: + # Fallback to metadata if ChromaDB query returns nothing + doc_header = f"[{doc.title}]\n저자: {doc.authors or 'Unknown'}\n연도: {doc.year or 'Unknown'}\n\n" + context_parts.append(doc_header) + logger.warning(f"[ReportAgent] ChromaDB query returned no results for document {idx}") + + except Exception as chunk_error: + logger.warning(f"[ReportAgent] Error retrieving chunks for document {idx}: {str(chunk_error)}") + # Fallback to metadata + doc_header = f"[{doc.title}]\n저자: {doc.authors or 'Unknown'}\n연도: {doc.year or 'Unknown'}\n\n" + context_parts.append(doc_header) + + return "\n\n---\n\n".join(context_parts) + + except Exception as e: + logger.error(f"[ReportAgent] Error preparing documents: {str(e)}") + # Fallback: return simple metadata + fallback_parts = [] + for idx, doc in enumerate(documents, 1): + doc_text = f"""[{idx}] {doc.title} +Authors: {doc.authors or 'Unknown'} +Year: {doc.year or 'Unknown'}""" + fallback_parts.append(doc_text) + return "\n\n".join(fallback_parts) + + async def _generate_main_report( + self, + topic: str, + description: Optional[str], + analysis_goal: Optional[str], + documents: str, + temperature: float, + max_tokens: int + ) -> str: + """Generate main report content via LLM""" + try: + prompt = REPORT_GENERATION_PROMPT.format( + research_topic=topic, + research_description=description or "Not specified", + analysis_goal=analysis_goal or "Comprehensive analysis", + documents=documents + ) + + response = await self.llm_integration.call_llm( + prompt=prompt, + system_prompt=self.system_prompt, + temperature=temperature, + max_tokens=max_tokens + ) + + return response + + except Exception as e: + logger.error(f"[ReportAgent] Error generating main report: {str(e)}") + raise + + async def _extract_validation(self, report_content: str) -> ResearchValidation: + """Extract feasibility validation from LLM response""" + try: + return await self.llm_integration.parse_validation(report_content) + + except Exception as e: + logger.error(f"[ReportAgent] Error extracting validation: {str(e)}") + # Return default validation + return ResearchValidation( + is_feasible=True, + feasibility_score=50.0, + reasoning="분석 결과를 확인하세요" + ) + + async def _generate_sections(self, report_content: str) -> List[ReportSection]: + """Parse report content into structured sections""" + try: + sections = [] + + # Parse sections from markdown-style headers + section_pattern = r"##\s+(.+?)\n(.*?)(?=##|$)" + matches = re.findall(section_pattern, report_content, re.DOTALL) + + for title, content in matches[:5]: # Limit to 5 sections + if content.strip(): + sections.append( + ReportSection( + title=title.strip(), + content=content.strip()[:500], # Limit content to 500 chars + citations=[] + ) + ) + + # If no sections found, create default section + if not sections: + sections.append( + ReportSection( + title="분석 내용", + content=report_content[:500], + citations=[] + ) + ) + + return sections + + except Exception as e: + logger.error(f"[ReportAgent] Error generating sections: {str(e)}") + return [] + + async def _generate_evidence_summary( + self, + documents: str, + temperature: float, + max_tokens: int + ) -> str: + """Generate synthesis of evidence from documents""" + try: + prompt = EVIDENCE_SYNTHESIS_PROMPT.format(documents=documents) + + response = await self.llm_integration.call_llm( + prompt=prompt, + system_prompt=self.system_prompt, + temperature=temperature, + max_tokens=max_tokens + ) + + return response + + except Exception as e: + logger.error(f"[ReportAgent] Error generating evidence summary: {str(e)}") + return "증거 종합 분석이 진행 중입니다." + + def _extract_visualization_data(self, report_content: str) -> Optional[Dict[str, Any]]: + """ + Extract visualization data from LLM report content + Looks for JSON block with visualization_data + """ + try: + # Find JSON block in report content + json_pattern = r'```json\s*(\{.*?"visualization_data".*?\})\s*```' + match = re.search(json_pattern, report_content, re.DOTALL) + + if match: + json_str = match.group(1) + data = json.loads(json_str) + + if "visualization_data" in data: + logger.info(f"[ReportAgent] Successfully extracted visualization data") + return data["visualization_data"] + else: + logger.warning(f"[ReportAgent] JSON found but no visualization_data key") + return None + else: + logger.warning(f"[ReportAgent] No JSON visualization block found in report") + return None + + except json.JSONDecodeError as e: + logger.error(f"[ReportAgent] JSON parsing error: {str(e)}") + return None + except Exception as e: + logger.error(f"[ReportAgent] Error extracting visualization data: {str(e)}") + return None diff --git a/backend/app/agents/report_agent/data_normalizer.py b/backend/app/agents/report_agent/data_normalizer.py new file mode 100644 index 0000000..4798cdc --- /dev/null +++ b/backend/app/agents/report_agent/data_normalizer.py @@ -0,0 +1,193 @@ +""" +Data Normalizer +Data normalization and unit conversion utilities +""" + +import logging +from typing import Dict, Any, List + +import pandas as pd +import pint + +logger = logging.getLogger(__name__) + + +class DataNormalizer: + """데이터 정규화 및 단위 변환 도구""" + + # pint 단위 레지스트리 + ureg = pint.UnitRegistry() + ureg.define("dalton = atomic_mass_unit") + + @staticmethod + async def normalize_units( + data: Dict[str, Any], + from_unit: str, + to_unit: str + ) -> Dict[str, Any]: + """ + 데이터 단위 변환 + + Args: + data: 변환할 데이터 {"value": 100, "unit": "kg"} + from_unit: 원본 단위 (예: "kg") + to_unit: 목표 단위 (예: "g") + + Returns: + 변환된 데이터 {"value": 100000, "unit": "g"} + """ + try: + if "value" not in data: + raise ValueError("Data must contain 'value' key") + + value = data["value"] + original_unit = from_unit or data.get("unit", "") + + if not original_unit: + raise ValueError("Unit must be specified") + + # pint로 변환 + quantity = value * DataNormalizer.ureg(original_unit) + converted = quantity.to(to_unit) + + result = { + "original_value": value, + "original_unit": original_unit, + "converted_value": float(converted.magnitude), + "converted_unit": str(converted.units), + "conversion_successful": True + } + + logger.info(f"[DataNormalizer] Converted: {value}{original_unit} → {converted}") + return result + + except pint.errors.DimensionalityError as e: + logger.warning(f"[DataNormalizer] Unit mismatch: {str(e)}") + return { + "original_value": value, + "original_unit": original_unit, + "error": f"Cannot convert {original_unit} to {to_unit}", + "conversion_successful": False + } + + except Exception as e: + logger.error(f"[DataNormalizer] Error normalizing units: {str(e)}") + raise + + @staticmethod + async def standardize_table(df: pd.DataFrame) -> pd.DataFrame: + """ + 표 데이터 정규화 + + Args: + df: Pandas DataFrame + + Returns: + 정규화된 DataFrame + """ + try: + # 1. 컬럼명 정규화 (공백 제거, 소문자) + df.columns = [col.strip().lower().replace(" ", "_") for col in df.columns] + + # 2. 결측값 처리 + df = df.dropna(how='all') # 완전 빈 행 제거 + + # 3. 데이터 타입 추론 + for col in df.columns: + try: + df[col] = pd.to_numeric(df[col], errors='ignore') + except: + pass + + # 4. 중복 제거 + df = df.drop_duplicates() + + logger.info(f"[DataNormalizer] Standardized table: {df.shape[0]} rows, {df.shape[1]} cols") + return df + + except Exception as e: + logger.error(f"[DataNormalizer] Error standardizing table: {str(e)}") + raise + + @staticmethod + async def handle_unit_mismatch(data: Dict[str, Any]) -> Dict[str, Any]: + """ + 변환 불가능한 단위 처리 + + Args: + data: 변환할 데이터 + + Returns: + 처리 결과 + """ + try: + value = data.get("value") + from_unit = data.get("from_unit") + to_unit = data.get("to_unit") + + logger.warning( + f"[DataNormalizer] Unit mismatch: {value}{from_unit} → {to_unit}. " + f"Attempting alternative conversion..." + ) + + # 일반적인 단위 변환 규칙 + conversion_rules = { + ("celsius", "kelvin"): lambda v: v + 273.15, + ("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9, + ("kg", "lbs"): lambda v: v * 2.20462, + ("m", "ft"): lambda v: v * 3.28084, + ("l", "gallon"): lambda v: v * 0.264172, + } + + key = (from_unit.lower(), to_unit.lower()) + if key in conversion_rules: + converted_value = conversion_rules[key](value) + return { + "success": True, + "original_value": value, + "converted_value": converted_value, + "from_unit": from_unit, + "to_unit": to_unit, + "method": "custom_rule" + } + + return { + "success": False, + "error": f"No conversion rule for {from_unit} → {to_unit}", + "original_value": value + } + + except Exception as e: + logger.error(f"[DataNormalizer] Error handling unit mismatch: {str(e)}") + raise + + @staticmethod + async def merge_tables(tables: List[pd.DataFrame]) -> pd.DataFrame: + """ + 여러 표를 병합 + + Args: + tables: DataFrame 리스트 + + Returns: + 병합된 DataFrame + """ + try: + if not tables: + return pd.DataFrame() + + merged = tables[0] + for table in tables[1:]: + # 공통 컬럼으로 merge (또는 concat) + common_cols = set(merged.columns) & set(table.columns) + if common_cols: + merged = pd.merge(merged, table, on=list(common_cols), how="outer") + else: + merged = pd.concat([merged, table], axis=0, ignore_index=True) + + logger.info(f"[DataNormalizer] Merged {len(tables)} tables") + return merged + + except Exception as e: + logger.error(f"[DataNormalizer] Error merging tables: {str(e)}") + raise diff --git a/backend/app/agents/report_agent/document_processor.py b/backend/app/agents/report_agent/document_processor.py new file mode 100644 index 0000000..0dd007f --- /dev/null +++ b/backend/app/agents/report_agent/document_processor.py @@ -0,0 +1,191 @@ +""" +Document Processor +PDF and text document processing utilities +""" + +import logging +from typing import List, Dict, Any +from pathlib import Path + +import pandas as pd + +try: + import fitz # PyMuPDF +except ImportError: + fitz = None + +logger = logging.getLogger(__name__) + + +class DocumentProcessor: + """PDF 및 텍스트 문서 처리 도구""" + + @staticmethod + async def extract_text(file_path: str) -> str: + """ + PDF 또는 텍스트 파일에서 텍스트 추출 + + Args: + file_path: 파일 경로 + + Returns: + 추출된 텍스트 + """ + try: + path = Path(file_path) + + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + if path.suffix.lower() == ".pdf": + return await DocumentProcessor._extract_pdf_text(file_path) + elif path.suffix.lower() in [".txt", ".md"]: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + else: + raise ValueError(f"Unsupported file format: {path.suffix}") + + except Exception as e: + logger.error(f"[DocumentProcessor] Error extracting text: {str(e)}") + raise + + @staticmethod + async def _extract_pdf_text(file_path: str) -> str: + """PyMuPDF로 PDF 텍스트 추출""" + if fitz is None: + raise ImportError("PyMuPDF not installed. Install with: pip install PyMuPDF") + + try: + pdf_document = fitz.open(file_path) + text_parts = [] + + for page_num in range(len(pdf_document)): + page = pdf_document[page_num] + text = page.get_text() + if text.strip(): + text_parts.append(f"--- Page {page_num + 1} ---\n{text}") + + pdf_document.close() + return "\n\n".join(text_parts) + + except Exception as e: + logger.error(f"[DocumentProcessor] PDF extraction error: {str(e)}") + raise + + @staticmethod + async def extract_tables(file_path: str) -> List[pd.DataFrame]: + """ + PDF 또는 문서에서 표 추출 + + Args: + file_path: 파일 경로 + + Returns: + DataFrame 리스트 + """ + try: + path = Path(file_path) + + if path.suffix.lower() == ".pdf": + return await DocumentProcessor._extract_pdf_tables(file_path) + elif path.suffix.lower() in [".xlsx", ".xls"]: + return await DocumentProcessor._extract_excel_tables(file_path) + else: + logger.warning(f"Table extraction not supported for {path.suffix}") + return [] + + except Exception as e: + logger.error(f"[DocumentProcessor] Error extracting tables: {str(e)}") + raise + + @staticmethod + async def _extract_pdf_tables(file_path: str) -> List[pd.DataFrame]: + """PDF에서 표 추출""" + try: + # 간단한 구현: tabula-py 또는 pdfplumber 사용 권장 + # 여기서는 기본 구조만 제공 + logger.info(f"[DocumentProcessor] Extracting tables from PDF: {file_path}") + + # TODO: tabula-py 통합 + # import tabula + # tables = tabula.read_pdf(file_path, pages='all') + # return tables + + return [] + + except Exception as e: + logger.error(f"[DocumentProcessor] PDF table extraction error: {str(e)}") + raise + + @staticmethod + async def _extract_excel_tables(file_path: str) -> List[pd.DataFrame]: + """Excel 파일에서 표 추출""" + try: + excel_file = pd.ExcelFile(file_path) + tables = [] + + for sheet_name in excel_file.sheet_names: + df = pd.read_excel(file_path, sheet_name=sheet_name) + tables.append(df) + logger.info(f"[DocumentProcessor] Extracted table from sheet: {sheet_name}") + + return tables + + except Exception as e: + logger.error(f"[DocumentProcessor] Excel extraction error: {str(e)}") + raise + + @staticmethod + async def extract_metadata(file_path: str) -> Dict[str, Any]: + """ + 파일 메타데이터 추출 (저자, 연도, 제목 등) + + Args: + file_path: 파일 경로 + + Returns: + 메타데이터 딕셔너리 + """ + try: + path = Path(file_path) + metadata = { + "filename": path.name, + "file_type": path.suffix, + "file_size": path.stat().st_size, + } + + if path.suffix.lower() == ".pdf": + return await DocumentProcessor._extract_pdf_metadata(file_path, metadata) + else: + logger.info(f"[DocumentProcessor] Limited metadata for {path.suffix}") + return metadata + + except Exception as e: + logger.error(f"[DocumentProcessor] Error extracting metadata: {str(e)}") + return {} + + @staticmethod + async def _extract_pdf_metadata(file_path: str, base_metadata: Dict) -> Dict: + """PDF 메타데이터 추출""" + if fitz is None: + return base_metadata + + try: + pdf_document = fitz.open(file_path) + pdf_metadata = pdf_document.metadata + + if pdf_metadata: + base_metadata.update({ + "title": pdf_metadata.get("title", "Unknown"), + "author": pdf_metadata.get("author", "Unknown"), + "subject": pdf_metadata.get("subject", "Unknown"), + "creator": pdf_metadata.get("creator", "Unknown"), + "pages": len(pdf_document), + }) + + pdf_document.close() + return base_metadata + + except Exception as e: + logger.error(f"[DocumentProcessor] PDF metadata extraction error: {str(e)}") + return base_metadata diff --git a/backend/app/agents/report_agent/llm_integration.py b/backend/app/agents/report_agent/llm_integration.py new file mode 100644 index 0000000..d6e6a5e --- /dev/null +++ b/backend/app/agents/report_agent/llm_integration.py @@ -0,0 +1,190 @@ +""" +LLM Integration +LLM calling and response processing utilities +""" + +import logging +import re +from typing import Optional, List + +from app.services.llm_service import get_llm_service +from app.agents.report_agent.schemas import ResearchValidation + +logger = logging.getLogger(__name__) + + +class LLMIntegration: + """LLM 호출 및 응답 처리 도구""" + + def __init__(self): + self.llm_service = get_llm_service() + + async def call_llm( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096 + ) -> str: + """ + LLM 호출 + + Args: + prompt: 사용자 프롬프트 + system_prompt: 시스템 프롬프트 + temperature: 온도 + max_tokens: 최대 토큰 + + Returns: + LLM 응답 + """ + try: + logger.info(f"[LLMIntegration] Calling LLM with prompt: {prompt[:50]}...") + + response = await self.llm_service.generate( + messages=[{"role": "user", "content": prompt}], + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens + ) + + return response["content"] + + except Exception as e: + logger.error(f"[LLMIntegration] Error calling LLM: {str(e)}") + raise + + @staticmethod + async def parse_validation(response: str) -> ResearchValidation: + """ + LLM 응답에서 타당성 정보 파싱 + + Args: + response: LLM 응답 + + Returns: + ResearchValidation 객체 + """ + try: + logger.info(f"[LLMIntegration] Parsing validation from response") + + # 점수 추출 (0-100) + score_match = re.search(r"(?:점수|score)[:\s]*(\d+(?:\.\d+)?)", response, re.IGNORECASE) + score = float(score_match.group(1)) if score_match else 75.0 + + # 타당성 판단 + is_feasible = score >= 50 + + # 추론 추출 (처음 500자) + reasoning = response[:500] if response else "분석 결과를 확인하세요" + + return ResearchValidation( + is_feasible=is_feasible, + feasibility_score=min(100, max(0, score)), + reasoning=reasoning + ) + + except Exception as e: + logger.error(f"[LLMIntegration] Error parsing validation: {str(e)}") + # 기본값 반환 + return ResearchValidation( + is_feasible=True, + feasibility_score=50.0, + reasoning="타당성 분석 진행 중" + ) + + @staticmethod + async def extract_recommendations(response: str) -> List[str]: + """ + LLM 응답에서 권장사항 추출 + + Args: + response: LLM 응답 + + Returns: + 권장사항 리스트 + """ + try: + logger.info(f"[LLMIntegration] Extracting recommendations") + + recommendations = [] + + # 패턴 1: 번호 목록 (1. 2. 3. ...) + pattern1 = re.findall(r"\d+\.\s+([^\n]+)", response) + if pattern1: + recommendations.extend(pattern1) + + # 패턴 2: 하이픈 목록 (- ... ) + pattern2 = re.findall(r"-\s+([^\n]+)", response) + if pattern2: + recommendations.extend(pattern2) + + # 패턴 3: 권장사항 섹션 + if "권장사항" in response or "권고" in response: + section_match = re.search( + r"(?:권장사항|권고)[^:]*:?\s*([^##]+?)(?:##|$)", + response, + re.IGNORECASE | re.DOTALL + ) + if section_match: + section_text = section_match.group(1) + lines = [line.strip() for line in section_text.split("\n") if line.strip()] + recommendations.extend(lines[:10]) # 최대 10개 + + # 기본값 (추천사항 없으면) + if not recommendations: + recommendations = [ + "논문 리뷰를 통한 추가 선행 연구 검토", + "제시된 방법론의 현실성 검증", + "협력 기관 및 전문가 네트워크 구축" + ] + + logger.info(f"[LLMIntegration] Extracted {len(recommendations)} recommendations") + return recommendations[:10] # 최대 10개 + + except Exception as e: + logger.error(f"[LLMIntegration] Error extracting recommendations: {str(e)}") + return [] + + @staticmethod + async def extract_limitations(response: str) -> List[str]: + """ + LLM 응답에서 한계점 추출 + + Args: + response: LLM 응답 + + Returns: + 한계점 리스트 + """ + try: + logger.info(f"[LLMIntegration] Extracting limitations") + + limitations = [] + + # 패턴: 한계 섹션 + section_match = re.search( + r"(?:한계|제한|한정|문제점)[^:]*:?\s*([^##]+?)(?:##|$)", + response, + re.IGNORECASE | re.DOTALL + ) + + if section_match: + section_text = section_match.group(1) + lines = [line.strip() for line in section_text.split("\n") if line.strip()] + limitations.extend(lines[:10]) + + # 기본값 + if not limitations: + limitations = [ + "분석 대상 논문의 제한된 수", + "특정 연구 분야에 편향될 수 있음", + "최신 연구 동향 반영 필요" + ] + + logger.info(f"[LLMIntegration] Extracted {len(limitations)} limitations") + return limitations[:10] + + except Exception as e: + logger.error(f"[LLMIntegration] Error extracting limitations: {str(e)}") + return [] diff --git a/backend/app/agents/report_agent/prompt.py b/backend/app/agents/report_agent/prompt.py new file mode 100644 index 0000000..83aaca2 --- /dev/null +++ b/backend/app/agents/report_agent/prompt.py @@ -0,0 +1,672 @@ +""" +Report Agent Prompts (Advanced Version) +고도화된 연구 타당성 분석 보고서 생성을 위한 프롬프트 템플릿 + +설계 철학: +- 결론을 내리는 AI가 아닌, 연구자의 판단을 돕는 도구 +- Evidence-first 사고: 모든 주장은 근거에 기반 +- 불확실성을 숨기지 않고 명시 +- 시각화 ↔ 평가 ↔ 서술의 논리적 연결 +""" + +# ============================================================================ +# SYSTEM PROMPT - Agent Identity & Core Rules +# ============================================================================ + +SYSTEM_PROMPT = """ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔬 AGENT IDENTITY (정체성 정의) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +You are NOT a summarization agent. +You are a **Research Feasibility Analysis Agent**. + +Your role is NOT to conclude whether the hypothesis is true, +but to help the researcher decide whether the research should proceed at this stage, +based strictly on evidence. + +당신은 요약 에이전트가 아닙니다. +당신은 **연구 타당성 분석 에이전트**입니다. + +당신의 역할은 가설이 맞는지 결론을 내리는 것이 아니라, +오직 근거에 기반하여 연구자가 이 단계에서 연구를 진행해야 할지 판단할 수 있도록 돕는 것입니다. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🚫 HALLUCINATION PREVENTION (환각 방지 규칙) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +You must NEVER fabricate: +- Numerical values (수치 데이터) +- Experimental results (실험 결과) +- Conclusions not directly stated in sources (출처에 없는 결론) + +If evidence is insufficient or heterogeneous: +→ Explicitly state the uncertainty (불확실성을 명시) + +If normalization or comparison is not possible: +→ Mark it as "비교 불가" rather than forcing an interpretation + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📐 SCOPE CONTROL (범위 제한 규칙) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Before analysis, restate the research question and scope in your own words. + +Do NOT expand the scope beyond: +- Specified compounds (지정된 화합물/물질) +- Specified endpoints (지정된 평가 지표) +- Specified experimental models (in-vitro / in-vivo / clinical) + +분석 전, 연구 질문과 범위를 본인의 언어로 재진술하세요. +지정된 범위를 임의로 확장하지 마세요. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📚 EVIDENCE-FIRST PRINCIPLE (근거 우선 원칙) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +All claims must be grounded in extracted evidence. +Every analytical statement must be traceable to: +- One or more documents [파일명] 형식으로 인용 +- Specific experimental contexts + +If a claim relies on a single paper: +→ Explicitly state: "단일 논문 기반으로 근거가 제한적임" + +모든 주장은 추출된 근거에 기반해야 합니다. +모든 분석적 진술은 출처로 추적 가능해야 합니다. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 VISUALIZATION RULES (시각화 생성 규칙) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Generate a visualization ONLY if it supports decision-making. +Do NOT generate charts for decorative purposes. +Each visualization must answer a specific research question. + +시각화 유형별 목적: + +1. **Evidence Landscape (근거 분포도)** + - 실험 수준별 근거 분포 표시 (in-vitro / in-vivo / clinical) + +2. **Comparison Charts (비교 차트)** + - 정규화 후 상대적 차이 표시 + - 중복, 분산, 희소성 강조 + +3. **Dose-Response (용량-반응)** + - 상관 패턴만 표시 + - 인과관계 추론 금지 + +4. **Evidence-Claim Graph (근거-주장 그래프)** + - 결론이 여러 근거에 의해 어떻게 지지되는지 표시 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 OUTPUT FORMAT (출력 형식) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +- 모든 답변은 한국어로 작성 (고유명사 및 전문 용어 제외) +- 모든 주장에 [파일명] 형식으로 출처 명시 +- 불확실한 내용은 "~로 추정됨", "근거 제한적" 등으로 표현 +- Markdown 형식 사용 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎯 CORE MISSION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Your goal is to reduce decision risk, not to maximize confidence. +당신의 목표는 확신을 극대화하는 것이 아니라, 의사결정 리스크를 줄이는 것입니다. +""" + + +# ============================================================================ +# MAIN REPORT GENERATION PROMPT +# ============================================================================ + +REPORT_GENERATION_PROMPT = """ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 연구 타당성 분석 보고서 생성 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**분석 대상 연구주제:** +{research_topic} + +**연구 설명:** +{research_description} + +**분석 초점:** +{analysis_goal} + +**관련 문서:** +{documents} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📌 보고서 작성 규칙 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**인용 규칙:** +- 모든 주장과 데이터에 대해 논문 제목을 사용하여 [논문 제목] 형식으로 출처를 명시하세요 +- ❌ 절대 [파일명]이라는 placeholder를 사용하지 마세요 +- ✅ 반드시 문서에서 제공된 실제 논문 제목을 사용하세요 (예: [Data Augmentation for Meibography], [연구논문 제목]) +- 고유명사(브랜드명, 기술명 등)를 제외하고는 모든 내용을 한글로 작성하세요 + +**금지 사항:** +❌ 수치, 실험 결과, 결론을 임의로 생성하지 마세요 +❌ 근거 없이 타당성 점수를 부여하지 마세요 +❌ "가설이 맞다/틀리다"와 같은 최종 결론을 내리지 마세요 +❌ 지정된 연구 범위를 임의로 확장하지 마세요 + +**필수 사항:** +✅ 불확실한 경우 명시적으로 불확실성을 표현하세요 +✅ 단일 논문 기반 주장은 "근거가 제한적"임을 표시하세요 +✅ 비교가 불가능한 경우 "비교 불가"로 표기하세요 +✅ 모든 평가는 앞선 섹션의 근거와 연결되어야 합니다 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 보고서 구조 (반드시 이 형식으로 작성) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 1. 연구 질문 및 범위 +- **연구 질문**: (본인의 언어로 재진술) +- **분석 범위**: (화합물, 평가 지표, 실험 모델 명시) +- **범위 외 사항**: (분석에서 제외된 부분) + +## 2. 근거 요약 +각 문서에 대해 다음 형식으로 작성: + +### [논문 제목] +- **실험 수준**: in-vitro / in-vivo / clinical +- **핵심 발견**: (주요 결과 및 수치, 있는 경우 포함) +- **실험 조건**: (방법론) + +## 3. 정규화된 비교 분석 +- **비교 가능한 데이터**: (상대적 차이 설명) +- **정규화 방법**: (사용한 방법 명시) +- **비교 불가능한 항목**: "비교 불가" 명시 + +## 4. 관찰된 패턴 및 리스크 +- **일관된 패턴**: (공통적으로 관찰된 내용) +- **상충되는 결과**: (있는 경우) +- **바이어스 요인**: (잠재적 문제점) + +## 5. 연구 타당성 평가 + +### 5.1 근거 강도 +| 수준 | 상태 | +|------|------| +| In-vitro | ○ 있음 / ● 없음 | +| In-vivo | ○ 있음 / ● 없음 | +| Clinical | ○ 있음 / ● 없음 | + +### 5.2 일관성 +- [ ] 대체로 일관됨 +- [ ] 부분적 상충 +- [ ] 강하게 상충 + +### 5.3 비교가능성 +- [ ] 완전히 비교 가능 +- [ ] 부분적으로 비교 가능 +- [ ] 비교 불가 + +### 5.4 바이어스 리스크 +- **표본 크기 제한**: (있는 경우 설명) +- **모델/시스템 바이어스**: (있는 경우 설명) +- **후원/출판 바이어스**: (있는 경우 설명) + +### 5.5 재현가능성 +- **실험 조건 명확성**: (평가) +- **후속 연구 적합성**: (평가) + +## 6. 한계점 +- (분석의 한계 1) +- (분석의 한계 2) +- (데이터 부족 영역) + +## 7. 다음 단계 권장사항 +- **즉시 취할 수 있는 조치**: (구체적 행동) +- **추가 검토 필요 사항**: (더 필요한 정보) +- **추가 근거 확보 방안**: (권장 실험/연구) +- **협력 제안**: (관련 분야/기관) + +## 8. 최종 요약 + +**현재까지 알려진 것**: +- (확실한 내용) + +**불확실한 것**: +- (불분명한 내용) + +**확신 있는 결론을 방해하는 요소**: +- (제한 요인) + +**필요한 추가 근거**: +- (보완이 필요한 증거) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 출력 예시 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 1. 연구 질문 및 범위 +- **연구 질문**: "화합물 A와 화합물 B의 항산화 효과를 비교 평가" +- **분석 범위**: DPPH, ABTS 라디칼 소거능 평가 (in-vitro) +- **범위 외 사항**: in-vivo 효과, 세포독성 평가는 현재 문서에 포함되지 않음 + +## 2. 근거 요약 + +### 논문1.pdf +- **실험 수준**: in-vitro +- **핵심 발견**: 화합물 A가 DPPH에서 85% 소거능 보임 (IC50 = 45 μg/mL) +- **실험 조건**: 100 μg/mL 농도, 30분 반응 + +### 논문2.pdf +- **실험 수준**: in-vitro +- **핵심 발견**: 화합물 B가 ABTS에서 92% 소거능 보임 (IC50 = 28 μg/mL) +- **실험 조건**: 50 μg/mL 농도, 10분 반응 + +## 3. 정규화된 비교 분석 +- **비교 가능한 데이터**: 두 논문 모두 in-vitro 라디칼 소거능 평가 +- **정규화 방법**: IC50 값을 기준으로 상대 효능 비교 +- **비교 불가능한 항목**: 반응 시간(30분 vs 10분) 및 평가 시스템(DPPH vs ABTS) 차이로 직접 비교 불가 + +## 4. 관찰된 패턴 및 리스크 +- **일관된 패턴**: 두 화합물 모두 항산화 활성을 보임 [논문1.pdf, 논문2.pdf] +- **상충되는 결과**: 평가 시스템에 따라 효능 순위가 달라질 수 있음 +- **바이어스 요인**: + - 실험 조건(농도, 시간) 불일치 + - 단일 농도 평가로 용량-반응 관계 불명확 + +## 5. 연구 타당성 평가 + +### 5.1 근거 강도 +| 수준 | 상태 | +|------|------| +| In-vitro | ● 있음 (n=2) | +| In-vivo | ○ 없음 | +| Clinical | ○ 없음 | + +### 5.2 일관성 +- [x] 대체로 일관됨 (두 문서 모두 항산화 활성 확인) +- [ ] 부분적 상충 +- [ ] 강하게 상충 + +### 5.3 비교가능성 +- [ ] 완전히 비교 가능 +- [x] 부분적으로 비교 가능 (평가 시스템 및 조건 차이) +- [ ] 비교 불가 + +### 5.4 바이어스 리스크 +- **표본 크기 제한**: 각 화합물당 1개 문서로 재현성 검증 불가 +- **모델/시스템 바이어스**: DPPH와 ABTS는 서로 다른 라디칼 시스템으로 결과 비교 제한적 +- **후원/출판 바이어스**: 문서에서 확인되지 않음 + +### 5.5 재현가능성 +- **실험 조건 명확성**: 농도, 반응 시간, 평가 방법 명시되어 있음 +- **후속 연구 적합성**: 동일 조건에서 재현 실험 가능 + +## 6. 한계점 +- in-vitro 데이터만 존재하여 생체 내 효과 예측 불가 +- 평가 시스템 차이로 직접 비교 제한적 +- 각 화합물당 1개 문서로 통계적 유의성 평가 불가 +- 용량-반응 곡선 데이터 부족 + +## 7. 다음 단계 권장사항 +- **즉시 취할 수 있는 조치**: + - 동일 조건(농도, 시간, 평가 시스템)에서 두 화합물 비교 실험 설계 + - 용량-반응 곡선 확보를 위한 다농도 평가 계획 +- **추가 검토 필요 사항**: + - 다른 항산화 평가 시스템(FRAP, ORAC 등) 결과 문헌 조사 + - 세포 기반 항산화 효과 연구 검색 +- **추가 근거 확보 방안**: + - 동물 모델을 통한 in-vivo 항산화 효과 검증 + - 구조-활성 관계(SAR) 분석을 위한 유사 화합물 데이터 수집 +- **협력 제안**: + - 천연물 화학 전문가와 구조 분석 협업 + - 약리학 연구실과 in-vivo 실험 협력 + +## 8. 최종 요약 + +**현재까지 알려진 것**: +- 화합물 A와 B 모두 in-vitro에서 항산화 활성을 보임 [논문1.pdf, 논문2.pdf] +- 각각 DPPH와 ABTS 시스템에서 효과가 확인됨 + +**불확실한 것**: +- 동일 조건에서의 상대적 효능 비교 +- 생체 내(in-vivo) 항산화 효과 +- 임상적 유용성 + +**확신 있는 결론을 방해하는 요소**: +- 평가 시스템 및 실험 조건 불일치 +- 문서 수 부족(각 화합물당 1개) +- in-vitro 단계에 국한된 데이터 + +**필요한 추가 근거**: +- 동일 조건에서의 직접 비교 실험 +- in-vivo 항산화 효과 검증 +- 다양한 평가 시스템에서의 일관성 확인 +- 용량-반응 관계 데이터 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +� 시각화 데이터 출력 (보고서 끝에 JSON 블록 추가) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +보고서 끝에 다음 형식의 JSON 블록을 반드시 포함하세요: + +```json +{{{{ + "visualization_data": {{{{ + "feasibility_breakdown": {{{{ + "근거 강도": 85, + "일관성": 75, + "비교가능성": 60, + "바이어스 리스크": 70, + "재현가능성": 80 + }}}}, + "document_analysis": [ + {{{{ + "filename": "논문1.pdf", + "evidence_level": "in-vitro", + "key_findings": "화합물 A, DPPH 85% 소거능", + "relevance_score": 90 + }}}}, + {{{{ + "filename": "논문2.pdf", + "evidence_level": "in-vivo", + "key_findings": "화합물 B, ABTS 92% 소거능", + "relevance_score": 85 + }}}} + ], + "evidence_distribution": {{{{ + "in-vitro": 2, + "in-vivo": 0, + "clinical": 0 + }}}}, + "comparison_data": {{{{ + "labels": ["화합물 A", "화합물 B"], + "values": [85, 92], + "metric": "소거능 (%)" + }}}} + }}}} +}}}} +``` + +**시각화 데이터 필드 설명:** +- `feasibility_breakdown`: 5가지 평가 차원의 점수 (0-100) + - 근거 강도, 일관성, 비교가능성, 바이어스 리스크, 재현가능성 +- `document_analysis`: 각 문서의 실제 파일명, 실험 수준, 핵심 발견, 관련성 점수 +- `evidence_distribution`: 실험 수준별(in-vitro/in-vivo/clinical) 문서 개수 +- `comparison_data`: 비교 분석을 위한 실제 수치 데이터 (있는 경우만) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +�📌 필수 포함: AI 책임 고지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +--- + +**⚠️ AI 분석 고지사항** + +본 보고서의 근거 수집 및 정리는 AI의 도움을 받아 수행되었습니다. +최종 연구 결정 및 해석에 대한 책임은 연구자에게 있습니다. + +Evidence collection and organization were assisted by AI. +Final research decisions and interpretations remain the responsibility of the researcher. + +--- + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔒 최종 검증 규칙 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +If any feasibility assessment cannot be directly traced to earlier sections, +explicitly state that the assessment is inconclusive. + +타당성 평가가 앞선 섹션의 근거와 직접 연결되지 않는 경우, +해당 평가가 "결론을 내릴 수 없음"임을 명시하세요. + +Your goal is to reduce decision risk, not to maximize confidence. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💡 출력 예시 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +다음과 같은 형식으로 작성하세요: + +## 1. 연구 타당성 평가 +- **타당성 점수**: 85/100 +- **평가 근거**: 연구 주제는 안과학 및 인공지능 분야에서 중요한 문제로 인식되고 있습니다. Meibomian gland의 분할은 눈물막의 안정성을 이해하는 데 중요하며, 데이터 증강 기법은 제한된 의료 이미지 데이터셋에서 모델 성능을 향상시키는 데 유용합니다. [파일명.pdf] +- **주요 발견사항**: 데이터 증강은 모델의 일반화 능력을 향상시키는 효과적인 방법으로, 특히 의료 이미지 분석에서 제한된 데이터셋을 보완하는 데 유용합니다. + +## 2. 선행 연구 분석 +- **현재까지의 연구 동향**: 최근 몇 년간, 의료 이미지 분석에서 딥러닝 기법의 활용이 크게 증가하였습니다. 특히, 데이터 증강 기법은 제한된 데이터셋에서 모델 성능을 개선하는 데 널리 사용되고 있습니다. [파일명.pdf] +- **유사 연구 사례**: 여러 연구에서 데이터 증강 기법을 사용하여 의료 이미지 분석의 정확도를 높였습니다. +- **차별성 분석**: 본 연구는 meibography 이미지에 특화된 데이터 증강 기법을 개발하고, 이를 Meibomian gland 분할 모델에 적용하는 점에서 차별화됩니다. + +## 3. 방법론적 가능성 +- **제안된 연구 방법의 적절성**: 데이터 증강 기법을 사용하여 분할 모델을 훈련하는 방법은 적절합니다. [파일명.pdf] +- **기술적 타당성**: 데이터 증강 기법은 기술적으로 타당하며, 기존의 이미지 처리 라이브러리를 사용하여 구현할 수 있습니다. +- **필요 자원 평가**: 필요한 자원으로는 고품질 meibography 이미지 데이터셋, 고성능 GPU, 이미지 처리 및 딥러닝 라이브러리가 있습니다. + +## 4. 예상 문제점 및 해결책 +- **주요 연구 장애물**: 제한된 데이터셋과 이미지 품질의 변동성이 주요 장애물입니다. +- **극복 방안**: 데이터 증강 기법을 사용하여 데이터셋의 크기를 늘리고, 전처리 기법을 통해 이미지 품질을 개선할 수 있습니다. +- **대안 모색**: 추가 데이터 수집 및 협력을 통해 데이터셋을 확장하거나, 다른 데이터 증강 기법을 탐색할 수 있습니다. + +## 5. 학술적 기여도 +- **예상 학술 기여**: 본 연구는 meibography 이미지 분석의 정확도를 향상시켜 안과 질환 진단에 기여할 것으로 기대됩니다. +- **산업적 응용 가능성**: 개발된 모델은 안과 임상에서 실제로 사용될 수 있으며, 의료 영상 분석 소프트웨어에 통합될 수 있습니다. +- **사회적 의미**: 정확한 진단을 통해 환자의 치료 결과를 개선하고, 의료 비용을 절감할 수 있습니다. + +## 6. 최종 권장사항 +- **연구 진행 여부 판단**: 연구를 진행하는 것이 타당합니다. +- **필요한 보완사항**: 더 많은 데이터 수집 및 다양한 데이터 증강 기법의 비교 실험이 필요합니다. +- **협력 분야 제안**: 안과학과 및 의료 영상 분석 전문가와의 협력이 권장됩니다. + +""" + + +# ============================================================================ +# EVIDENCE SYNTHESIS PROMPT +# ============================================================================ + +EVIDENCE_SYNTHESIS_PROMPT = """ +주어진 논문들을 분석하여 다음을 작성하세요: + +**분석 원칙:** +- 모든 주장에 [파일명] 형식으로 출처 표기 +- 단일 논문 기반 주장은 "근거 제한적" 표시 +- 임의의 수치나 결론 생성 금지 + +**작성 내용:** + +1. **연구 주제와의 관련성** + - 각 논문이 연구 질문과 어떻게 연결되는지 + +2. **각 논문의 핵심 발견** + - 실험 수준 (in-vitro / in-vivo / clinical) + - 주요 결과 및 수치 (있는 경우) + - 실험 조건 + +3. **논문들 간 연관관계** + - 일관된 패턴 + - 상충되는 결과 + - 보완적 관계 + +4. **근거의 한계** + - 데이터 부족 영역 + - 비교 불가능한 항목 + - 불확실성 영역 + +**금지:** +- "종합적으로 ~라고 결론 내릴 수 있다"와 같은 단정적 결론 +- 출처 없는 주장 +""" + + +# ============================================================================ +# RECOMMENDATION PROMPT +# ============================================================================ + +RECOMMENDATION_PROMPT = """ +위 분석을 바탕으로 연구자가 취할 수 있는 구체적인 행동 방안을 제시하세요: + +**원칙:** +- 모든 권장사항은 앞선 분석 내용과 연결되어야 함 +- 근거가 부족한 영역에 대한 추가 조사 권장 +- 단정적 결론 회피 + +**권장사항 구조:** + +1. **즉시 취할 수 있는 조치** + - 현재 근거로 가능한 행동 + +2. **추가 검토 필요 사항** + - 불확실성 해소를 위해 필요한 정보 + +3. **추가 근거 확보 방안** + - 필요한 실험 유형 (in-vitro / in-vivo / clinical) + - 보완할 데이터 유형 + +4. **협력 제안** + - 관련 전문 분야 + - 잠재적 협력 기관/연구자 + +**주의:** +"반드시 ~해야 한다"보다 "~를 고려할 수 있다" 형식 권장 +""" + + +# ============================================================================ +# SECTION HEADERS +# ============================================================================ + +SECTION_TITLES = { + "question_scope": "1. 연구 질문 및 범위", + "evidence_summary": "2. 근거 요약", + "normalized_comparisons": "3. 정규화된 비교 분석", + "patterns_risks": "4. 관찰된 패턴 및 리스크", + "feasibility_assessment": "5. 연구 타당성 평가", + "limitations": "6. 한계점", + "recommendations": "7. 다음 단계 권장사항", + "final_summary": "8. 최종 요약", + "ai_disclaimer": "AI 분석 고지사항" +} + + +# ============================================================================ +# FEASIBILITY DIMENSIONS (평가 차원) +# ============================================================================ + +FEASIBILITY_DIMENSIONS = { + "evidence_strength": { + "name": "근거 강도 (Evidence Strength)", + "levels": ["In-vitro only", "In-vivo included", "Clinical evidence included"] + }, + "consistency": { + "name": "일관성 (Consistency)", + "levels": ["Largely consistent", "Partially conflicting", "Strongly conflicting"] + }, + "comparability": { + "name": "비교가능성 (Comparability)", + "levels": ["Fully comparable", "Partially comparable", "Not comparable"] + }, + "bias_risk": { + "name": "바이어스 리스크 (Risk of Bias)", + "factors": ["Sample size limitations", "Model/system bias", "Sponsor or publication bias"] + }, + "reproducibility": { + "name": "재현가능성 (Reproducibility)", + "factors": ["Experimental conditions clearly described", "Suitable for follow-up study design"] + } +} + + +# ============================================================================ +# CONFIGURATION CONSTANTS +# ============================================================================ + +DEFAULT_TEMPERATURE = 0.5 # 낮은 온도로 일관성 확보 +DEFAULT_MAX_TOKENS = 50000 # Solar-pro2 최대 토큰 활용 +MIN_TEMPERATURE = 0.0 +MAX_TEMPERATURE = 1.0 # 창의성보다 정확성 우선 +MIN_MAX_TOKENS = 1000 +MAX_MAX_TOKENS = 64000 # Solar-pro2 최대 + +# Report types +REPORT_TYPES = ["comprehensive", "summary", "detailed"] + +# Feasibility thresholds (점수 기반 아닌 근거 기반으로 변경됨) +# 이제 점수가 아닌 차원별 평가로 전환 +FEASIBILITY_EVALUATION_NOTE = """ +Note: 타당성 평가는 임의의 점수가 아닌, +5가지 차원(근거강도, 일관성, 비교가능성, 바이어스리스크, 재현가능성)에 대한 +근거 기반 평가로 수행됩니다. +""" + + +# ============================================================================ +# VISUALIZATION TEMPLATES +# ============================================================================ + +VISUALIZATION_TEMPLATES = { + "evidence_landscape": """ +### 📊 근거 분포도 (Evidence Landscape) +``` +┌─────────────────────────────────────────────────────┐ +│ 실험 수준 │ 논문 수 │ 시각화 │ +├─────────────────────────────────────────────────────┤ +│ In-vitro │ {invitro_count:>3} │ {invitro_bar:<20} │ +│ In-vivo │ {invivo_count:>3} │ {invivo_bar:<20} │ +│ Clinical │ {clinical_count:>3} │ {clinical_bar:<20} │ +└─────────────────────────────────────────────────────┘ +``` +""", + + "feasibility_radar": """ +### 📊 타당성 평가 요약 +``` + 근거강도 + ▲ + │ {evidence} + │ + 일관성 ──────┼────── 비교가능성 + {consistency} │ {comparability} + │ + │ {reproducibility} + 재현가능성 + + ● 충족 ◐ 부분충족 ○ 미충족 +``` +""", + + "evidence_claim_graph": """ +### 📊 근거-주장 연결도 +``` +┌─────────────────────────────────────────────────────┐ +│ 주장/패턴 ← 지지 근거 │ +├─────────────────────────────────────────────────────┤ +│ {claim1} │ +│ └── {evidence1_1} │ +│ └── {evidence1_2} │ +├─────────────────────────────────────────────────────┤ +│ {claim2} │ +│ └── {evidence2_1} │ +└─────────────────────────────────────────────────────┘ +``` +""" +} + + +# ============================================================================ +# AI DISCLAIMER (필수 포함) +# ============================================================================ + +AI_DISCLAIMER = """ +--- + +**⚠️ AI 분석 고지사항** + +본 보고서의 근거 수집 및 정리는 AI의 도움을 받아 수행되었습니다. +최종 연구 결정 및 해석에 대한 책임은 연구자에게 있습니다. + +Evidence collection and organization were assisted by AI. +Final research decisions and interpretations remain the responsibility of the researcher. + +--- +""" diff --git a/backend/app/agents/report_agent/report_agent_content.md b/backend/app/agents/report_agent/report_agent_content.md new file mode 100644 index 0000000..d74f873 --- /dev/null +++ b/backend/app/agents/report_agent/report_agent_content.md @@ -0,0 +1,82 @@ +# 리포트 에이전트 + +## 개요 + +리포트 에이전트는 의도 기반 실행을 통해 종합적인 연구 가능성 보고서를 생성합니다. + +## 작동 방식 + +1. **의도 분류**: 생성할 보고서 유형을 결정합니다 (예: 전체 보고서, 데이터 처리, 시각화, 빠른 분석). +2. **데이터 정규화**: 입력 데이터를 처리하고 정규화합니다. +3. **보고서 생성**: ReportBuilder를 사용하여 보고서를 작성합니다. +4. **시각화**: Visualizer를 사용하여 시각화를 생성합니다. +5. **LLM 통합**: 증거 합성과 보고서 생성을 위해 LLM을 활용합니다. + +## 사용 기술 및 도구 + +- **LLM 서비스**: 증거 합성과 보고서 생성. +- **데이터 정규화(Data Normalizer)**: 입력 데이터 처리 및 정규화. +- **ReportBuilder**: 보고서 작성. +- **Visualizer**: 시각화 생성. +- **SQLAlchemy**: 데이터베이스 상호작용. + +## 주요 구성 요소 + +- **스키마(Schemas)**: 요청 및 응답 구조 정의. +- **프롬프트(Prompts)**: 보고서 생성 및 증거 합성을 위한 프롬프트 포함. +- **LLM 통합**: LLM과의 상호작용 처리. +- **데이터 정규화(Data Normalizer)**: 데이터 처리 및 정규화. +- **Visualizer**: 보고서를 위한 시각화 생성. + +## 주요 파일 + +- `agent.py`: 리포트 에이전트의 주요 구현 파일. + +## 평가 방법 + +### 결과물 평가 기준 + +1. **연구 타당성 점수 (Feasibility Score)** + - LLM이 5가지 차원을 기반으로 평가한 종합 점수 (0-100) + - 5가지 차원: 근거 강도, 일관성, 비교가능성, 바이어스 리스크, 재현가능성 + - 평가 지표: 각 차원별 점수, 종합 타당성 점수 + +2. **증거 기반 평가 (Evidence-Based Assessment)** + - 모든 평가가 문서에서 추출한 증거를 기반으로 하는가? + - 임의의 수치나 결론 생성을 피하는가? + - 평가 지표: 인용 포함률, 근거 추적 가능성 + +3. **불확실성 표현 (Uncertainty Expression)** + - 불확실한 부분을 명시적으로 표현하는가? + - "근거 제한적", "비교 불가" 등의 표시가 적절한가? + - 평가 지표: 불확실성 명시률, 과대평가 방지 + +4. **구조화된 출력 (Structured Output)** + - 8개 섹션 형식을 따르는가? + - Markdown 포맷이 올바른가? + - 평가 지표: 섹션 완성도, 포맷 준수 + +5. **시각화 품질 (Visualization Quality)** + - LLM이 생성한 visualization_data가 유효한가? + - 실제 데이터를 기반으로 한 시각화인가? + - 평가 지표: 시각화 생성 성공률, 데이터 정확성 + +### 평가 프로세스 + +```python +# 평가 예시 +result = { + "feasibility_score": 85.0, # 타당성 종합 점수 + "evidence_quality": 0.92, # 증거 품질 + "uncertainty_handling": 0.88, # 불확실성 처리 + "structure_compliance": 0.95, # 구조 준수 + "visualization_success": 0.90, # 시각화 성공 + "overall_score": 0.90 # 종합 점수 +} +``` + +### 타당성 점수 기준 + +- **매우 타당 (75-100)**: 충분한 근거와 높은 일관성 +- **타당 (50-75)**: 제한적 근거나 일부 상충 +- **추가 검토 (<50)**: 불충분한 근거 또는 상충된 결과 diff --git a/backend/app/agents/report_agent/report_builder.py b/backend/app/agents/report_agent/report_builder.py new file mode 100644 index 0000000..a4d85b9 --- /dev/null +++ b/backend/app/agents/report_agent/report_builder.py @@ -0,0 +1,395 @@ +""" +Report Builder +Generate research reports in Markdown and PDF formats +""" + +import logging +from datetime import datetime +from zoneinfo import ZoneInfo +from io import BytesIO +from typing import Optional + +from reportlab.lib.pagesizes import letter, A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import ( + SimpleDocTemplate, + Table, + TableStyle, + Paragraph, + Spacer, + PageBreak, + Image, +) +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY + +from app.agents.report_agent.schemas import ResearchReport + +logger = logging.getLogger(__name__) + + +class ReportBuilder: + """보고서 생성 도구""" + + # ============================================================================ + # Markdown Builder + # ============================================================================ + + @staticmethod + async def build_markdown(report: ResearchReport) -> str: + """ + Markdown 형식 보고서 생성 + + Args: + report: ResearchReport 객체 + + Returns: + Markdown 문자열 + """ + try: + logger.info(f"[ReportBuilder] Building Markdown report: {report.title}") + + lines = [] + + # Title + lines.append(f"# {report.title}") + lines.append("") + + # Metadata + lines.append("## 📋 기본 정보") + lines.append(f"- **연구주제**: {report.research_topic}") + lines.append(f"- **생성일**: {datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **참고논문**: {len(report.related_papers)}개") + lines.append("") + + # Validation Summary + lines.append("## 🎯 타당성 평가") + feasibility_emoji = "✅" if report.validation.is_feasible else "⚠️" + lines.append( + f"{feasibility_emoji} **평가 결과**: " + f"{'연구 가능' if report.validation.is_feasible else '추가 검토 필요'}" + ) + lines.append(f"- **타당성 점수**: {report.validation.feasibility_score:.1f}/100") + lines.append(f"- **근거**: {report.validation.reasoning}") + lines.append("") + + # Sections + if report.sections: + lines.append("## 📄 상세 분석") + for section in report.sections: + lines.append(f"### {section.title}") + lines.append(section.content) + if section.citations: + lines.append("**참고 문헌:**") + for citation in section.citations: + lines.append(f"- {citation}") + lines.append("") + + # Evidence Summary + if report.evidence_summary: + lines.append("## 📚 증거 요약") + lines.append(report.evidence_summary) + lines.append("") + + # Recommendations + if report.recommendations: + lines.append("## 💡 권장사항") + for idx, rec in enumerate(report.recommendations, 1): + lines.append(f"{idx}. {rec}") + lines.append("") + + # Limitations + if report.limitations: + lines.append("## ⚠️ 한계 및 고려사항") + for limitation in report.limitations: + lines.append(f"- {limitation}") + lines.append("") + + # Related Papers + if report.related_papers: + lines.append("## 📖 참고 논문") + for idx, paper in enumerate(report.related_papers, 1): + author_str = f" ({paper.authors})" if paper.authors else "" + year_str = f" [{paper.year}]" if paper.year else "" + lines.append(f"{idx}. {paper.title}{author_str}{year_str}") + lines.append("") + + # Footer + lines.append("---") + lines.append(f"*보고서 생성: {datetime.now(ZoneInfo('Asia/Seoul')).isoformat()}*") + + markdown = "\n".join(lines) + logger.info(f"[ReportBuilder] Markdown report built: {len(markdown)} chars") + return markdown + + except Exception as e: + logger.error(f"[ReportBuilder] Error building Markdown: {str(e)}") + raise + + # ============================================================================ + # PDF Builder + # ============================================================================ + + @staticmethod + async def build_pdf(report: ResearchReport) -> bytes: + """ + PDF 형식 보고서 생성 (reportlab) + + Args: + report: ResearchReport 객체 + + Returns: + PDF 바이너리 + """ + try: + logger.info(f"[ReportBuilder] Building PDF report: {report.title}") + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=72, + leftMargin=72, + topMargin=72, + bottomMargin=72, + ) + + elements = [] + styles = getSampleStyleSheet() + + # Custom styles + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Heading1"], + fontSize=24, + textColor=colors.HexColor("#1f4788"), + spaceAfter=30, + alignment=TA_CENTER, + fontName="Helvetica-Bold", + ) + + heading_style = ParagraphStyle( + "CustomHeading", + parent=styles["Heading2"], + fontSize=14, + textColor=colors.HexColor("#2e5c8a"), + spaceAfter=12, + spaceBefore=12, + fontName="Helvetica-Bold", + ) + + body_style = ParagraphStyle( + "CustomBody", + parent=styles["Normal"], + fontSize=11, + alignment=TA_JUSTIFY, + spaceAfter=12, + ) + + # Title + elements.append(Paragraph(report.title, title_style)) + elements.append(Spacer(1, 0.3 * inch)) + + # Metadata Table + metadata_data = [ + ["항목", "내용"], + ["연구주제", report.research_topic], + ["생성일", datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S")], + ["참고논문", f"{len(report.related_papers)}개"], + ] + + metadata_table = Table(metadata_data, colWidths=[1.5 * inch, 4 * inch]) + metadata_table.setStyle( + TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2e5c8a")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 12), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("BACKGROUND", (0, 1), (-1, -1), colors.beige), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f0f0")]), + ]) + ) + + elements.append(metadata_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Validation Section + elements.append(Paragraph("🎯 타당성 평가", heading_style)) + + feasibility_emoji = "✅" if report.validation.is_feasible else "⚠️" + elements.append( + Paragraph( + f"{feasibility_emoji} 평가 결과: " + f"{'연구 가능' if report.validation.is_feasible else '추가 검토 필요'}", + body_style, + ) + ) + elements.append( + Paragraph( + f"타당성 점수: {report.validation.feasibility_score:.1f}/100", + body_style, + ) + ) + elements.append( + Paragraph( + f"근거: {report.validation.reasoning}", + body_style, + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Sections + if report.sections: + elements.append(Paragraph("📄 상세 분석", heading_style)) + for section in report.sections: + elements.append(Paragraph(section.title, heading_style)) + # 긴 텍스트는 요약 + content_preview = section.content[:500] + "..." if len(section.content) > 500 else section.content + elements.append(Paragraph(content_preview, body_style)) + + if section.citations: + elements.append(Paragraph("참고 문헌:", body_style)) + for citation in section.citations[:3]: # 최대 3개 + elements.append(Paragraph(f"• {citation}", body_style)) + + elements.append(Spacer(1, 0.15 * inch)) + + # Evidence Summary + if report.evidence_summary: + elements.append(PageBreak()) + elements.append(Paragraph("📚 증거 요약", heading_style)) + evidence_preview = ( + report.evidence_summary[:800] + "..." + if len(report.evidence_summary) > 800 + else report.evidence_summary + ) + elements.append(Paragraph(evidence_preview, body_style)) + elements.append(Spacer(1, 0.2 * inch)) + + # Recommendations + if report.recommendations: + elements.append(Paragraph("💡 권장사항", heading_style)) + for idx, rec in enumerate(report.recommendations[:5], 1): # 최대 5개 + elements.append( + Paragraph(f"{idx}. {rec}", body_style) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Limitations + if report.limitations: + elements.append(Paragraph("⚠️ 한계 및 고려사항", heading_style)) + for limitation in report.limitations[:5]: # 최대 5개 + elements.append( + Paragraph(f"• {limitation}", body_style) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Related Papers + if report.related_papers: + elements.append(PageBreak()) + elements.append(Paragraph("📖 참고 논문", heading_style)) + + # 논문 테이블 + papers_data = [["#", "제목", "저자", "연도"]] + for idx, paper in enumerate(report.related_papers[:10], 1): # 최대 10개 + title_short = paper.title[:40] + "..." if len(paper.title) > 40 else paper.title + author_short = paper.authors[:20] + "..." if paper.authors and len(paper.authors) > 20 else (paper.authors or "N/A") + year_str = str(paper.year) if paper.year else "N/A" + papers_data.append([str(idx), title_short, author_short, year_str]) + + papers_table = Table(papers_data, colWidths=[0.4 * inch, 2.5 * inch, 1.5 * inch, 0.7 * inch]) + papers_table.setStyle( + TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2e5c8a")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 1, colors.grey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f5f5")]), + ]) + ) + elements.append(papers_table) + + # Footer + elements.append(Spacer(1, 0.3 * inch)) + footer_text = f"생성일: {datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y-%m-%d %H:%M:%S')} | Report Agent" + elements.append( + Paragraph( + footer_text, + ParagraphStyle( + "Footer", + parent=styles["Normal"], + fontSize=9, + textColor=colors.grey, + alignment=TA_CENTER, + ), + ) + ) + + # Build PDF + doc.build(elements) + pdf_bytes = buffer.getvalue() + buffer.close() + + logger.info(f"[ReportBuilder] PDF report built: {len(pdf_bytes)} bytes") + return pdf_bytes + + except Exception as e: + logger.error(f"[ReportBuilder] Error building PDF: {str(e)}", exc_info=True) + raise + + # ============================================================================ + # Helper Methods + # ============================================================================ + + @staticmethod + async def build_all_formats(report: ResearchReport) -> dict: + """ + 모든 포맷으로 보고서 생성 + + Args: + report: ResearchReport 객체 + + Returns: + { + "markdown": str, + "pdf": bytes + } + """ + try: + logger.info(f"[ReportBuilder] Building all formats for: {report.title}") + + markdown = await ReportBuilder.build_markdown(report) + pdf = await ReportBuilder.build_pdf(report) + + return { + "markdown": markdown, + "pdf": pdf, + } + + except Exception as e: + logger.error(f"[ReportBuilder] Error building all formats: {str(e)}") + raise + + @staticmethod + def get_file_extension(format_type: str) -> str: + """포맷에 해당하는 파일 확장자 반환""" + extensions = { + "markdown": "md", + "pdf": "pdf", + } + return extensions.get(format_type, "txt") + + @staticmethod + def get_mime_type(format_type: str) -> str: + """포맷에 해당하는 MIME 타입 반환""" + mime_types = { + "markdown": "text/markdown", + "pdf": "application/pdf", + } + return mime_types.get(format_type, "text/plain") diff --git a/backend/app/agents/report_agent/schemas.py b/backend/app/agents/report_agent/schemas.py new file mode 100644 index 0000000..7cde1de --- /dev/null +++ b/backend/app/agents/report_agent/schemas.py @@ -0,0 +1,140 @@ +""" +Report Agent Schemas +Input and output data models for research feasibility report generation +""" + +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field + + +class DocumentReference(BaseModel): + """Reference to a document used in analysis""" + id: Optional[int] = Field(None, description="Document ID") + title: str = Field(..., description="Document title") + authors: Optional[str] = Field(None, description="Document authors") + year: Optional[int] = Field(None, description="Publication year") + + +class ResearchTopicData(BaseModel): + """Research topic and related documents""" + topic: Optional[str] = Field(None, description="Research topic/hypothesis") + description: Optional[str] = Field(None, description="Detailed description") + analysis_goal: Optional[str] = Field(None, description="Analysis focus") + related_documents: List[DocumentReference] = Field( + default_factory=list, + description="Documents relevant to the topic" + ) + + class Config: + extra = "allow" # Allow extra fields + + +class ReportAgentRequest(BaseModel): + """Input schema for report generation""" + research_topic: str = Field( + ..., + min_length=1, + max_length=2000, + description="Research topic/hypothesis to validate" + ) + research_data: Optional[ResearchTopicData] = Field( + None, + description="Research data including topic and related documents" + ) + report_type: str = Field( + default="comprehensive", + description="Report type: comprehensive, summary, or detailed" + ) + include_visualizations: bool = Field( + default=False, + description="Include graphs and visualizations in report" + ) + include_network_graph: bool = Field( + default=False, + description="Include research evidence network graph" + ) + temperature: float = Field( + default=0.7, + ge=0.0, + le=2.0, + description="LLM temperature for creative analysis" + ) + max_tokens: int = Field( + default=2048, + ge=1000, + le=8192, + description="Maximum tokens in response" + ) + + class Config: + extra = "allow" # Allow extra fields + + +class ResearchValidation(BaseModel): + """Research feasibility validation result""" + is_feasible: bool = Field(..., description="Is research feasible?") + feasibility_score: float = Field( + ..., + ge=0.0, + le=100.0, + description="Feasibility score (0-100)" + ) + reasoning: str = Field(..., description="Explanation of feasibility assessment") + + +class ReportSection(BaseModel): + """A section within the report""" + title: str = Field(..., description="Section title") + content: str = Field(..., description="Section content") + citations: List[str] = Field( + default_factory=list, + description="References to documents" + ) + + +class ResearchReport(BaseModel): + """Comprehensive research feasibility report""" + title: str = Field(..., description="Report title") + research_topic: str = Field(..., description="Original research topic") + validation: ResearchValidation = Field( + ..., + description="Feasibility validation results" + ) + sections: List[ReportSection] = Field( + ..., + description="Report sections" + ) + evidence_summary: str = Field( + ..., + description="Summary of evidence from literature" + ) + recommendations: List[str] = Field( + ..., + description="Recommendations for research" + ) + limitations: List[str] = Field( + ..., + description="Limitations and considerations" + ) + related_papers: List[DocumentReference] = Field( + default_factory=list, + description="Papers referenced in report" + ) + + +class ReportAgentResponse(BaseModel): + """Output schema for report generation""" + report: ResearchReport = Field(..., description="Generated research report") + visualizations: Dict[str, str] = Field( + default_factory=dict, + description="Visualization HTML strings" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata" + ) + tokens_used: int = Field(default=0, description="Tokens consumed") + report_format: str = Field( + default="json", + description="Format: json, markdown, or docx" + ) diff --git a/backend/app/agents/report_agent/tools.py b/backend/app/agents/report_agent/tools.py new file mode 100644 index 0000000..ca7750f --- /dev/null +++ b/backend/app/agents/report_agent/tools.py @@ -0,0 +1,15 @@ +""" +Report Agent Tools +Re-exports utility classes for backward compatibility +DEPRECATED: Import directly from specific modules instead +""" + +from app.agents.report_agent.document_processor import DocumentProcessor +from app.agents.report_agent.data_normalizer import DataNormalizer +from app.agents.report_agent.llm_integration import LLMIntegration + +__all__ = [ + "DocumentProcessor", + "DataNormalizer", + "LLMIntegration", +] diff --git a/backend/app/agents/report_agent/visualizer.py b/backend/app/agents/report_agent/visualizer.py new file mode 100644 index 0000000..81a8eae --- /dev/null +++ b/backend/app/agents/report_agent/visualizer.py @@ -0,0 +1,619 @@ +""" +Report Agent Visualizer +Generate interactive visualizations for research reports +""" + +import logging +from typing import List, Dict, Any, Optional +from io import StringIO + +import plotly.graph_objects as go +import plotly.express as px +from plotly.subplots import make_subplots + +try: + import networkx as nx + from pyvis.network import Network +except ImportError: + nx = None + Network = None + +from app.agents.report_agent.schemas import ResearchReport, ResearchValidation, DocumentReference + +logger = logging.getLogger(__name__) + + +class Visualizer: + """시각화 엔진""" + + # ============================================================================ + # Evidence Network Graph (Pyvis) + # ============================================================================ + + @staticmethod + async def create_evidence_network( + report: ResearchReport, + output_file: Optional[str] = None + ) -> str: + """ + 증거 네트워크 그래프 생성 (Pyvis) + + 노드: 연구주제 + 논문들 + 엣지: 연관성 관계 + + Args: + report: ResearchReport 객체 + output_file: 저장할 HTML 파일 경로 (None이면 HTML 문자열 반환) + + Returns: + HTML 문자열 또는 파일 경로 + """ + try: + if Network is None: + logger.warning("[Visualizer] Pyvis not installed, returning placeholder") + return "

네트워크 그래프 생성 불가 (pyvis 미설치)

" + + logger.info(f"[Visualizer] Creating evidence network for: {report.research_topic}") + + # NetworkX 그래프 생성 + G = nx.Graph() + + # 중앙 노드: 연구주제 + research_node = "연구주제" + G.add_node(research_node, title=report.research_topic, color="#FF6B6B", size=30) + + # 논문 노드 추가 + for idx, paper in enumerate(report.related_papers, 1): + node_id = f"paper_{idx}" + title = f"{paper.title}\n({paper.authors or 'Unknown'}, {paper.year or 'N/A'})" + G.add_node(node_id, title=title, color="#4ECDC4", size=15) + + # 중앙 노드와 연결 + G.add_edge(research_node, node_id, weight=1) + + # 논문 간 연결 (유사성 기반) + num_papers = len(report.related_papers) + if num_papers > 1: + # 인접한 논문 연결 (간단한 네트워크) + for i in range(num_papers - 1): + for j in range(i + 1, min(i + 3, num_papers)): # 각 논문당 최대 3개 연결 + G.add_edge(f"paper_{i+1}", f"paper_{j+1}", weight=0.5) + + # Pyvis 네트워크 생성 + net = Network( + height="750px", + width="100%", + directed=False, + notebook=False, + cdn_resources="remote" + ) + + net.from_nx(G) + + # 물리 시뮬레이션 설정 + net.toggle_physics(True) + net.show_buttons(filter_=["physics"]) + + # HTML 생성 + if output_file: + net.show(output_file) + logger.info(f"[Visualizer] Network graph saved to: {output_file}") + return output_file + else: + # HTML 문자열로 반환 + html_string = net.generate_html() + logger.info(f"[Visualizer] Network graph generated: {len(html_string)} chars") + return html_string + + except Exception as e: + logger.error(f"[Visualizer] Error creating evidence network: {str(e)}") + raise + + # ============================================================================ + # Feasibility Score Chart (Plotly) + # ============================================================================ + + @staticmethod + async def create_feasibility_chart( + validation: ResearchValidation, + breakdown: Optional[Dict[str, float]] = None + ) -> str: + """ + 타당성 점수 시각화 (Gauge + Bar Chart) + + Args: + validation: ResearchValidation 객체 + breakdown: 세부 항목별 점수 {"선행연구": 80, "방법론": 70, ...} + + Returns: + Plotly HTML 문자열 + """ + try: + logger.info(f"[Visualizer] Creating feasibility chart: {validation.feasibility_score}") + + # 기본 breakdown이 없으면 생성 + if not breakdown: + breakdown = { + "선행연구": min(100, validation.feasibility_score + 10), + "방법론": validation.feasibility_score, + "실행가능성": max(0, validation.feasibility_score - 15), + "학술기여도": validation.feasibility_score, + } + + # Subplot 생성: Gauge + Bar Chart + fig = make_subplots( + rows=1, + cols=2, + subplot_titles=("타당성 종합 점수", "세부 항목별 점수"), + specs=[[{"type": "indicator"}, {"type": "bar"}]], + column_widths=[0.4, 0.6] + ) + + # 1. Gauge Chart (왼쪽) + fig.add_trace( + go.Indicator( + mode="gauge+number+delta", + value=validation.feasibility_score, + domain={"x": [0, 1], "y": [0, 1]}, + title={"text": "점수"}, + delta={"reference": 50}, + gauge={ + "axis": {"range": [0, 100]}, + "bar": {"color": "darkblue"}, + "steps": [ + {"range": [0, 25], "color": "#FF6B6B"}, # 빨강 (낮음) + {"range": [25, 50], "color": "#FFA94D"}, # 주황 (보통) + {"range": [50, 75], "color": "#74C0FC"}, # 파랑 (높음) + {"range": [75, 100], "color": "#51CF66"}, # 초록 (매우높음) + ], + "threshold": { + "line": {"color": "red", "width": 4}, + "thickness": 0.75, + "value": 90, + }, + }, + ), + row=1, + col=1, + ) + + # 2. Bar Chart (오른쪽) + items = list(breakdown.keys()) + scores = list(breakdown.values()) + colors = [ + "#51CF66" if s >= 75 else "#74C0FC" if s >= 50 else "#FFA94D" if s >= 25 else "#FF6B6B" + for s in scores + ] + + fig.add_trace( + go.Bar( + x=items, + y=scores, + marker={"color": colors}, + text=scores, + textposition="auto", + hovertemplate="%{x}
점수: %{y:.1f}/100", + ), + row=1, + col=2, + ) + + # 레이아웃 설정 (논문 figure 스타일) + fig.update_layout( + title={ + "text": "Figure 2. 연구 타당성 평가", + "font": {"size": 20, "family": "Arial, sans-serif", "color": "#2c3e50"}, + "x": 0.5, + "xanchor": "center" + }, + showlegend=False, + height=500, + hovermode="x unified", + template="plotly_white", + margin=dict(l=80, r=80, t=120, b=80), + font=dict(family="Arial, sans-serif", size=12, color="#2c3e50"), + ) + + fig.update_yaxes( + range=[0, 100], + title_font=dict(size=14), + tickfont=dict(size=12), + row=1, col=2 + ) + fig.update_xaxes( + title_font=dict(size=14), + tickfont=dict(size=11), + tickangle=-15, + row=1, col=2 + ) + + html_string = fig.to_html(include_plotlyjs="cdn") + logger.info(f"[Visualizer] Feasibility chart created: {len(html_string)} chars") + return html_string + + except Exception as e: + logger.error(f"[Visualizer] Error creating feasibility chart: {str(e)}") + raise + + # ============================================================================ + # Trend Chart (Plotly) + # ============================================================================ + + @staticmethod + async def create_trend_chart( + data: List[Dict[str, Any]], + title: str = "연구 동향", + x_axis: str = "year", + y_axis: str = "count" + ) -> str: + """ + 연구 동향 차트 생성 (Line + Area) + + Args: + data: 시계열 데이터 + [ + {"year": 2020, "count": 5}, + {"year": 2021, "count": 12}, + ... + ] + title: 차트 제목 + x_axis: X축 필드명 + y_axis: Y축 필드명 + + Returns: + Plotly HTML 문자열 + """ + try: + if not data: + logger.warning("[Visualizer] No trend data provided") + return "

트렌드 데이터가 없습니다.

" + + logger.info(f"[Visualizer] Creating trend chart: {title}") + + # 데이터 정렬 + sorted_data = sorted(data, key=lambda x: x.get(x_axis, 0)) + + x_values = [item.get(x_axis) for item in sorted_data] + y_values = [item.get(y_axis) for item in sorted_data] + + # Plotly 그래프 + fig = go.Figure() + + # Area Chart + fig.add_trace( + go.Scatter( + x=x_values, + y=y_values, + mode="lines+markers", + name="추세", + fill="tozeroy", + line={"color": "#4ECDC4", "width": 3}, + marker={"size": 8, "color": "#FF6B6B"}, + hovertemplate="%{x}
%{y}개", + ) + ) + + # 평균선 추가 + avg_y = sum(y_values) / len(y_values) + fig.add_hline( + y=avg_y, + line_dash="dash", + line_color="gray", + annotation_text=f"평균: {avg_y:.1f}", + annotation_position="right", + ) + + # 레이아웃 + fig.update_layout( + title=title, + xaxis_title=x_axis.capitalize(), + yaxis_title=y_axis.capitalize(), + height=400, + template="plotly_white", + hovermode="x unified", + ) + + html_string = fig.to_html(include_plotlyjs="cdn") + logger.info(f"[Visualizer] Trend chart created: {len(html_string)} chars") + return html_string + + except Exception as e: + logger.error(f"[Visualizer] Error creating trend chart: {str(e)}") + raise + + # ============================================================================ + # Paper Distribution Chart + # ============================================================================ + + @staticmethod + async def create_paper_distribution_chart( + papers: List[DocumentReference] + ) -> str: + """ + 논문 분포 차트 (연도별, 저자별) + + Args: + papers: DocumentReference 리스트 + + Returns: + Plotly HTML 문자열 + """ + try: + logger.info(f"[Visualizer] Creating paper distribution chart for {len(papers)} papers") + + if not papers: + return "

논문 데이터가 없습니다.

" + + # 연도별 논문 수 (Unknown 제외, AI Generated 문서는 연도 null) + year_counts = {} + for paper in papers: + # AI Generated 보고서는 연도를 포함하지 않음 + if paper.year and paper.year != "Unknown" and isinstance(paper.year, int): + year_counts[paper.year] = year_counts.get(paper.year, 0) + 1 + + # Plotly 그래프 + fig = make_subplots( + rows=1, + cols=2, + subplot_titles=("연도별 논문 분포", "주요 저자 분포 (Top 10)"), + horizontal_spacing=0.15, + specs=[[{"type": "bar"}, {"type": "bar"}]], + ) + + # 1. 연도별 (왼쪽) + if year_counts: + years = sorted(year_counts.keys()) + counts = [year_counts[y] for y in years] + + fig.add_trace( + go.Bar( + x=years, + y=counts, + marker={"color": "#4ECDC4", "line": {"width": 1, "color": "#3AAFA9"}}, + name="논문 수", + text=counts, + textposition="outside", + textfont={"size": 14, "family": "Arial, sans-serif"}, + hovertemplate="연도: %{x}
논문 수: %{y}개", + ), + row=1, + col=1, + ) + else: + # 데이터 없음 표시 + fig.add_annotation( + text="연도 정보가 없습니다", + xref="x", yref="y", + x=0.5, y=0.5, + showarrow=False, + row=1, col=1 + ) + + # 2. 저자별 (오른쪽) + author_counts = {} + for paper in papers: + if paper.authors and paper.authors != "AI Generated": + # 첫 번째 저자만 추출 + first_author = paper.authors.split(",")[0].strip() + if first_author and first_author != "Unknown": + author_counts[first_author] = author_counts.get(first_author, 0) + 1 + + if author_counts: + top_authors = sorted(author_counts.items(), key=lambda x: x[1], reverse=True)[:10] + authors = [a[0][:20] + "..." if len(a[0]) > 20 else a[0] for a in top_authors] # 이름 길이 제한 + author_cnts = [a[1] for a in top_authors] + + fig.add_trace( + go.Bar( + x=authors, + y=author_cnts, + marker={"color": "#FF6B6B", "line": {"width": 1, "color": "#E85D5D"}}, + name="논문 수", + text=author_cnts, + textposition="outside", + textfont={"size": 14, "family": "Arial, sans-serif"}, + hovertemplate="%{x}
논문 수: %{y}개", + ), + row=1, + col=2, + ) + else: + fig.add_annotation( + text="저자 정보가 없습니다", + xref="x2", yref="y2", + x=0.5, y=0.5, + showarrow=False, + row=1, col=2 + ) + + # 레이아웃 (논문 figure 스타일) + fig.update_layout( + title={ + "text": "Figure 3. 논문 분포 분석", + "font": {"size": 20, "family": "Arial, sans-serif", "color": "#2c3e50"}, + "x": 0.5, + "xanchor": "center" + }, + showlegend=False, + height=500, + template="plotly_white", + margin=dict(l=80, r=80, t=120, b=80), + font=dict(family="Arial, sans-serif", size=12, color="#2c3e50"), + ) + + fig.update_xaxes( + title_text="연도", + title_font=dict(size=14), + tickfont=dict(size=12), + row=1, col=1 + ) + fig.update_xaxes( + title_text="저자", + title_font=dict(size=14), + tickfont=dict(size=10), + tickangle=-45, + row=1, col=2 + ) + fig.update_yaxes( + title_text="논문 수", + title_font=dict(size=14), + tickfont=dict(size=12), + row=1, col=1 + ) + fig.update_yaxes( + title_text="논문 수", + title_font=dict(size=14), + tickfont=dict(size=12), + row=1, col=2 + ) + + html_string = fig.to_html(include_plotlyjs="cdn") + logger.info(f"[Visualizer] Paper distribution chart created: {len(html_string)} chars") + return html_string + + except Exception as e: + logger.error(f"[Visualizer] Error creating paper distribution chart: {str(e)}") + raise + + # ============================================================================ + # Comparison Chart (from LLM data) + # ============================================================================ + + @staticmethod + async def create_comparison_chart(comparison_data: Dict[str, Any]) -> str: + """ + 비교 분석 차트 생성 (LLM 데이터 기반) + + Args: + comparison_data: { + "labels": ["A", "B"], + "values": [85, 92], + "metric": "소거능 (%)" + } + + Returns: + HTML string + """ + try: + labels = comparison_data.get("labels", []) + values = comparison_data.get("values", []) + metric = comparison_data.get("metric", "값") + + if not labels or not values or len(labels) != len(values): + logger.warning(f"[Visualizer] Invalid comparison data") + return "

비교 데이터가 부족합니다

" + + logger.info(f"[Visualizer] Creating comparison chart: {labels}") + + fig = go.Figure(data=[ + go.Bar( + x=labels, + y=values, + text=values, + textposition='outside', + textfont=dict(size=14, family="Arial, sans-serif"), + marker=dict( + color=values, + colorscale='Viridis', + showscale=False, + line=dict(width=1, color="#34495e") + ), + hovertemplate="%{x}
" + metric + ": %{y}" + ) + ]) + + fig.update_layout( + title={ + "text": f"Figure 4. 비교 분석 - {metric}", + "font": {"size": 20, "family": "Arial, sans-serif", "color": "#2c3e50"}, + "x": 0.5, + "xanchor": "center" + }, + xaxis_title="항목", + yaxis_title=f"{metric}", + template="plotly_white", + height=450, + margin=dict(l=80, r=80, t=120, b=80), + font=dict(family="Arial, sans-serif", size=12, color="#2c3e50"), + xaxis=dict(title_font=dict(size=14), tickfont=dict(size=12)), + yaxis=dict(title_font=dict(size=14), tickfont=dict(size=12)) + ) + + html_string = fig.to_html( + include_plotlyjs='cdn', + div_id='comparison_chart' + ) + + return html_string + + except Exception as e: + logger.error(f"[Visualizer] Error creating comparison chart: {str(e)}") + raise + + # ============================================================================ + # All Visualizations Bundle + # ============================================================================ + + @staticmethod + async def create_all_visualizations( + report: ResearchReport, + viz_data: Optional[Dict[str, Any]] = None + ) -> Dict[str, str]: + """ + 모든 시각화 생성 + + Args: + report: ResearchReport 객체 + viz_data: LLM에서 추출한 시각화 데이터 (Optional) + + Returns: + { + "evidence_network": HTML, + "feasibility_chart": HTML, + "paper_distribution": HTML, + "comparison_chart": HTML (if data available) + } + """ + try: + logger.info(f"[Visualizer] Creating all visualizations for: {report.title}") + logger.info(f"[Visualizer] Visualization data provided: {bool(viz_data)}") + + visualizations = {} + + # 1. 증거 네트워크 + visualizations["evidence_network"] = await Visualizer.create_evidence_network(report) + + # 2. 타당성 점수 차트 (viz_data 사용 또는 기본값) + if viz_data and "feasibility_breakdown" in viz_data: + breakdown = viz_data["feasibility_breakdown"] + logger.info(f"[Visualizer] Using LLM-provided feasibility breakdown: {breakdown}") + else: + # 기본값 사용 + breakdown = { + "선행연구": min(100, report.validation.feasibility_score + 10), + "방법론": report.validation.feasibility_score, + "실행가능성": max(0, report.validation.feasibility_score - 15), + "학술기여도": report.validation.feasibility_score, + } + logger.warning(f"[Visualizer] No feasibility breakdown in viz_data, using defaults") + + visualizations["feasibility_chart"] = await Visualizer.create_feasibility_chart( + report.validation, + breakdown + ) + + # 3. 비교 차트 (viz_data에 comparison_data가 있는 경우) + if viz_data and "comparison_data" in viz_data: + comparison_data = viz_data["comparison_data"] + if "labels" in comparison_data and "values" in comparison_data: + visualizations["comparison_chart"] = await Visualizer.create_comparison_chart( + comparison_data + ) + logger.info(f"[Visualizer] Created comparison chart") + + logger.info(f"[Visualizer] All visualizations created successfully: {list(visualizations.keys())}") + return visualizations + + except Exception as e: + logger.error(f"[Visualizer] Error creating all visualizations: {str(e)}", exc_info=True) + raise diff --git a/backend/app/agents/search_agent/advanced_filter.py b/backend/app/agents/search_agent/advanced_filter.py new file mode 100644 index 0000000..7d9b7f9 --- /dev/null +++ b/backend/app/agents/search_agent/advanced_filter.py @@ -0,0 +1,205 @@ +""" +Advanced Search Agent - Enhanced Filtering and Selection +추가된 기능: +1. Adaptive cutoff (엘보우/갭 방법으로 K 자동 결정) +2. 다양성 선택 (MMR - Maximal Marginal Relevance) +3. 신뢰성 게이트 (실험 조건/수치 명시 여부, preprint 감지) +4. 3축 평가 (관련성, 다양성, 신뢰성) +""" + +import numpy as np +from typing import List, Dict, Any, Tuple, Optional +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity +import re +from datetime import datetime, timedelta + +class AdvancedPaperFilter: + def __init__(self): + self.vectorizer = TfidfVectorizer(max_features=1000, stop_words='english') + + def find_adaptive_cutoff(self, scores: List[float]) -> Tuple[float, int]: + """ + 엘보우 방법으로 최적 cutoff 찾기 + Returns: (cutoff_score, selected_count) + """ + if len(scores) < 3: + return 0.7, len(scores) + + # Sort scores in descending order + sorted_scores = sorted(scores, reverse=True) + + # Calculate gaps between consecutive scores + gaps = [sorted_scores[i] - sorted_scores[i+1] for i in range(len(sorted_scores)-1)] + + if not gaps: + return sorted_scores[0] * 0.8, 1 + + # Find the largest gap (elbow point) + max_gap_idx = np.argmax(gaps) + + # Cutoff is the score after the largest gap + cutoff_score = sorted_scores[max_gap_idx + 1] + selected_count = max_gap_idx + 1 + + # Ensure minimum quality threshold + min_threshold = 0.6 + if cutoff_score < min_threshold: + cutoff_score = min_threshold + selected_count = sum(1 for score in sorted_scores if score >= min_threshold) + + return cutoff_score, min(selected_count, 10) # Cap at 10 + + def calculate_mmr_selection(self, papers: List[Dict], lambda_param: float = 0.7) -> List[Dict]: + """ + MMR (Maximal Marginal Relevance) 다양성 선택 + lambda_param: 관련성 vs 다양성 균형 (1.0=관련성만, 0.0=다양성만) + """ + if len(papers) <= 1: + return papers + + # Extract text for similarity calculation + texts = [f"{paper['title']} {paper['abstract'][:500]}" for paper in papers] + + try: + # Calculate TF-IDF vectors + tfidf_matrix = self.vectorizer.fit_transform(texts) + similarity_matrix = cosine_similarity(tfidf_matrix) + except: + # Fallback: just return by relevance + return sorted(papers, key=lambda x: x.get('relevance_score', 0), reverse=True) + + selected = [] + remaining = list(range(len(papers))) + + # Select first paper (highest relevance) + best_idx = max(remaining, key=lambda i: papers[i].get('relevance_score', 0)) + selected.append(best_idx) + remaining.remove(best_idx) + + # Iteratively select diverse papers + while remaining and len(selected) < min(8, len(papers)): + mmr_scores = [] + + for i in remaining: + # Relevance component + relevance = papers[i].get('relevance_score', 0) + + # Diversity component (negative similarity to selected papers) + max_similarity = max(similarity_matrix[i][j] for j in selected) + diversity = 1 - max_similarity + + # MMR score + mmr_score = lambda_param * relevance + (1 - lambda_param) * diversity + mmr_scores.append((mmr_score, i)) + + # Select paper with highest MMR score + _, best_idx = max(mmr_scores) + selected.append(best_idx) + remaining.remove(best_idx) + + return [papers[i] for i in selected] + + def assess_reliability(self, paper: Dict) -> Dict[str, Any]: + """ + 신뢰성 평가 게이트 + Returns: {"reliability_score": float, "flags": [], "metadata": {}} + """ + title = paper.get('title', '') + abstract = paper.get('abstract', '') + text = f"{title} {abstract}".lower() + + reliability_score = 0.5 # Base score + flags = [] + metadata = {} + + # 1. 실험 조건/수치 존재 여부 + experimental_indicators = [ + r'\d+\s*(mg/kg|μm|nm|mm|cm)', # 용량/농도 + r'n\s*=\s*\d+', # 샘플 수 + r'p\s*[<>=]\s*0\.\d+', # p-value + r'\d+\s*(days?|hours?|weeks?)', # 시간 + r'(ic50|ec50|ld50)', # IC50 등 + r'(control|treatment|placebo)', # 실험 디자인 + ] + + numeric_evidence = sum(1 for pattern in experimental_indicators + if re.search(pattern, text)) + + if numeric_evidence >= 3: + reliability_score += 0.3 + metadata['experimental_evidence'] = 'high' + elif numeric_evidence >= 1: + reliability_score += 0.1 + metadata['experimental_evidence'] = 'medium' + else: + flags.append('limited_experimental_data') + metadata['experimental_evidence'] = 'low' + + # 2. Preprint 감지 + if re.search(r'(preprint|biorxiv|medrxiv|arxiv)', text): + reliability_score -= 0.2 + flags.append('preprint') + metadata['publication_status'] = 'preprint' + else: + metadata['publication_status'] = 'peer_reviewed' + + # 3. Review paper 감지 (일반적으로 더 신뢰도 높음) + if re.search(r'(review|systematic|meta-analysis)', text): + reliability_score += 0.2 + metadata['paper_type'] = 'review' + + # 4. 방법론 명시 여부 + method_keywords = [ + 'western blot', 'pcr', 'elisa', 'immunofluorescence', + 'rna-seq', 'microarray', 'qrt-pcr', 'flow cytometry' + ] + + methods_found = [method for method in method_keywords if method in text] + if methods_found: + reliability_score += min(0.2, len(methods_found) * 0.05) + metadata['methods_identified'] = methods_found + + # 5. 발표 날짜 기반 평가 + try: + pub_date = paper.get('published_date', '') + if pub_date: + # 너무 오래된 논문 (5년 이상)은 약간 감점 + pub_year = int(pub_date[:4]) + current_year = datetime.now().year + if current_year - pub_year > 5: + reliability_score -= 0.1 + flags.append('older_publication') + except: + pass + + # Score normalization + reliability_score = max(0.0, min(1.0, reliability_score)) + + return { + 'reliability_score': reliability_score, + 'flags': flags, + 'metadata': metadata + } + + def calculate_diversity_score(self, paper: Dict, selected_papers: List[Dict]) -> float: + """ + 기선택 논문들 대비 다양성 점수 계산 + """ + if not selected_papers: + return 1.0 + + current_text = f"{paper['title']} {paper['abstract'][:500]}" + selected_texts = [f"{p['title']} {p['abstract'][:500]}" for p in selected_papers] + + try: + all_texts = [current_text] + selected_texts + tfidf_matrix = self.vectorizer.fit_transform(all_texts) + similarities = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:]).flatten() + + # Diversity = 1 - max_similarity + max_similarity = np.max(similarities) if len(similarities) > 0 else 0 + return 1.0 - max_similarity + + except: + return 0.5 # Fallback \ No newline at end of file diff --git a/backend/app/agents/search_agent/agent.py b/backend/app/agents/search_agent/agent.py index 0e26004..47afb7a 100644 --- a/backend/app/agents/search_agent/agent.py +++ b/backend/app/agents/search_agent/agent.py @@ -18,10 +18,11 @@ from app.agents.search_agent.schemas import SearchAgentRequest, SearchAgentResponse, PaperInfo from app.agents.search_agent.prompt import ( SEARCH_QUERY_GENERATION_PROMPT, - RELEVANCE_EVALUATION_PROMPT, + ENHANCED_RELEVANCE_EVALUATION_PROMPT, REQUESTED_COUNT_EXTRACTION_PROMPT, DEFAULT_MAX_RESULTS, ) +from app.agents.search_agent.advanced_filter import AdvancedPaperFilter from app.agents.search_agent.arxiv_search import search_arxiv from app.agents.search_agent.pdf_download import download_pdfs from app.services.llm_service import get_llm_service @@ -34,16 +35,19 @@ class SearchAgent(BaseAgent): """ Search Agent (Orchestrator) Coordinates arXiv paper search, relevance filtering, and PDF download + Enhanced with adaptive cutoff, diversity selection, and reliability gates """ - def __init__(self, db: AsyncSession = None): + def __init__(self, db: AsyncSession = None, background_tasks: object = None): """Initialize search agent""" super().__init__() self.agent_type = "search_agent" self.llm_service = get_llm_service() self.db = db + self.background_tasks = background_tasks self.uploads_dir = Path("/app/uploads") # Docker container path self.uploads_dir.mkdir(parents=True, exist_ok=True) + self.advanced_filter = AdvancedPaperFilter() # 새로운 고급 필터 async def execute(self, request: SearchAgentRequest) -> SearchAgentResponse: """ @@ -97,8 +101,8 @@ async def execute(self, request: SearchAgentRequest) -> SearchAgentResponse: metadata={"message": "No papers found for this query"} ) - # Step 3: Filter by relevance - filtered_papers = await self._filter_by_relevance( + # Step 3: Enhanced filtering with 3-axis evaluation (Relevance, Diversity, Reliability) + filtered_papers = await self._enhanced_filter_papers( papers, request.content, request.analysis_goal, @@ -114,7 +118,8 @@ async def execute(self, request: SearchAgentRequest) -> SearchAgentResponse: request.session_id, request.user_id, self.uploads_dir, - self.db + self.db, + self.background_tasks ) logger.info(f"[SearchAgent] Downloaded {len(download_results['paths'])} PDFs") @@ -267,3 +272,134 @@ async def _filter_by_relevance( # Sort by relevance score filtered.sort(key=lambda x: x.relevance_score, reverse=True) return filtered[:max_results] + + async def _enhanced_filter_papers( + self, + papers: List[Dict], + content: str, + analysis_goal: Optional[str], + min_score: float, + max_results: int, + existing_arxiv_ids: set + ) -> List[PaperInfo]: + """ + Enhanced paper filtering with 3-axis evaluation: + 1. Relevance (기존 + 향상) + 2. Diversity (MMR을 통한 다양성 확보) + 3. Reliability (신뢰성 게이트) + """ + logger.info(f"[SearchAgent] Starting enhanced filtering for {len(papers)} papers") + + # Phase 1: Initial relevance and reliability assessment + evaluated_papers = [] + + for paper in papers: + try: + # Skip duplicates + if paper["arxiv_id"] in existing_arxiv_ids: + logger.info(f"[SearchAgent] Skipping duplicate: {paper['arxiv_id']}") + continue + + # Enhanced LLM evaluation + prompt = ENHANCED_RELEVANCE_EVALUATION_PROMPT.format( + content=content, + analysis_goal=analysis_goal or "General research", + title=paper["title"], + abstract=paper["abstract"][:1200] # Longer abstract for better evaluation + ) + + response = await self.llm_service.generate( + messages=[{"role": "user", "content": prompt}], + system_prompt="You are an expert biomedical research evaluator.", + temperature=0.2, # Lower temperature for more consistent evaluation + max_tokens=300 + ) + + # Parse enhanced response + result = json.loads(response["content"].strip()) + relevance_score = float(result.get("relevance_score", 0.0)) + + # Rule-based reliability assessment + reliability_assessment = self.advanced_filter.assess_reliability(paper) + + # Combine LLM insights with rule-based assessment + llm_reliability = result.get("reliability_indicators", {}) + final_reliability = ( + reliability_assessment["reliability_score"] * 0.7 + + (sum(llm_reliability.values()) / len(llm_reliability) if llm_reliability else 0.5) * 0.3 + ) + + # Calculate composite score (weighted average) + composite_score = ( + relevance_score * 0.6 + # 60% relevance + final_reliability * 0.3 + # 30% reliability + 0.1 # 10% base diversity (will be recalculated later) + ) + + logger.info(f"[SearchAgent] '{paper['title'][:50]}...' - R:{relevance_score:.2f}, Rel:{final_reliability:.2f}, Comp:{composite_score:.2f}") + + if relevance_score >= min_score: # Basic relevance threshold + paper_info = { + "title": paper["title"], + "authors": paper["authors"], + "abstract": paper["abstract"], + "arxiv_id": paper["arxiv_id"], + "pdf_url": paper["pdf_url"], + "published_date": paper["published_date"], + "relevance_score": relevance_score, + "reliability_score": final_reliability, + "composite_score": composite_score, + "reliability_flags": reliability_assessment["flags"], + "coverage_aspects": result.get("coverage_aspects", []), + "metadata": reliability_assessment["metadata"] + } + evaluated_papers.append(paper_info) + + except Exception as e: + logger.warning(f"[SearchAgent] Enhanced evaluation failed for paper: {str(e)}") + continue + + if not evaluated_papers: + return [] + + # Phase 2: Adaptive cutoff determination + relevance_scores = [p["relevance_score"] for p in evaluated_papers] + adaptive_cutoff, suggested_count = self.advanced_filter.find_adaptive_cutoff(relevance_scores) + + logger.info(f"[SearchAgent] Adaptive cutoff: {adaptive_cutoff:.2f}, suggested count: {suggested_count}") + + # Apply adaptive cutoff + high_quality_papers = [p for p in evaluated_papers if p["relevance_score"] >= adaptive_cutoff] + + # Phase 3: Diversity-aware selection (MMR) + if len(high_quality_papers) > max_results: + diverse_papers = self.advanced_filter.calculate_mmr_selection( + high_quality_papers, + lambda_param=0.7 # Balance: 70% relevance, 30% diversity + ) + else: + diverse_papers = high_quality_papers + + # Convert to PaperInfo objects + final_papers = [] + for paper in diverse_papers[:max_results]: + # Add preprint warning to title if needed + title = paper["title"] + if "preprint" in paper["reliability_flags"]: + title = f"[PREPRINT] {title}" + + paper_info = PaperInfo( + title=title, + authors=paper["authors"], + abstract=paper["abstract"], + arxiv_id=paper["arxiv_id"], + pdf_url=paper["pdf_url"], + published_date=paper["published_date"], + relevance_score=paper["relevance_score"], + # Store additional metadata in a way that's accessible + # You may need to extend PaperInfo schema to include these fields + ) + final_papers.append(paper_info) + + logger.info(f"[SearchAgent] Final selection: {len(final_papers)} diverse, high-quality papers") + return final_papers diff --git a/backend/app/agents/search_agent/pdf_download.py b/backend/app/agents/search_agent/pdf_download.py index 6cfef93..3979044 100644 --- a/backend/app/agents/search_agent/pdf_download.py +++ b/backend/app/agents/search_agent/pdf_download.py @@ -6,7 +6,7 @@ import logging import urllib.request from pathlib import Path -from typing import List, Dict +from typing import List, Dict, Optional, Callable from datetime import datetime from app.agents.search_agent.schemas import PaperInfo @@ -21,7 +21,8 @@ async def download_pdfs( session_id: int, user_id: int, uploads_dir: Path, - db: AsyncSession = None + db: AsyncSession = None, + background_tasks: Optional[object] = None, ) -> Dict[str, List]: """ Download PDFs to session-specific directory and register in DB @@ -98,6 +99,18 @@ async def download_pdfs( try: await db.commit() logger.info(f"[PDFDownload] Committed {len(document_ids)} documents to DB") + + # Schedule auto-indexing for each downloaded document + if background_tasks: + from app.api.v1.documents import auto_index_document + for doc_id in document_ids: + background_tasks.add_task( + auto_index_document, + document_id=doc_id, + user_id=user_id + ) + logger.info(f"[PDFDownload] Scheduled auto-indexing for {len(document_ids)} documents") + except Exception as e: logger.error(f"[PDFDownload] DB commit failed: {str(e)}") await db.rollback() diff --git a/backend/app/agents/search_agent/prompt.py b/backend/app/agents/search_agent/prompt.py index 6fb7a03..2bd43bb 100644 --- a/backend/app/agents/search_agent/prompt.py +++ b/backend/app/agents/search_agent/prompt.py @@ -9,21 +9,36 @@ User's Question: {content} Analysis Goal: {analysis_goal} -Task: Generate a concise, effective arXiv search query (2-10 keywords) that will find relevant academic papers. - -Guidelines: -- Focus on technical terms and key concepts -- Use academic/scientific terminology -- Avoid common words and articles -- Keep it focused and specific - -Respond with ONLY the search query, no explanations. +Task: Generate a concise, effective arXiv search query (2-6 core keywords) optimized for academic paper search. + +CRITICAL RULES for Academic Paper Search: +1. Use SIMPLE, CONCRETE terms that actually appear in paper titles/abstracts +2. AVOID meta-analysis terms: "compare", "comparison", "safety profile", "evaluation", "review" +3. Use SPECIFIC scientific terminology: + - For toxicity: "toxicity", "liver injury", "hepatotoxicity", "adverse events", "ALT", "AST" + - For mechanisms: "mechanism", "pathway", "binding", "inhibition" + - For drug names: use exact compound names or generic names +4. Break down complex questions into their CORE CONCEPTS only +5. If question asks for "comparison", just include the main entities (e.g., "HER2 inhibitor hepatotoxicity" NOT "compare HER2 inhibitors") + +BAD Examples (too ambitious, won't find papers): +❌ "HER2 inhibitors" AND "hepatotoxicity" COMPARE "approved therapies" +❌ "SAFETY PROFILES" "neratinib" "tucatinib" +❌ "compare efficacy" "immunotherapy" "chemotherapy" + +GOOD Examples (simple, concrete terms): +✅ HER2 inhibitor hepatotoxicity +✅ neratinib liver toxicity +✅ immunotherapy melanoma +✅ CRISPR gene editing safety + +Respond with ONLY the search query (2-6 keywords), no explanations or quotes. Search Query:""" -# Prompt for evaluating paper relevance -RELEVANCE_EVALUATION_PROMPT = """You are an expert at evaluating research paper relevance. +# Enhanced prompt for comprehensive paper evaluation +ENHANCED_RELEVANCE_EVALUATION_PROMPT = """You are an expert research evaluator specializing in biomedical literature. User's Research Interest: - Question: {content} @@ -33,12 +48,26 @@ - Title: {title} - Abstract: {abstract} -Task: Evaluate how relevant this paper is to the user's research interest. +Task: Evaluate this paper across THREE dimensions: -Respond with ONLY a JSON object in this exact format: -{{"relevance_score": 0.85, "reason": "brief explanation"}} +1. RELEVANCE: How directly related is this paper to the user's research question? +2. RELIABILITY: How trustworthy and well-documented is this research? +3. COVERAGE: What specific aspects of the research question does this paper address? -The relevance_score should be between 0.0 (not relevant) and 1.0 (highly relevant).""" +Respond with ONLY a JSON object in this exact format: +{{ + "relevance_score": 0.85, + "reliability_indicators": {{ + "has_experimental_data": true, + "has_numerical_results": true, + "methodology_clear": true, + "is_preprint": false + }}, + "coverage_aspects": ["in_vitro", "dose_response", "mechanism"], + "overall_reason": "brief explanation" +}} + +Scores should be between 0.0 and 1.0.""" # Prompt for extracting requested paper count from user input diff --git a/backend/app/agents/search_agent/search_agent_content.md b/backend/app/agents/search_agent/search_agent_content.md new file mode 100644 index 0000000..2e97f15 --- /dev/null +++ b/backend/app/agents/search_agent/search_agent_content.md @@ -0,0 +1,84 @@ +# 검색 에이전트 + +## 개요 + +검색 에이전트는 논문 검색 워크플로우를 조율하며, 쿼리 생성부터 PDF 다운로드까지의 과정을 담당합니다. + +## 작동 방식 + +1. **검색 쿼리 생성**: 사용자 쿼리와 분석 목표를 LLM을 사용하여 arXiv 검색 쿼리로 변환합니다. +2. **arXiv 검색**: arXiv 검색 API를 사용하여 관련 논문을 찾습니다. +3. **결과 필터링**: LLM을 사용하여 논문의 관련성을 필터링합니다. +4. **PDF 다운로드**: 관련 논문의 PDF를 다운로드합니다. + +## 사용 기술 및 도구 + +- **arXiv 검색 API**: 관련 논문 검색. +- **LLM 서비스**: 쿼리 생성 및 관련성 필터링. +- **PDF 다운로드**: 논문 PDF 다운로드. +- **SQLAlchemy**: 데이터베이스 상호작용. + +## 주요 구성 요소 + +- **스키마(Schemas)**: 요청 및 응답 구조 정의. +- **프롬프트(Prompts)**: 쿼리 생성 및 관련성 평가를 위한 프롬프트 포함. +- **arXiv 검색**: arXiv에서 논문 검색 처리. +- **PDF 다운로드**: PDF 다운로드 관리. + +## 주요 파일 + +- `agent.py`: 검색 에이전트의 주요 구현 파일. + +## 평가 방법 + +### 결과물 평가 기준 + +1. **검색 품질 (Search Quality)** + - 사용자 질문에서 생성한 arXiv 쿼리가 적절한가? + - 검색된 논문이 연구 주제와 관련이 있는가? + - 평가 지표: 쿼리 품질, 검색 결과 수 + +2. **관련성 필터링 (Relevance Filtering)** + - LLM 기반 관련성 평가가 정확한가? + - 규칙 기반 신뢰도 평가가 적절한가? + - 평가 지표: 관련성 점수 (0-100), 신뢰도 점수 + +3. **적응형 컬오프 (Adaptive Cutoff)** + - 검색 결과의 수준에 따라 동적으로 컬오프 조정 + - 평균보다 높은 품질의 논문만 선택 + - 평가 지표: 선택률, 품질 분포 + +4. **종합 평가 (Combined Scoring)** + - LLM 관련성 (30%) + 규칙 기반 신뢰도 (70%) + - 최종 점수 = 0.3 _ relevance + 0.7 _ reliability + - 평가 지표: 최종 점수, 필터링 정확도 + +5. **PDF 다운로드 성공률 (Download Success Rate)** + - 선택된 논문의 PDF를 성공적으로 다운로드했는가? + - 평가 지표: 다운로드 성공률 + +### 평가 프로세스 + +```python +# 평가 예시 +paper_evaluation = { + "relevance_score": 85.0, # LLM 관련성 점수 + "reliability_score": 92.0, # 규칙 기반 신뢰도 + "combined_score": 89.9, # 종합 점수 (0.3*85 + 0.7*92) + "reliability_flags": [], # 신뢰도 경고 + "passed_filter": True # 필터 통과 여부 +} + +result = { + "total_found": 50, # 총 검색 결과 + "high_quality": 12, # 고품질 논문 + "download_success": 10, # 다운로드 성공 + "success_rate": 0.83 # 10/12 = 83% +} +``` + +### 품질 기준 + +- **후수 (>80)**: 높은 관련성과 신뢰도 +- **양호 (60-80)**: 적절한 관련성, 부분적 신뢰도 +- **개선 필요 (<60)**: 낮은 관련성 또는 신뢰도 문제 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 3957618..3e6076e 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -9,12 +9,13 @@ - /agents/general/* - General chat agent endpoints - /agents/search - Search agent endpoints - /agents/analysis - Analysis agent endpoints +- /agents/report/* - Report agent endpoints """ from fastapi import APIRouter from app.api.v1 import auth, documents, sessions -from app.api.v1.agents import embedding_router, general_router, search_router, analysis_router +from app.api.v1.agents import embedding_router, general_router, search_router, analysis_router, report_router # Create main v1 router router = APIRouter() @@ -33,3 +34,4 @@ router.include_router(general_router, prefix="/agents") router.include_router(search_router, prefix="/agents") router.include_router(analysis_router, prefix="/agents") +router.include_router(report_router, prefix="/agents") diff --git a/backend/app/api/v1/agents/__init__.py b/backend/app/api/v1/agents/__init__.py index 5e80590..30630ae 100644 --- a/backend/app/api/v1/agents/__init__.py +++ b/backend/app/api/v1/agents/__init__.py @@ -6,11 +6,13 @@ - /agents/general/* - GeneralChatAgent routes - /agents/search - SearchAgent routes - /agents/analysis - AnalysisAgent routes +- /agents/report/* - ReportAgent routes """ from .embedding import router as embedding_router from .general import router as general_router from .search import router as search_router from .analysis import router as analysis_router +from .report import router as report_router -__all__ = ["embedding_router", "general_router", "search_router", "analysis_router"] +__all__ = ["embedding_router", "general_router", "search_router", "analysis_router", "report_router"] diff --git a/backend/app/api/v1/agents/embedding.py b/backend/app/api/v1/agents/embedding.py index 7a12545..0ed607a 100644 --- a/backend/app/api/v1/agents/embedding.py +++ b/backend/app/api/v1/agents/embedding.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.agents.embedding_agent.agent import EmbeddingAgent +from app.agents.embedding_agent.schemas import EmbeddingAgentInputSchema from app.api.deps import get_current_user from app.db.database import get_db_session from app.db.models import Document @@ -82,8 +83,12 @@ async def analyze_pdf( # Initialize EmbeddingAgent agent = EmbeddingAgent(db, embedding_service) - # Process the PDF (includes text extraction, chunking, embedding, and summary generation) - file_path = document.file_path - response = await agent.process_pdf(document_id, file_path) + # Process the PDF using execute method + request = EmbeddingAgentInputSchema( + document_id=document_id, + chunk_size=512 + ) + + response = await agent.execute(request) return response diff --git a/backend/app/api/v1/agents/report.py b/backend/app/api/v1/agents/report.py new file mode 100644 index 0000000..6db15ab --- /dev/null +++ b/backend/app/api/v1/agents/report.py @@ -0,0 +1,318 @@ +""" +Report API Router +Handles API endpoints for report generation +""" + +import logging +from typing import Optional, Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.database import get_db_session +from app.agents.report_agent.schemas import ( + ReportAgentRequest, + ReportAgentResponse, +) +from app.services.report_service import get_report_service + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/report", + tags=["report_agent"], +) + +# ============================================================================ +# Report Generation Endpoints +# ============================================================================ + + +@router.post("/generate", response_model=ReportAgentResponse) +async def generate_report( + request: ReportAgentRequest, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db_session)], + session_id: Optional[str] = Query(None), +) -> ReportAgentResponse: + """ + Generate a research feasibility report + + Args: + request: Report generation request with research topic and optional parameters + current_user: Authenticated user + session_id: Optional chat session ID + db: Database session + + Returns: + ReportAgentResponse with generated report and metadata + + Raises: + HTTPException: If generation fails + """ + try: + user_id = current_user["user_id"] + logger.info( + f"[ReportRouter] Report generation request from user {user_id}, " + f"topic: {request.research_topic}" + ) + + report_service = get_report_service() + + # Generate report + session_id_int = int(session_id) if session_id else user_id + + response = await report_service.generate_report( + user_id=user_id, + session_id=session_id_int, + research_topic=request.research_topic, + research_description=request.research_data.description if request.research_data else None, + analysis_goal=request.research_data.analysis_goal if request.research_data else None, + documents=[doc.dict() for doc in request.research_data.related_documents] + if request.research_data and request.research_data.related_documents else None, + include_visualizations=request.include_visualizations, + report_type=request.report_type, + temperature=request.temperature, + max_tokens=request.max_tokens, + db=db, + ) + + logger.info(f"[ReportRouter] Report generated successfully") + return response + + except ValueError as e: + logger.error(f"[ReportRouter] Validation error: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"[ReportRouter] Error generating report: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail="Failed to generate report. Please try again later.", + ) + + +@router.get("/history") +async def get_report_history( + current_user: Annotated[dict, Depends(get_current_user)], + session_id: Optional[str] = Query(None), + limit: int = Query(10, ge=1, le=100), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """ + Get user's report generation history + + Args: + current_user: Authenticated user + session_id: Optional session ID filter + limit: Maximum number of reports to retrieve (1-100) + db: Database session + + Returns: + List of report metadata + + Raises: + HTTPException: If retrieval fails + """ + try: + user_id = current_user["user_id"] + logger.info( + f"[ReportRouter] Retrieving report history for user {user_id}" + ) + + report_service = get_report_service() + + history = await report_service.get_report_history( + user_id=user_id, + session_id=int(session_id) if session_id else None, + db=db, + limit=limit, + ) + + return { + "user_id": user_id, + "history": history, + "count": len(history), + } + + except Exception as e: + logger.error( + f"[ReportRouter] Error retrieving report history: {str(e)}", + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail="Failed to retrieve report history.", + ) + + +@router.delete("/delete/{report_id}") +async def delete_report( + report_id: str, + current_user: Annotated[dict, Depends(get_current_user)], + db: AsyncSession = Depends(get_db_session), +) -> dict: + """ + Delete a generated report + + Args: + report_id: Report ID to delete + current_user: Authenticated user + db: Database session + + Returns: + Success message + + Raises: + HTTPException: If deletion fails or unauthorized + """ + try: + user_id = current_user["user_id"] + logger.info( + f"[ReportRouter] Delete report request from user {user_id}, " + f"report_id: {report_id}" + ) + + report_service = get_report_service() + + success = await report_service.delete_report( + report_id=report_id, + user_id=user_id, + db=db, + ) + + return { + "success": success, + "report_id": report_id, + "message": "Report deleted successfully", + } + + except ValueError as e: + logger.error(f"[ReportRouter] Validation error: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + f"[ReportRouter] Error deleting report: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=500, + detail="Failed to delete report.", + ) + + +@router.get("/download/{report_id}") +async def download_report( + report_id: str, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db_session)], + format: str = Query("markdown", pattern="^(markdown|pdf|json)$"), +) -> dict: + """ + Download a generated report in specified format + + Args: + report_id: Report ID to download + format: Download format (markdown, pdf, json) + current_user: Authenticated user + db: Database session + + Returns: + Report content with appropriate media type + + Raises: + HTTPException: If download fails or unauthorized + """ + try: + user_id = current_user["user_id"] + logger.info( + f"[ReportRouter] Download report request from user {user_id}, " + f"report_id: {report_id}, format: {format}" + ) + + report_service = get_report_service() + + # TODO: Implement report download + # Would retrieve report from database and return in requested format + + return { + "report_id": report_id, + "format": format, + "url": f"/downloads/{report_id}.{format}", + "message": "Download link generated", + } + + except ValueError as e: + logger.error(f"[ReportRouter] Validation error: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + f"[ReportRouter] Error downloading report: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=500, + detail="Failed to download report.", + ) + + +# ============================================================================ +# Analysis Endpoints +# ============================================================================ + + +@router.post("/analyze") +async def quick_analysis( + topic: str, + current_user: Annotated[dict, Depends(get_current_user)], + db: AsyncSession = Depends(get_db_session), +) -> dict: + """ + Quick analysis without full report generation + Useful for quick topic feasibility assessment + + Args: + topic: Research topic to analyze + current_user: Authenticated user + db: Database session + + Returns: + Quick analysis result + + Raises: + HTTPException: If analysis fails + """ + try: + user_id = current_user["user_id"] + logger.info( + f"[ReportRouter] Quick analysis request from user {user_id}, " + f"topic: {topic}" + ) + + report_service = get_report_service() + + response = await report_service.generate_report( + user_id=user_id, + session_id=user_id, + research_topic=topic, + documents=None, + include_visualizations=False, + report_type="json", + db=db, + ) + + return { + "topic": topic, + "analysis": response.report.validation.reasoning, + "feasibility_score": response.report.validation.feasibility_score, + "is_feasible": response.report.validation.is_feasible, + } + + except Exception as e: + logger.error( + f"[ReportRouter] Error in quick analysis: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=500, + detail="Failed to perform analysis.", + ) + + diff --git a/backend/app/api/v1/agents/search.py b/backend/app/api/v1/agents/search.py index e796150..e69c7fa 100644 --- a/backend/app/api/v1/agents/search.py +++ b/backend/app/api/v1/agents/search.py @@ -9,7 +9,7 @@ from typing import Annotated from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from app.agents.search_agent import SearchAgent, SearchAgentRequest, SearchAgentResponse @@ -35,6 +35,7 @@ ) async def search_papers( request: SearchAgentRequest, + background_tasks: BackgroundTasks, current_user: Annotated[dict, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db_session)], ) -> SearchAgentResponse: @@ -78,7 +79,7 @@ async def search_papers( logger.info(f"[SearchAPI] Saved user message ID: {user_message.id}") # Execute search - agent = SearchAgent(db=db) + agent = SearchAgent(db=db, background_tasks=background_tasks) response = await agent.execute(request) if not response.success: diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py index 2951b13..12d07b3 100644 --- a/backend/app/api/v1/documents.py +++ b/backend/app/api/v1/documents.py @@ -8,9 +8,10 @@ - DELETE /api/v1/documents/{doc_id} - Delete document """ +import logging from typing import Annotated -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status, BackgroundTasks from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -23,6 +24,11 @@ DocumentUploadRequest, ) from app.services.document_service import DocumentService +from app.agents.embedding_agent.agent import EmbeddingAgent +from app.agents.embedding_agent.schemas import EmbeddingAgentInputSchema +from app.services.embedding_service import get_embedding_service + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/documents", tags=["documents"]) @@ -41,6 +47,7 @@ }, ) async def upload_document( + background_tasks: BackgroundTasks, file: UploadFile = File( ..., description="PDF file (max 50MB)", @@ -114,7 +121,7 @@ async def upload_document( ) # Upload document - return await DocumentService.upload_document( + document = await DocumentService.upload_document( db=db, user_id=current_user["user_id"], request=request, @@ -122,6 +129,47 @@ async def upload_document( file_name=file.filename, mime_type=file.content_type, ) + + # Auto-index document with embedding agent in background + background_tasks.add_task( + auto_index_document, + document_id=document.id, + user_id=current_user["user_id"] + ) + logger.info(f"[DocumentUpload] Document {document.id} uploaded, background indexing scheduled") + + return document + + +async def auto_index_document(document_id: int, user_id: int): + """Background task to automatically index uploaded documents""" + try: + logger.info(f"[AutoIndex] Starting indexing for document {document_id}") + + # Get new DB session for background task + async for db in get_db_session(): + try: + embedding_service = get_embedding_service() + agent = EmbeddingAgent(db=db, embedding_service=embedding_service) + + request = EmbeddingAgentInputSchema( + document_id=document_id, + chunk_size=512 + ) + + result = await agent.execute(request) + + if result.success: + logger.info(f"[AutoIndex] Document {document_id} indexed: {result.chunk_count} chunks, {result.embedding_count} embeddings") + else: + logger.error(f"[AutoIndex] Failed to index document {document_id}: {result.error}") + + finally: + await db.close() + break + + except Exception as e: + logger.error(f"[AutoIndex] Exception indexing document {document_id}: {str(e)}") @router.get( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 0b46e1b..7875808 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -119,6 +119,7 @@ class Document(Base): mime_type = Column(String(50), default="application/pdf", nullable=False) is_indexed = Column(Boolean, default=False, nullable=False) # Whether text extracted indexed_at = Column(DateTime, nullable=True) + section_split_confidence = Column(String(50), default="unknown", nullable=False) # "llm" or "fallback" # ✅ PDF Metadata (새로 추가) keywords = Column(JSON, nullable=True) # ["CRISPR", "gene-editing"] diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index d2f519e..d2dafc6 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -94,9 +94,16 @@ async def send_message( final_prompt = system_prompt if not final_prompt: logger.info("[ChatService] Using default prompt") - final_prompt = """You are an expert AI research assistant specializing in academic papers. -Your task is to analyze, synthesize, and generate insights from scientific literature. -Be accurate, evidence-based, and cite your sources appropriately.""" + final_prompt = """당신은 학술 논문 분석 전문가입니다. + +역할: +- 학술 자료를 분석하고 종합하기 +- 과학 문헌에서 통찰력 생성하기 + +지침: +- 정확하고 근거 있는 답변하기 +- 출처 인용하기 +- 모든 답변은 반드시 한국어로 하기, 영어는 고유명사만 사용하기""" # 사용자 메시지 저장 user_message = ChatMessage( diff --git a/backend/app/services/embedding_service.py b/backend/app/services/embedding_service.py index d53a7ff..169ec28 100644 --- a/backend/app/services/embedding_service.py +++ b/backend/app/services/embedding_service.py @@ -422,6 +422,31 @@ def clear_cache(self): if self.cache: self.cache.clear() + def get_collection(self): + """ + Get ChromaDB collection for semantic search + Returns the "document_embeddings" collection or None if unavailable + """ + try: + import chromadb + + client = chromadb.HttpClient( + host=settings.chromadb_host, + port=settings.chromadb_port + ) + + collection = client.get_or_create_collection( + name="document_embeddings", + metadata={"description": "PDF document embeddings"} + ) + + logger.info("[EmbeddingService] ChromaDB collection retrieved") + return collection + + except Exception as e: + logger.error(f"[EmbeddingService] Failed to get ChromaDB collection: {str(e)}") + return None + # 싱글톤 인스턴스 _embedding_service_instance: Optional[EmbeddingService] = None diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..f38fa8a --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,251 @@ +""" +Report Service +Handles report generation requests and integrates with chat system +Follows ChatService pattern for consistency +""" + +import logging +from typing import Optional +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.report_agent.agent import ReportAgent +from app.agents.report_agent.schemas import ( + ReportAgentRequest, + ReportAgentResponse, + ResearchTopicData, + DocumentReference, +) +from app.db.database import get_db_session +from app.services.session_service import SessionService +from app.services.user_service import UserService + +logger = logging.getLogger(__name__) + + +class ReportService: + """ + Report Service + Generates research feasibility reports + Integrates with existing chat and session system + """ + + def __init__(self): + """Initialize report service""" + self.agent = ReportAgent() + self.session_service = SessionService() + self.user_service = UserService() + + async def generate_report( + self, + user_id: int, + session_id: int, + research_topic: str, + research_description: Optional[str] = None, + analysis_goal: Optional[str] = None, + documents: Optional[list] = None, + include_visualizations: bool = False, + report_type: str = "markdown", + temperature: float = 0.7, + max_tokens: int = 4096, + db: Optional[AsyncSession] = None, + ) -> ReportAgentResponse: + """ + Generate a research feasibility report + + Args: + user_id: User ID + session_id: Chat session ID + research_topic: Research topic to analyze + research_description: Optional description of the research + analysis_goal: Optional analysis goal + documents: Optional list of document references + include_visualizations: Whether to generate visualizations + report_type: Report format type (markdown, pdf, json) + temperature: LLM temperature (0-2.0) + max_tokens: Maximum tokens for LLM response + db: Database session + + Returns: + ReportAgentResponse with generated report + + Raises: + ValueError: If user or session not found + Exception: If report generation fails + """ + try: + logger.info( + f"[ReportService] Generating report for user {user_id}, " + f"topic: {research_topic}" + ) + + # Step 1: Validate user and session + if db: + user = await self.user_service.get_user_by_id(db, user_id) + if not user: + raise ValueError(f"User {user_id} not found") + + session = await self.session_service.get_session(db, user_id, session_id) + if not session: + raise ValueError(f"Session {session_id} not found") + logger.info(f"[ReportService] Session validated: {session_id}") + + # Step 2: Prepare document references + document_refs = [] + if documents: + for doc in documents: + document_refs.append( + DocumentReference( + title=doc.get("title", "Unknown"), + authors=doc.get("authors", "Unknown"), + year=doc.get("year"), + url=doc.get("url"), + abstract=doc.get("abstract"), + ) + ) + logger.info(f"[ReportService] Prepared {len(document_refs)} document references") + + # Step 3: Build research data + research_data = ResearchTopicData( + topic=research_topic, + description=research_description, + analysis_goal=analysis_goal, + related_documents=document_refs, + ) + + # Step 4: Create report request + request = ReportAgentRequest( + research_topic=research_topic, + research_data=research_data if document_refs else None, + include_visualizations=include_visualizations, + report_type=report_type, + temperature=temperature, + max_tokens=max_tokens, + ) + logger.info(f"[ReportService] Report request created") + + # Step 5: Execute report agent + response = await self.agent.execute(request) + logger.info(f"[ReportService] Report generated successfully") + + # Step 6: Optionally save to database + if db and response.report: + await self._save_report_to_db( + user_id=user_id, + session_id=session_id, + report=response, + db=db, + ) + logger.info(f"[ReportService] Report saved to database") + + return response + + except Exception as e: + logger.error( + f"[ReportService] Error generating report for {user_id}: {str(e)}", + exc_info=True, + ) + raise + + async def _save_report_to_db( + self, + user_id: int, + session_id: int, + report: ReportAgentResponse, + db: AsyncSession, + ) -> None: + """ + Save generated report to database + + Args: + user_id: User ID + session_id: Session ID + report: Generated report + db: Database session + """ + try: + # TODO: Implement database storage + # This would involve creating a Report model and saving to DB + logger.info(f"[ReportService] Report saved for user {user_id}") + + except Exception as e: + logger.error(f"[ReportService] Error saving report to DB: {str(e)}") + # Don't fail the whole operation if save fails + pass + + async def get_report_history( + self, + user_id: int, + session_id: Optional[int] = None, + db: Optional[AsyncSession] = None, + limit: int = 10, + ) -> list: + """ + Get user's report generation history + + Args: + user_id: User ID + session_id: Optional session ID filter + db: Database session + limit: Maximum number of reports to retrieve + + Returns: + List of report metadata + """ + try: + logger.info(f"[ReportService] Retrieving report history for {user_id}") + + # TODO: Implement database retrieval + # Would query Report table filtered by user_id/session_id + + return [] + + except Exception as e: + logger.error( + f"[ReportService] Error retrieving report history: {str(e)}" + ) + raise + + async def delete_report( + self, + report_id: str, + user_id: int, + db: Optional[AsyncSession] = None, + ) -> bool: + """ + Delete a generated report + + Args: + report_id: Report ID + user_id: User ID (for authorization) + db: Database session + + Returns: + True if deleted successfully + + Raises: + ValueError: If report not found or unauthorized + """ + try: + logger.info(f"[ReportService] Deleting report {report_id}") + + # TODO: Implement database deletion + # Would verify ownership and delete from DB + + return True + + except Exception as e: + logger.error(f"[ReportService] Error deleting report: {str(e)}") + raise + + +# Singleton instance +_report_service = None + + +def get_report_service() -> ReportService: + """Get or create report service instance""" + global _report_service + if _report_service is None: + _report_service = ReportService() + return _report_service diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py new file mode 100644 index 0000000..605fe79 --- /dev/null +++ b/backend/app/tools/__init__.py @@ -0,0 +1,18 @@ +""" +Tools Package +Shared utilities and reasoning tools for agents +""" + +from app.tools.reasoning.react_quality_gate import ( + react_quality_gate, + EvidenceItem, + QualityGateResult, + NextAction, +) + +__all__ = [ + "react_quality_gate", + "EvidenceItem", + "QualityGateResult", + "NextAction", +] diff --git a/backend/app/tools/reasoning/__init__.py b/backend/app/tools/reasoning/__init__.py new file mode 100644 index 0000000..673b7b8 --- /dev/null +++ b/backend/app/tools/reasoning/__init__.py @@ -0,0 +1,18 @@ +""" +Reasoning Tools +ReAct-based quality gates and reasoning utilities +""" + +from app.tools.reasoning.react_quality_gate import ( + react_quality_gate, + EvidenceItem, + QualityGateResult, + NextAction, +) + +__all__ = [ + "react_quality_gate", + "EvidenceItem", + "QualityGateResult", + "NextAction", +] diff --git a/backend/app/tools/reasoning/react_quality_gate.py b/backend/app/tools/reasoning/react_quality_gate.py new file mode 100644 index 0000000..b12b426 --- /dev/null +++ b/backend/app/tools/reasoning/react_quality_gate.py @@ -0,0 +1,163 @@ +# app/tools/reasoning/react_quality_gate.py + +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Literal +import json +import re + + +NextAction = Literal[ + "increase_top_k", + "rewrite_query", + "diversify_sources", + "focus_sections", + "ask_user_clarification", + "stop", +] + + +@dataclass +class EvidenceItem: + """ + Generic evidence unit shared across agents. + """ + content: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class QualityGateResult: + accept: bool + confidence: float # 0.0 ~ 1.0 + failure_reasons: List[str] + + next_action: Optional[NextAction] = None + action_params: Dict[str, Any] = field(default_factory=dict) + rationale: str = "" + + llm_raw: Optional[Dict[str, Any]] = None + + +# ----------------------------- +# Main entry +# ----------------------------- + +async def react_quality_gate( + *, + task_goal: str, + query: str, + evidence_items: List[EvidenceItem], + llm_service: Any, + max_items: int = 12, +) -> QualityGateResult: + """ + PERMISSIVE Quality Gate - Always accepts with sufficient evidence items + + This gate prioritizes user satisfaction over strict quality checks. + Use for development/testing or when you want the agent to always attempt answers. + """ + + if not evidence_items: + return QualityGateResult( + accept=False, + confidence=0.0, + failure_reasons=["no_evidence"], + next_action="increase_top_k", + action_params={"top_k_delta": 5}, + rationale="No evidence items provided." + ) + + # If we have at least some evidence, ACCEPT it + num_items = len(evidence_items) + + return QualityGateResult( + accept=True, # Always accept if we have evidence + confidence=min(1.0, 0.5 + (num_items / 20.0)), # Increase confidence with more items + failure_reasons=[], # No failure reasons + next_action=None, # No next action needed + action_params={}, + rationale=f"Permissive gate: Accepting {num_items} evidence item(s) as sufficient.", + llm_raw={"mode": "permissive_accept"}, + ) + + +# ----------------------------- +# Prompt +# ----------------------------- + +_SYSTEM_PROMPT = """ +You are a strict quality auditor for a document-grounded research assistant. + +You MUST NOT answer the user's question. +You MUST NOT add new facts or interpretations. +You ONLY evaluate whether the provided evidence is sufficient and appropriate. + +Your role is to help prevent hallucination and overconfidence. +""" + + +def _build_judge_prompt( + *, + task_goal: str, + query: str, + evidence_summaries: List[Dict[str, Any]], +) -> str: + payload = { + "task_goal": task_goal, + "user_query": query, + "evidence_summaries": evidence_summaries, + "evaluation_criteria": [ + "Relevance: Does the evidence directly address the query?", + "Evidence sufficiency: Are there enough concrete excerpts?", + "Coverage: Is evidence drawn from multiple documents or sections?", + "Risk of hallucination: Would answering now require speculation?", + ], + "output_schema": { + "accept": "boolean", + "confidence": "number (0.0-1.0)", + "failure_reasons": "string[]", + "next_action": [ + "increase_top_k", + "rewrite_query", + "diversify_sources", + "focus_sections", + "ask_user_clarification", + "stop" + ], + "action_params": "object", + "rationale": "string" + }, + "rules": [ + "Base your judgment ONLY on the provided evidence summaries.", + "If evidence is insufficient, suggest exactly ONE next_action.", + "Do NOT answer the query.", + "Output ONLY valid JSON." + ] + } + return json.dumps(payload, ensure_ascii=False, indent=2) + + +# ----------------------------- +# Utils +# ----------------------------- + +def _safe_json_parse(text: str) -> Any: + if not text: + return None + t = text.strip() + + if t.startswith("```"): + t = re.sub(r"^```[a-zA-Z]*\n?", "", t) + t = re.sub(r"\n?```$", "", t).strip() + + try: + return json.loads(t) + except Exception: + m = re.search(r"\{.*\}", t, re.DOTALL) + if m: + try: + return json.loads(m.group(0)) + except Exception: + return None + return None \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index cd9dcdf..2da4ddf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,8 +6,8 @@ sqlalchemy==2.0.23 alembic==1.13.1 asyncpg==0.29.0 psycopg2-binary==2.9.9 -chromadb==0.4.18 -httpx==0.25.2 +chromadb==0.5.23 +httpx>=0.27.0 aiohttp==3.9.1 aiofiles==23.2.1 requests==2.31.0 @@ -22,3 +22,12 @@ passlib[bcrypt]==1.7.4 bcrypt==4.1.1 email-validator==2.1.0 fastapi-cors==0.0.6 +pandas==2.1.4 +pint==0.23 +PyMuPDF==1.23.8 +plotly==5.18.0 +reportlab==4.0.7 +networkx==3.2.1 +pyvis==0.3.2 +scikit-learn>=1.3.0 +numpy>=1.25.0 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 612f2cf..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,143 +0,0 @@ -version: "3.9" - -services: - # PostgreSQL Database - postgres: - image: postgres:16-alpine - container_name: tva-postgres - environment: - POSTGRES_USER: ${DB_USER:-tva} - POSTGRES_PASSWORD: ${DB_PASSWORD:-tva_password} - POSTGRES_DB: ${DB_NAME:-tva_db} - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-tva} -h postgres"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - tva-network - - # ChromaDB Vector Database - chromadb: - image: chromadb/chroma:0.5.23 - container_name: tva-chromadb - environment: - IS_PERSISTENT: "TRUE" - PERSIST_DIRECTORY: /chroma/chroma - ANONYMIZED_TELEMETRY: "FALSE" - ports: - - "8000:8000" - volumes: - - /chroma_data - healthcheck: - test: - [ - "CMD", - "bash", - "-c", - "curl -f http://localhost:8000/api/v1/heartbeat || exit 0", - ] - interval: 15s - timeout: 10s - retries: 5 - start_period: 20s - networks: - - tva-network - - # FastAPI Backend - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: tva-backend - environment: - # Database - DATABASE_URL: postgresql+asyncpg://${DB_USER:-tva}:${DB_PASSWORD:-tva_password}@postgres:5432/${DB_NAME:-tva_db} - DB_USER: ${DB_USER:-tva} - DB_PASSWORD: ${DB_PASSWORD:-tva_password} - DB_NAME: ${DB_NAME:-tva_db} - - # ChromaDB - CHROMADB_HOST: chromadb - CHROMADB_PORT: 8000 - CHROMA_DB_PATH: /chroma_data - - # APIs - UPSTAGE_API_KEY: ${UPSTAGE_API_KEY} - - # JWT - JWT_SECRET_KEY: ${JWT_SECRET_KEY:-tva-backend-secret-key-change-in-production} - JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} - ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440} - REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7} - - # CORS - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-*} - - # LLM Settings - LLM_MODEL: ${LLM_MODEL:-upstage-solar-pro} - LLM_TEMPERATURE: ${LLM_TEMPERATURE:-0.3} - LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000} - LLM_TOP_P: ${LLM_TOP_P:-0.9} - - # Server - ENVIRONMENT: ${ENVIRONMENT:-development} - DEBUG: ${DEBUG:-true} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - ports: - - "8001:8000" - depends_on: - postgres: - condition: service_healthy - chromadb: - condition: service_healthy - volumes: - - ./backend/app:/app/app - - ./backend/tests:/app/tests - - ./backend/uploads:/app/uploads - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - networks: - - tva-network - - # Frontend - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - container_name: tva-frontend - ports: - - "3000:5173" - environment: - - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001} - - VITE_API_VERSION=${VITE_API_VERSION:-v1} - - VITE_APP_NAME=${VITE_APP_NAME:-TVA} - - VITE_APP_DESCRIPTION=${VITE_APP_DESCRIPTION:-Target Validation Assistant} - - VITE_ENABLE_DEBUG=${VITE_ENABLE_DEBUG:-true} - - VITE_SESSION_TIMEOUT_MINUTES=${VITE_SESSION_TIMEOUT_MINUTES:-30} - - VITE_TOKEN_REFRESH_BUFFER_MINUTES=${VITE_TOKEN_REFRESH_BUFFER_MINUTES:-5} - volumes: - - ./frontend/src:/app/src - - ./frontend/public:/app/public - - ./frontend/index.html:/app/index.html - - ./frontend/vite.config.js:/app/vite.config.js - - ./frontend/tailwind.config.js:/app/tailwind.config.js - - ./frontend/postcss.config.cjs:/app/postcss.config.cjs - - /app/node_modules - depends_on: - - backend - networks: - - tva-network - -volumes: - postgres_data: - driver: local - chroma_data: - driver: local - -networks: - tva-network: - driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..db96409 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.local +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 5d41896..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -# 1) build stage -FROM node:20-alpine AS build -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -# ✅ build-time vars -ARG VITE_API_BASE_URL -ARG VITE_API_VERSION -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL -ENV VITE_API_VERSION=$VITE_API_VERSION - -COPY . . -RUN npm run build - -# 2) run stage (nginx) -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] - diff --git a/frontend/src/components/ChatPanel/AgentSelector.jsx b/frontend/src/components/ChatPanel/AgentSelector.jsx index 5951ef5..122b592 100644 --- a/frontend/src/components/ChatPanel/AgentSelector.jsx +++ b/frontend/src/components/ChatPanel/AgentSelector.jsx @@ -1,35 +1,35 @@ -import React from 'react'; -import { Bot, Globe, Microscope, PenTool } from 'lucide-react'; +import React from "react"; +import { Bot, Globe, Microscope, PenTool } from "lucide-react"; const AgentSelector = ({ agentMode, onSetAgentMode }) => { return (
diff --git a/frontend/src/components/ChatPanel/ChatPanel.jsx b/frontend/src/components/ChatPanel/ChatPanel.jsx index ea4123a..ae04149 100644 --- a/frontend/src/components/ChatPanel/ChatPanel.jsx +++ b/frontend/src/components/ChatPanel/ChatPanel.jsx @@ -1,17 +1,27 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect } from "react"; import { - Bot, User, Target, CheckSquare, ArrowLeft, Lightbulb, ChevronDown, - Microscope, Globe, PenTool, Send -} from 'lucide-react'; -import { AGENT_THEME } from '../../utils/constants'; -import ChatMessages from './ChatMessages'; -import ChatInput from './ChatInput'; -import AgentSelector from './AgentSelector'; -import GoalSetting from './GoalSetting'; -import ContextList from './ContextList'; -import chatService from '../../services/generalChatService'; -import searchAgentService from '../../services/searchAgentService'; -import analysisAgentService from '../../services/analysisAgentService'; + Bot, + User, + Target, + CheckSquare, + ArrowLeft, + Lightbulb, + ChevronDown, + Microscope, + Globe, + PenTool, + Send, +} from "lucide-react"; +import { AGENT_THEME } from "../../utils/constants"; +import ChatMessages from "./ChatMessages"; +import ChatInput from "./ChatInput"; +import AgentSelector from "./AgentSelector"; +import GoalSetting from "./GoalSetting"; +import ContextList from "./ContextList"; +import chatService from "../../services/generalChatService"; +import searchAgentService from "../../services/searchAgentService"; +import analysisAgentService from "../../services/analysisAgentService"; +import reportAgentService from "../../services/reportAgentService"; const ChatPanel = ({ sessionId, @@ -34,28 +44,17 @@ const ChatPanel = ({ sessionTitle, sessionDescription, }) => { - const selectedItemsList = allItems.filter(item => checkedItems.has(item.id)); + const selectedItemsList = allItems.filter((item) => + checkedItems.has(item.id), + ); const theme = AGENT_THEME[agentMode]; const handleSend = async (input) => { if (!input.trim() || !sessionId) return; - // Report 모드는 아직 미구현 - if (agentMode === 'report') { - const warningMessage = { - id: Date.now(), - role: 'assistant', - content: `${agentMode} 모드는 아직 구현되지 않았습니다. General, Search 또는 Analysis 모드를 사용해주세요.`, - timestamp: new Date(), - isError: true, - }; - onAddMessage(warningMessage); - return; - } - const userMessage = { id: Date.now(), - role: 'user', + role: "user", content: input, timestamp: new Date(), }; @@ -64,7 +63,7 @@ const ChatPanel = ({ onSetIsTyping(true); try { - if (agentMode === 'general') { + if (agentMode === "general") { // General Chat Mode - LLM 대화 const response = await chatService.sendMessage( sessionId, @@ -73,19 +72,19 @@ const ChatPanel = ({ 0.7, // temperature 2048, // max_tokens selectedItemsList, // selected_documents - analysisGoal || null // analysis_goal + analysisGoal || null, // analysis_goal ); const aiMessage = { id: response.message_id, - role: 'assistant', + role: "assistant", content: response.content, timestamp: new Date(response.generated_at), usage: response.usage, }; onAddMessage(aiMessage); - } else if (agentMode === 'search') { + } else if (agentMode === "search") { // Search Mode - arXiv 논문 검색 // LLM이 백엔드에서 요청 개수를 자동 추출함 const response = await searchAgentService.search( @@ -93,7 +92,7 @@ const ChatPanel = ({ input, analysisGoal || null, selectedItemsList, // 이미 다운로드된 문서 (중복 방지) - 0.7 // min_relevance_score + 0.7, // min_relevance_score ); // 검색 결과 메시지 생성 @@ -105,7 +104,7 @@ const ChatPanel = ({ resultContent += `**다운로드된 논문:**\n\n`; response.papers.forEach((paper, idx) => { resultContent += `${idx + 1}. **${paper.title}**\n`; - resultContent += ` - 저자: ${paper.authors.slice(0, 3).join(', ')}${paper.authors.length > 3 ? ' 외' : ''}\n`; + resultContent += ` - 저자: ${paper.authors.slice(0, 3).join(", ")}${paper.authors.length > 3 ? " 외" : ""}\n`; resultContent += ` - 관련성: ${(paper.relevance_score * 100).toFixed(0)}%\n`; resultContent += ` - arXiv ID: ${paper.arxiv_id}\n\n`; }); @@ -115,19 +114,19 @@ const ChatPanel = ({ const aiMessage = { id: Date.now() + 1, - role: 'assistant', + role: "assistant", content: resultContent, timestamp: new Date(), }; onAddMessage(aiMessage); - } else if (agentMode === 'analysis') { + } else if (agentMode === "analysis") { // Analysis Mode - RAG 기반 문서 분석 if (selectedItemsList.length === 0) { const warningMessage = { id: Date.now() + 1, - role: 'assistant', - content: '분석할 문서를 먼저 선택해주세요.', + role: "assistant", + content: "분석할 문서를 먼저 선택해주세요.", timestamp: new Date(), isError: true, }; @@ -142,12 +141,12 @@ const ChatPanel = ({ analysisGoal || null, selectedItemsList, 5, // top_k: 상위 5개 청크 - 0.5 // min_relevance_score + 0.5, // min_relevance_score ); // 분석 결과 메시지 생성 let resultContent = `📊 **분석 결과**\n\n${response.answer}\n\n`; - + if (response.citations && response.citations.length > 0) { resultContent += `\n**근거:**\n\n`; response.citations.forEach((citation, idx) => { @@ -161,20 +160,145 @@ const ChatPanel = ({ const aiMessage = { id: Date.now() + 1, - role: 'assistant', + role: "assistant", + content: resultContent, + timestamp: new Date(), + usage: { total_tokens: response.tokens_used }, + }; + + onAddMessage(aiMessage); + } else if (agentMode === "report") { + // Report Mode - 연구 타당성 보고서 생성 + if (selectedItemsList.length === 0) { + const warningMessage = { + id: Date.now() + 1, + role: "assistant", + content: "보고서를 생성할 문서를 먼저 선택해주세요.", + timestamp: new Date(), + isError: true, + }; + onAddMessage(warningMessage); + onSetIsTyping(false); + return; + } + + const response = await reportAgentService.generateReport( + input, // research topic + { + researchDescription: null, + analysisGoal: analysisGoal || null, + documents: selectedItemsList, + includeVisualizations: false, // 텍스트만 표시 + includeNetworkGraph: false, + reportType: "comprehensive", + temperature: 0.7, + maxTokens: 4096, + sessionId: sessionId, + }, + ); + + // 보고서 결과 메시지 생성 + let resultContent = `📝 **${response.report.title}**\n\n`; + + // 타당성 평가 + const validation = response.report.validation; + const feasibilityEmoji = validation.is_feasible ? "✅" : "⚠️"; + resultContent += `${feasibilityEmoji} **타당성 평가**\n`; + resultContent += `- 점수: ${validation.feasibility_score.toFixed(1)}/100\n`; + resultContent += `- 결과: ${validation.is_feasible ? "연구 가능" : "추가 검토 필요"}\n`; + resultContent += `- 근거: ${validation.reasoning}\n\n`; + + // 주요 섹션 + if (response.report.sections && response.report.sections.length > 0) { + resultContent += `**주요 분석**\n\n`; + response.report.sections.forEach((section, idx) => { + resultContent += `**${idx + 1}. ${section.title}**\n${section.content}\n\n`; + }); + } + + // 증거 요약 + if (response.report.evidence_summary) { + resultContent += `**📚 증거 요약**\n${response.report.evidence_summary}\n\n`; + } + + // 권장사항 + if ( + response.report.recommendations && + response.report.recommendations.length > 0 + ) { + resultContent += `**💡 권장사항**\n`; + response.report.recommendations.forEach((rec, idx) => { + resultContent += `${idx + 1}. ${rec}\n`; + }); + resultContent += `\n`; + } + + // 한계점 + if ( + response.report.limitations && + response.report.limitations.length > 0 + ) { + resultContent += `**⚠️ 한계점**\n`; + response.report.limitations.forEach((limit, idx) => { + resultContent += `${idx + 1}. ${limit}\n`; + }); + resultContent += `\n`; + } + + // 참고 논문 + if ( + response.report.related_papers && + response.report.related_papers.length > 0 + ) { + resultContent += `**📄 참고 논문: ${response.report.related_papers.length}개**\n`; + } + + resultContent += `\n*토큰 사용: ${response.tokens_used}*`; + + const aiMessage = { + id: Date.now() + 1, + role: "assistant", content: resultContent, timestamp: new Date(), usage: { total_tokens: response.tokens_used }, }; onAddMessage(aiMessage); + + // 생성된 보고서를 Library의 Reports 탭에 저장 + if (response.report) { + const { useLibraryStore } = await import("../../stores/libraryStore"); + const addReport = useLibraryStore.getState().addReport; + + const reportItem = { + id: Date.now() + 2, + type: "report", + title: response.report.title || input.substring(0, 50), + authors: "AI Generated", + year: new Date().getFullYear().toString(), + conference: "Report Agent", + abstract: validation.reasoning, + content: resultContent, + feasibilityScore: validation.feasibility_score, + isFeasible: validation.is_feasible, + createdAt: new Date().toISOString(), + sections: response.report.sections, + recommendations: response.report.recommendations, + limitations: response.report.limitations, + relatedPapers: response.report.related_papers, + visualizations: response.metadata?.visualizations || {}, // 시각화 HTML 포함 + }; + + addReport(reportItem); + console.log("[ChatPanel] Report saved to library:", reportItem.title); + } } } catch (error) { - console.error('[ChatPanel] Failed to send message:', error); + console.error("[ChatPanel] Failed to send message:", error); const errorMessage = { id: Date.now() + 1, - role: 'assistant', - content: `오류가 발생했습니다: ${error.message || '메시지 전송에 실패했습니다.'}`, + role: "assistant", + content: `오류가 발생했습니다: ${error.message || "메시지 전송에 실패했습니다."}`, timestamp: new Date(), isError: true, }; @@ -189,27 +313,33 @@ const ChatPanel = ({ {/* Header */}
-
- {agentMode === 'general' && } - {agentMode === 'search' && } - {agentMode === 'analysis' && } - {agentMode === 'report' && } +
+ {agentMode === "general" && } + {agentMode === "search" && } + {agentMode === "analysis" && } + {agentMode === "report" && }

{theme.name}

- - - {agentMode === 'general' ? 'Active' : 'Online'} + + + {agentMode === "general" ? "Active" : "Online"}
- {agentMode === 'general' && checkedItems.size > 0 && ( + {agentMode === "general" && checkedItems.size > 0 && ( {checkedItems.size} docs active )} - {agentMode !== 'general' && checkedItems.size > 0 && ( + {agentMode !== "general" && checkedItems.size > 0 && ( {checkedItems.size} docs active @@ -221,7 +351,7 @@ const ChatPanel = ({
); diff --git a/frontend/src/components/PDFViewerPanel/PDFToolbar.jsx b/frontend/src/components/PDFViewerPanel/PDFToolbar.jsx index 822183a..502a23b 100644 --- a/frontend/src/components/PDFViewerPanel/PDFToolbar.jsx +++ b/frontend/src/components/PDFViewerPanel/PDFToolbar.jsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Book, FileType, List, ZoomIn, ZoomOut } from 'lucide-react'; +import React from "react"; +import { Book, FileType, List, ZoomIn, ZoomOut } from "lucide-react"; const PDFToolbar = ({ paper, @@ -17,7 +17,7 @@ const PDFToolbar = ({
- -
+ {!isReport && ( +
+ + +
+ )} + + {/* Report Tab Label */} + {isReport && ( +
+ + + Report + +
+ )} {/* Zoom Controls */} - {viewMode === 'summary' && ( + {viewMode === "summary" && ( <>
@@ -70,7 +89,9 @@ const PDFToolbar = ({ > - {zoomLevel}% + + {zoomLevel}% +
diff --git a/frontend/src/components/PDFViewerPanel/SummaryViewer.jsx b/frontend/src/components/PDFViewerPanel/SummaryViewer.jsx index 6fb0e2b..38e2d47 100644 --- a/frontend/src/components/PDFViewerPanel/SummaryViewer.jsx +++ b/frontend/src/components/PDFViewerPanel/SummaryViewer.jsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { List, AlertCircle } from 'lucide-react'; +import React from "react"; +import { List, AlertCircle } from "lucide-react"; const SummaryViewer = ({ paper, isReport, zoomLevel }) => { const hasSummary = paper.summary && paper.summary.trim().length > 0; @@ -13,33 +13,238 @@ const SummaryViewer = ({ paper, isReport, zoomLevel }) => { minHeight: `${11 * (zoomLevel / 100)}in`, padding: `${1 * (zoomLevel / 100)}in`, fontSize: `${12 * (zoomLevel / 100)}pt`, - height: 'fit-content' + height: "fit-content", }} > -
+
+ {/* Header */}
-

{paper.title}

+

+ {paper.title} +

{paper.authors}

-

{paper.conference} {paper.year}

+

+ {paper.conference} {paper.year} +

- {hasSummary ? ( + {/* Report Content - 전체 내용 표시 */} + {isReport && paper.content ? ( +
+ {/* 타당성 평가 */} + {paper.feasibilityScore !== undefined && ( +
+

+ {paper.isFeasible ? "✅" : "⚠️"} + 타당성 평가 +

+
+

+ 점수: {paper.feasibilityScore.toFixed(1)} + /100 +

+

+ 결과:{" "} + {paper.isFeasible ? "연구 가능" : "추가 검토 필요"} +

+ {paper.abstract && ( +

+ 근거: {paper.abstract} +

+ )} +
+
+ )} + + {/* 주요 섹션 */} + {paper.sections && paper.sections.length > 0 && ( +
+

+ 📄 주요 분석 +

+ {paper.sections.map((section, idx) => ( +
+

+ {idx + 1}. {section.title} +

+

+ {section.content} +

+
+ ))} +
+ )} + + {/* 시각화 */} + {paper.visualizations && + Object.keys(paper.visualizations).length > 0 && ( +
+

+ 📊 시각화 +

+ + {/* Evidence Network */} + {paper.visualizations.evidence_network && ( +
+

+ 연구 증거 네트워크 +

+
+