Spaces:
Sleeping
Sleeping
ForStream commited on
Commit ·
2b50ae3
0
Parent(s):
Initial: LP출자 온톨로지 LLM 프로토타입
Browse files- FastAPI 백엔드 (rag_engine + KoSimCSE + 4구성 호출)
- React + Vite 프론트엔드 (3탭: 설명/테스트/데이터 관리)
- Gemma 4 E4B (HF Inference API) + Sonnet 4.6
- 데이터: 트리플 3,712개, RAG 청크 274개, KoSimCSE 임베딩 캐시
- Docker multi-stage 빌드
- .gitignore +33 -0
- Dockerfile +68 -0
- README.md +52 -0
- api/llm_adapters.py +162 -0
- api/main.py +235 -0
- api/requirements.txt +20 -0
- code/baseline_lib.py +792 -0
- code/rag_engine.py +1265 -0
- code/semantic_search.py +309 -0
- data/alias_dictionary.json +81 -0
- data/investment_ontology_v1_10.ttl +0 -0
- data/regulations_chunks_v14.jsonl +0 -0
- data/risk_weight_lookup.json +155 -0
- prepare_data.sh +51 -0
- web/index.html +17 -0
- web/package.json +19 -0
- web/src/App.jsx +56 -0
- web/src/DataTab.jsx +319 -0
- web/src/ExplainTab.jsx +124 -0
- web/src/TestTab.jsx +287 -0
- web/src/main.jsx +6 -0
- web/src/styles.css +661 -0
- web/vite.config.js +20 -0
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.so
|
| 5 |
+
.Python
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
|
| 9 |
+
# Node
|
| 10 |
+
node_modules/
|
| 11 |
+
web/dist/
|
| 12 |
+
.npm/
|
| 13 |
+
|
| 14 |
+
# OS
|
| 15 |
+
.DS_Store
|
| 16 |
+
Thumbs.db
|
| 17 |
+
|
| 18 |
+
# Editor
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
|
| 23 |
+
# Local env
|
| 24 |
+
.env
|
| 25 |
+
.env.local
|
| 26 |
+
|
| 27 |
+
# Cache (HF Spaces에서 새로 빌드)
|
| 28 |
+
.cache/
|
| 29 |
+
|
| 30 |
+
# Binary assets (HF의 binary 거부 — 나중에 LFS/Xet로 추가 예정)
|
| 31 |
+
assets/*.pdf
|
| 32 |
+
assets/*.png
|
| 33 |
+
data/_embeddings_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =====================================================================
|
| 2 |
+
# Stage 1: React (Vite) 빌드
|
| 3 |
+
# =====================================================================
|
| 4 |
+
FROM node:20-alpine AS web-build
|
| 5 |
+
|
| 6 |
+
WORKDIR /web
|
| 7 |
+
COPY web/package.json web/package-lock.json* ./
|
| 8 |
+
RUN npm install --no-audit --no-fund
|
| 9 |
+
|
| 10 |
+
COPY web/ ./
|
| 11 |
+
RUN npm run build
|
| 12 |
+
|
| 13 |
+
# =====================================================================
|
| 14 |
+
# Stage 2: Python 런타임 (FastAPI + 데이터 + Web 빌드 결과)
|
| 15 |
+
# =====================================================================
|
| 16 |
+
FROM python:3.11-slim
|
| 17 |
+
|
| 18 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 19 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 20 |
+
PIP_NO_CACHE_DIR=1 \
|
| 21 |
+
HF_HOME=/app/.cache/huggingface \
|
| 22 |
+
TRANSFORMERS_CACHE=/app/.cache/huggingface
|
| 23 |
+
|
| 24 |
+
# HF Spaces 기본 포트
|
| 25 |
+
ENV PORT=7860
|
| 26 |
+
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
# 시스템 패키지 (rdflib + sentence-transformers 빌드 의존)
|
| 30 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 31 |
+
build-essential git curl \
|
| 32 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 33 |
+
|
| 34 |
+
# Python 의존성 (캐시 효율화: requirements 먼저)
|
| 35 |
+
COPY api/requirements.txt /app/api/requirements.txt
|
| 36 |
+
RUN pip install --upgrade pip && pip install -r /app/api/requirements.txt
|
| 37 |
+
|
| 38 |
+
# 코드 (rag_engine·semantic_search 등 active/code의 핵심 모듈)
|
| 39 |
+
COPY code/ /app/code/
|
| 40 |
+
# 백엔드
|
| 41 |
+
COPY api/ /app/api/
|
| 42 |
+
# 데이터
|
| 43 |
+
COPY data/ /app/active/ontology/
|
| 44 |
+
COPY assets/ /app/active/
|
| 45 |
+
|
| 46 |
+
# React 빌드 결과 (Stage 1)
|
| 47 |
+
COPY --from=web-build /web/dist /app/hf_app/web/dist
|
| 48 |
+
|
| 49 |
+
# 디렉토리 구조 맞춤 (main.py가 active/ontology 등 상대경로 사용)
|
| 50 |
+
RUN mkdir -p /app/active/code && cp -r /app/code/* /app/active/code/
|
| 51 |
+
|
| 52 |
+
# KoSimCSE 모델 사전 다운로드 (런타임 cold start 단축)
|
| 53 |
+
RUN python -c "from transformers import AutoModel, AutoTokenizer; \
|
| 54 |
+
AutoTokenizer.from_pretrained('BM-K/KoSimCSE-roberta'); \
|
| 55 |
+
AutoModel.from_pretrained('BM-K/KoSimCSE-roberta')" || true
|
| 56 |
+
|
| 57 |
+
# HF Space 사용자 권한 (필수 — 1000)
|
| 58 |
+
RUN useradd -m -u 1000 user && chown -R user /app
|
| 59 |
+
USER user
|
| 60 |
+
|
| 61 |
+
EXPOSE 7860
|
| 62 |
+
|
| 63 |
+
# FastAPI 시작 — Dockerfile WORKDIR이 /app, main.py는 api/main.py
|
| 64 |
+
WORKDIR /app/hf_app
|
| 65 |
+
RUN ln -sf /app/api ./api
|
| 66 |
+
WORKDIR /app
|
| 67 |
+
|
| 68 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LP출자 온톨로지 LLM 프로토타입
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: 온톨로지 기반 폐쇄망 RAG 시스템 (LP 출자 도메인)
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# LP출자 온톨로지 LLM 적용 프로토타입 테스트
|
| 14 |
+
|
| 15 |
+
사내 AI 경진대회 출품작 · 온톨로지 기반 폐쇄망 RAG 시스템.
|
| 16 |
+
|
| 17 |
+
## 구조
|
| 18 |
+
|
| 19 |
+
- `api/` — FastAPI 백엔드 (rag_engine + KoSimCSE + 4구성 호출 + LLM 어댑터)
|
| 20 |
+
- `web/` — React + Vite 프론트엔드 (3탭: 설명/테스트/데이터 관리)
|
| 21 |
+
- `code/` — `rag_engine.py`·`semantic_search.py`·`baseline_lib.py` 등 핵심 모듈 (active/code 사본)
|
| 22 |
+
- `data/` — `investment_ontology_v1_10.ttl`·`regulations_chunks_v14.jsonl`·alias·lookup
|
| 23 |
+
- `assets/` — paper_v5.pdf·노드 그래프 png 등 다운로드용 정적 자산
|
| 24 |
+
- `Dockerfile` — multi-stage (Node 빌드 → Python 런타임)
|
| 25 |
+
|
| 26 |
+
## 환경변수 (HF Space Secrets에 설정)
|
| 27 |
+
|
| 28 |
+
| Key | 설명 |
|
| 29 |
+
|---|---|
|
| 30 |
+
| `ANTHROPIC_API_KEY` | Sonnet 4.6 호출용 (필수) |
|
| 31 |
+
| `HF_TOKEN` | HF Inference API용 (Gemma 호출) |
|
| 32 |
+
| `LLM_BACKEND` | `hf_inference` 권장 (또는 `ollama`/`transformers_local`) |
|
| 33 |
+
| `HF_GEMMA_MODEL` | 기본: `google/gemma-4-E4B-it` (Gemma 4 E4B, multimodal, 128K context) |
|
| 34 |
+
| `ANTHROPIC_MODEL` | 기본: `claude-sonnet-4-6` |
|
| 35 |
+
|
| 36 |
+
## 로컬 개발
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
# 백엔드
|
| 40 |
+
cd api && pip install -r requirements.txt
|
| 41 |
+
uvicorn main:app --reload --port 8000
|
| 42 |
+
|
| 43 |
+
# 프론트엔드 (별도 터미널)
|
| 44 |
+
cd web && npm install && npm run dev
|
| 45 |
+
# Vite dev 서버가 /api 호출을 :8000으로 프록시
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 핵심 결과 (페이퍼 기준)
|
| 49 |
+
|
| 50 |
+
- **패러프레이즈 정답률 (lenient)**: 키워드 33% → axisB (LLM파서+KoSimCSE) **93~100%**
|
| 51 |
+
- **Sonnet ≒ Gemma 4 e4b** 패러프레이즈 lenient 동등 → 폐쇄망 4B 정당성 입증
|
| 52 |
+
- 30문항 × 8구성 × 3회 = 720 응답 + Sonnet judge 평가 (`results_v5_axisB/`)
|
api/llm_adapters.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM 호출 추상화 — 환경에 따라 백엔드 전환.
|
| 2 |
+
|
| 3 |
+
LLM_BACKEND 환경변수:
|
| 4 |
+
- ollama (default, 로컬 개발)
|
| 5 |
+
- hf_inference (HF Inference API, gemma-3-4b-it)
|
| 6 |
+
- transformers_local (HF Spaces ZeroGPU + transformers 직접 로드)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import os
|
| 11 |
+
import time
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
LLM_BACKEND = os.environ.get("LLM_BACKEND", "ollama")
|
| 15 |
+
GEMMA_MODEL_OLLAMA = os.environ.get("OLLAMA_MODEL", "gemma4:e4b")
|
| 16 |
+
GEMMA_MODEL_HF = os.environ.get("HF_GEMMA_MODEL", "google/gemma-4-E4B-it")
|
| 17 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 18 |
+
|
| 19 |
+
# transformers_local backend cache
|
| 20 |
+
_tx_model = None
|
| 21 |
+
_tx_tokenizer = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def call_gemma(question: str, context: str, system: Optional[str] = None,
|
| 25 |
+
max_tokens: int = 1500, temperature: float = 0.3) -> tuple[str | None, bool, str]:
|
| 26 |
+
"""Gemma 호출. (answer, success, info) 반환."""
|
| 27 |
+
sys_prompt = system or (
|
| 28 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 29 |
+
"아래 컨텍스트의 사실을 그대로 유지하면서 자연스러운 한국어로 답변을 다듬어 주세요. "
|
| 30 |
+
"사실을 추가하거나 추측하지 마세요. 제공된 정보만 사용하세요."
|
| 31 |
+
)
|
| 32 |
+
full_prompt = f"[컨텍스트]\n{context}\n\n[질문]\n{question}\n\n[답변]"
|
| 33 |
+
|
| 34 |
+
if LLM_BACKEND == "ollama":
|
| 35 |
+
return _call_ollama(sys_prompt, full_prompt, max_tokens, temperature)
|
| 36 |
+
elif LLM_BACKEND == "hf_inference":
|
| 37 |
+
return _call_hf_inference(sys_prompt, full_prompt, max_tokens, temperature)
|
| 38 |
+
elif LLM_BACKEND == "transformers_local":
|
| 39 |
+
return _call_transformers_local(sys_prompt, full_prompt, max_tokens, temperature)
|
| 40 |
+
else:
|
| 41 |
+
return None, False, f"unknown LLM_BACKEND: {LLM_BACKEND}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def call_gemma_intent_parser(question: str, system: str) -> tuple[str | None, bool]:
|
| 45 |
+
"""LLM 의도 파서 — JSON 응답. (raw_text, success)."""
|
| 46 |
+
if LLM_BACKEND == "ollama":
|
| 47 |
+
try:
|
| 48 |
+
import requests
|
| 49 |
+
r = requests.post("http://localhost:11434/api/generate",
|
| 50 |
+
json={"model": GEMMA_MODEL_OLLAMA, "system": system, "prompt": question,
|
| 51 |
+
"format": "json", "stream": False, "options": {"temperature": 0}},
|
| 52 |
+
timeout=60)
|
| 53 |
+
return r.json().get("response", ""), True
|
| 54 |
+
except Exception as e:
|
| 55 |
+
return None, False
|
| 56 |
+
elif LLM_BACKEND == "hf_inference":
|
| 57 |
+
try:
|
| 58 |
+
from huggingface_hub import InferenceClient
|
| 59 |
+
client = InferenceClient(model=GEMMA_MODEL_HF, token=HF_TOKEN)
|
| 60 |
+
resp = client.chat_completion(
|
| 61 |
+
messages=[{"role": "system", "content": system},
|
| 62 |
+
{"role": "user", "content": question}],
|
| 63 |
+
max_tokens=200, temperature=0, response_format={"type": "json_object"},
|
| 64 |
+
)
|
| 65 |
+
return resp.choices[0].message.content, True
|
| 66 |
+
except Exception as e:
|
| 67 |
+
return None, False
|
| 68 |
+
elif LLM_BACKEND == "transformers_local":
|
| 69 |
+
# transformers 로컬 호출 (Spaces ZeroGPU)
|
| 70 |
+
try:
|
| 71 |
+
_ensure_tx_loaded()
|
| 72 |
+
messages = [{"role": "system", "content": system},
|
| 73 |
+
{"role": "user", "content": question}]
|
| 74 |
+
return _generate_tx(messages, max_tokens=200, temperature=0), True
|
| 75 |
+
except Exception as e:
|
| 76 |
+
return None, False
|
| 77 |
+
return None, False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ----------------------------------------------------------
|
| 81 |
+
# 백엔드별 구현
|
| 82 |
+
# ----------------------------------------------------------
|
| 83 |
+
def _call_ollama(system, prompt, max_tokens, temperature):
|
| 84 |
+
try:
|
| 85 |
+
import ollama
|
| 86 |
+
except ImportError:
|
| 87 |
+
return None, False, "ollama 패키지 미설치"
|
| 88 |
+
try:
|
| 89 |
+
start = time.time()
|
| 90 |
+
resp = ollama.chat(
|
| 91 |
+
model=GEMMA_MODEL_OLLAMA,
|
| 92 |
+
messages=[{"role": "system", "content": system},
|
| 93 |
+
{"role": "user", "content": prompt}],
|
| 94 |
+
options={"num_predict": max_tokens, "temperature": temperature, "repeat_penalty": 1.15},
|
| 95 |
+
)
|
| 96 |
+
return resp["message"]["content"], True, f"elapsed={time.time()-start:.2f}s"
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return None, False, f"{type(e).__name__}: {str(e)[:200]}"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _call_hf_inference(system, prompt, max_tokens, temperature):
|
| 102 |
+
try:
|
| 103 |
+
from huggingface_hub import InferenceClient
|
| 104 |
+
except ImportError:
|
| 105 |
+
return None, False, "huggingface_hub 패키지 미설치"
|
| 106 |
+
if not HF_TOKEN:
|
| 107 |
+
return None, False, "HF_TOKEN 환경변수 미설정"
|
| 108 |
+
try:
|
| 109 |
+
start = time.time()
|
| 110 |
+
client = InferenceClient(model=GEMMA_MODEL_HF, token=HF_TOKEN)
|
| 111 |
+
resp = client.chat_completion(
|
| 112 |
+
messages=[{"role": "system", "content": system},
|
| 113 |
+
{"role": "user", "content": prompt}],
|
| 114 |
+
max_tokens=max_tokens, temperature=temperature,
|
| 115 |
+
)
|
| 116 |
+
return resp.choices[0].message.content, True, f"elapsed={time.time()-start:.2f}s"
|
| 117 |
+
except Exception as e:
|
| 118 |
+
return None, False, f"{type(e).__name__}: {str(e)[:200]}"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _ensure_tx_loaded():
|
| 122 |
+
"""transformers 모델 lazy 로드 (Spaces ZeroGPU용)."""
|
| 123 |
+
global _tx_model, _tx_tokenizer
|
| 124 |
+
if _tx_model is not None:
|
| 125 |
+
return
|
| 126 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 127 |
+
import torch
|
| 128 |
+
_tx_tokenizer = AutoTokenizer.from_pretrained(GEMMA_MODEL_HF, token=HF_TOKEN or None)
|
| 129 |
+
_tx_model = AutoModelForCausalLM.from_pretrained(
|
| 130 |
+
GEMMA_MODEL_HF, torch_dtype=torch.bfloat16,
|
| 131 |
+
device_map="auto", token=HF_TOKEN or None,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _generate_tx(messages, max_tokens=1500, temperature=0.3):
|
| 136 |
+
import torch
|
| 137 |
+
inputs = _tx_tokenizer.apply_chat_template(
|
| 138 |
+
messages, return_tensors="pt", add_generation_prompt=True,
|
| 139 |
+
).to(_tx_model.device)
|
| 140 |
+
with torch.no_grad():
|
| 141 |
+
out = _tx_model.generate(
|
| 142 |
+
inputs,
|
| 143 |
+
max_new_tokens=max_tokens,
|
| 144 |
+
temperature=temperature if temperature > 0 else None,
|
| 145 |
+
do_sample=temperature > 0,
|
| 146 |
+
pad_token_id=_tx_tokenizer.eos_token_id,
|
| 147 |
+
)
|
| 148 |
+
response = _tx_tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
|
| 149 |
+
return response
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _call_transformers_local(system, prompt, max_tokens, temperature):
|
| 153 |
+
"""Spaces ZeroGPU에서 transformers 직접 추론. @spaces.GPU 데코레이터는 main.py에서 적용."""
|
| 154 |
+
try:
|
| 155 |
+
_ensure_tx_loaded()
|
| 156 |
+
start = time.time()
|
| 157 |
+
messages = [{"role": "system", "content": system},
|
| 158 |
+
{"role": "user", "content": prompt}]
|
| 159 |
+
answer = _generate_tx(messages, max_tokens, temperature)
|
| 160 |
+
return answer, True, f"elapsed={time.time()-start:.2f}s"
|
| 161 |
+
except Exception as e:
|
| 162 |
+
return None, False, f"{type(e).__name__}: {str(e)[:200]}"
|
api/main.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI 백엔드 — /api/ask 4구성 호출.
|
| 2 |
+
|
| 3 |
+
루트 구조:
|
| 4 |
+
GET /api/health — 헬스체크
|
| 5 |
+
GET /api/dataset/summary — 데이터셋 메타 (검토건 표, GP 표)
|
| 6 |
+
POST /api/ask — 질문 → 3개 답변(Python/Sonnet/Gemma) + route
|
| 7 |
+
POST /api/register — 사용자 등록 검토건 → 세션 내 그래프 merge
|
| 8 |
+
GET /api/download/{type} — paper/graph/ttl 정적 파일
|
| 9 |
+
|
| 10 |
+
환경변수:
|
| 11 |
+
- LLM_BACKEND (ollama|hf_inference|transformers_local)
|
| 12 |
+
- ANTHROPIC_API_KEY
|
| 13 |
+
- HF_TOKEN
|
| 14 |
+
- HF_GEMMA_MODEL (default: google/gemma-3-4b-it)
|
| 15 |
+
- OLLAMA_MODEL (default: gemma4:e4b)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any, Dict, List, Optional
|
| 25 |
+
|
| 26 |
+
from fastapi import FastAPI, HTTPException
|
| 27 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 29 |
+
from pydantic import BaseModel
|
| 30 |
+
|
| 31 |
+
# active/code 디렉토리를 path에 추가 (rag_engine·semantic_search 재사용)
|
| 32 |
+
API_DIR = Path(__file__).resolve().parent
|
| 33 |
+
HF_APP_DIR = API_DIR.parent
|
| 34 |
+
ACTIVE_DIR = HF_APP_DIR.parent
|
| 35 |
+
CODE_DIR = ACTIVE_DIR / "code"
|
| 36 |
+
sys.path.insert(0, str(CODE_DIR))
|
| 37 |
+
|
| 38 |
+
import rag_engine
|
| 39 |
+
import semantic_search as ss
|
| 40 |
+
import llm_adapters
|
| 41 |
+
|
| 42 |
+
# 데이터 경로 (Docker에서는 COPY로 같이 들어옴)
|
| 43 |
+
ONTOLOGY_DIR = ACTIVE_DIR / "ontology"
|
| 44 |
+
PAPER_PATH = ACTIVE_DIR / "paper_v5.pdf"
|
| 45 |
+
ONT_GRAPH_PNG = ACTIVE_DIR.parent.parent / "온톨로지" / "v08_ontology_graph.png"
|
| 46 |
+
TTL_PATH = ONTOLOGY_DIR / "investment_ontology_v1_10.ttl"
|
| 47 |
+
JSONL_PATH = ONTOLOGY_DIR / "regulations_chunks_v14.jsonl"
|
| 48 |
+
ALIAS_PATH = ONTOLOGY_DIR / "alias_dictionary.json"
|
| 49 |
+
LOOKUP_PATH = ONTOLOGY_DIR / "risk_weight_lookup.json"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ============================================================
|
| 53 |
+
# 앱 + 데이터 로드
|
| 54 |
+
# ============================================================
|
| 55 |
+
app = FastAPI(title="LP출자 온톨로지 LLM 프로토타입 API", version="1.0")
|
| 56 |
+
|
| 57 |
+
app.add_middleware(
|
| 58 |
+
CORSMiddleware,
|
| 59 |
+
allow_origins=["*"], # 개발 편의. 운영 시 도메인 제한.
|
| 60 |
+
allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# 전역 데이터 (앱 시작 시 1회 로드)
|
| 64 |
+
print(f"[startup] LLM_BACKEND = {llm_adapters.LLM_BACKEND}")
|
| 65 |
+
print(f"[startup] 데이터 로드: {TTL_PATH}")
|
| 66 |
+
GRAPH = rag_engine.load_ttl(TTL_PATH)
|
| 67 |
+
CHUNKS = rag_engine.load_chunks(JSONL_PATH)
|
| 68 |
+
ALIAS = rag_engine.load_alias(ALIAS_PATH)
|
| 69 |
+
LOOKUP = rag_engine.load_lookup(LOOKUP_PATH)
|
| 70 |
+
print(f"[startup] 트리플 {len(GRAPH):,}, 청크 {len(CHUNKS)}")
|
| 71 |
+
print(f"[startup] KoSimCSE warm-up...")
|
| 72 |
+
ss.warm_up(CHUNKS)
|
| 73 |
+
print(f"[startup] ready")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ============================================================
|
| 77 |
+
# 스키마
|
| 78 |
+
# ============================================================
|
| 79 |
+
class AskRequest(BaseModel):
|
| 80 |
+
question: str
|
| 81 |
+
mode: str = "axisB" # "axisB" | "keyword"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class AnswerCol(BaseModel):
|
| 85 |
+
answer: str
|
| 86 |
+
route: str
|
| 87 |
+
elapsed_sec: float = 0.0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AskResponse(BaseModel):
|
| 91 |
+
question: str
|
| 92 |
+
mode: str
|
| 93 |
+
route: str
|
| 94 |
+
python: AnswerCol # raw 컨텍스트 (LLM 호출 없음)
|
| 95 |
+
sonnet: AnswerCol
|
| 96 |
+
gemma: AnswerCol
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ============================================================
|
| 100 |
+
# 엔드포인트
|
| 101 |
+
# ============================================================
|
| 102 |
+
@app.get("/api/health")
|
| 103 |
+
def health():
|
| 104 |
+
return {
|
| 105 |
+
"ok": True,
|
| 106 |
+
"triples": len(GRAPH),
|
| 107 |
+
"chunks": len(CHUNKS),
|
| 108 |
+
"llm_backend": llm_adapters.LLM_BACKEND,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/api/dataset/summary")
|
| 113 |
+
def dataset_summary():
|
| 114 |
+
"""데이터셋 요약 — 검토건 표, GP 표."""
|
| 115 |
+
from rag_engine import (
|
| 116 |
+
query_all_investments_with_label, query_investment_branches,
|
| 117 |
+
query_investment_meta,
|
| 118 |
+
)
|
| 119 |
+
invs = query_all_investments_with_label(GRAPH)
|
| 120 |
+
fund_rows, gp_set = [], set()
|
| 121 |
+
for inv in invs:
|
| 122 |
+
if inv.get("n_branches", 0) == 0:
|
| 123 |
+
continue
|
| 124 |
+
meta = query_investment_meta(GRAPH, inv["iri"])
|
| 125 |
+
branches = query_investment_branches(GRAPH, inv["iri"])
|
| 126 |
+
amt = sum(int(float(b.get("amount", 0))) for b in branches) // 100000000
|
| 127 |
+
first_stage = branches[0].get("stage_label", "-") if branches else "-"
|
| 128 |
+
fund_rows.append({
|
| 129 |
+
"id": str(inv["iri"]).split("_")[-1],
|
| 130 |
+
"fund": inv["fund_label"][:25],
|
| 131 |
+
"amount_eok": amt,
|
| 132 |
+
"stage": first_stage,
|
| 133 |
+
"branches": len(branches),
|
| 134 |
+
})
|
| 135 |
+
if meta.get("gp_label"):
|
| 136 |
+
gp_set.add(meta["gp_label"])
|
| 137 |
+
return {
|
| 138 |
+
"funds": fund_rows,
|
| 139 |
+
"gps": [{"name": n, "id": f"gp-{i+1:03d}"} for i, n in enumerate(sorted(gp_set))],
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.post("/api/ask", response_model=AskResponse)
|
| 144 |
+
def ask(req: AskRequest):
|
| 145 |
+
q = req.question.strip()
|
| 146 |
+
if not q:
|
| 147 |
+
raise HTTPException(400, "question is empty")
|
| 148 |
+
mode = req.mode if req.mode in ("axisB", "keyword") else "axisB"
|
| 149 |
+
|
| 150 |
+
# Python column: LLM 답변 생성 없이 raw 컨텍스트
|
| 151 |
+
try:
|
| 152 |
+
if mode == "axisB":
|
| 153 |
+
r_py = rag_engine.answer_question_llm(
|
| 154 |
+
q, GRAPH, CHUNKS, ALIAS, LOOKUP,
|
| 155 |
+
use_anthropic=True, use_gemma_gen=False, use_semantic=True,
|
| 156 |
+
)
|
| 157 |
+
else:
|
| 158 |
+
r_py = rag_engine.answer_question(
|
| 159 |
+
q, GRAPH, CHUNKS, ALIAS, LOOKUP, use_gemma=False,
|
| 160 |
+
)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
r_py = {"answer": f"오류: {e}", "route": "error"}
|
| 163 |
+
|
| 164 |
+
route_str = r_py.get("route", "")
|
| 165 |
+
|
| 166 |
+
# Sonnet column
|
| 167 |
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
| 168 |
+
r_son = _call_sonnet(q, r_py.get("answer", ""))
|
| 169 |
+
else:
|
| 170 |
+
r_son = AnswerCol(answer="⚠️ ANTHROPIC_API_KEY 미설정", route="no_api", elapsed_sec=0)
|
| 171 |
+
|
| 172 |
+
# Gemma column
|
| 173 |
+
r_gem = _call_gemma_col(q, r_py.get("answer", ""))
|
| 174 |
+
|
| 175 |
+
return AskResponse(
|
| 176 |
+
question=q, mode=mode, route=route_str,
|
| 177 |
+
python=AnswerCol(answer=r_py.get("answer", ""), route=route_str, elapsed_sec=0.0),
|
| 178 |
+
sonnet=r_son,
|
| 179 |
+
gemma=r_gem,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.get("/api/download/{kind}")
|
| 184 |
+
def download(kind: str):
|
| 185 |
+
path_map = {
|
| 186 |
+
"paper": (PAPER_PATH, "LP출자_온톨로지_RAG_paper.pdf", "application/pdf"),
|
| 187 |
+
"graph": (ONT_GRAPH_PNG, "ontology_graph.png", "image/png"),
|
| 188 |
+
"ttl": (TTL_PATH, "investment_ontology_v1_10.ttl", "text/turtle"),
|
| 189 |
+
}
|
| 190 |
+
if kind not in path_map:
|
| 191 |
+
raise HTTPException(404, f"unknown kind: {kind}")
|
| 192 |
+
path, filename, media_type = path_map[kind]
|
| 193 |
+
if not path.exists():
|
| 194 |
+
raise HTTPException(404, f"file not found: {path}")
|
| 195 |
+
return FileResponse(str(path), filename=filename, media_type=media_type)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ============================================================
|
| 199 |
+
# 내부 헬퍼
|
| 200 |
+
# ============================================================
|
| 201 |
+
def _call_sonnet(question: str, context: str) -> AnswerCol:
|
| 202 |
+
import anthropic
|
| 203 |
+
start = time.time()
|
| 204 |
+
try:
|
| 205 |
+
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
| 206 |
+
system = (
|
| 207 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 208 |
+
"아래 컨텍스트의 사실을 그대로 유지하면서 자연스러운 한국어로 답변을 다듬어 주세요."
|
| 209 |
+
)
|
| 210 |
+
resp = client.messages.create(
|
| 211 |
+
model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
|
| 212 |
+
max_tokens=1500, temperature=0.3, system=system,
|
| 213 |
+
messages=[{"role": "user", "content": f"[컨텍스트]\n{context}\n\n[질문]\n{question}\n\n[답변]"}],
|
| 214 |
+
)
|
| 215 |
+
text = "".join(b.text for b in resp.content if hasattr(b, "text"))
|
| 216 |
+
return AnswerCol(answer=text, route="sonnet", elapsed_sec=time.time() - start)
|
| 217 |
+
except Exception as e:
|
| 218 |
+
return AnswerCol(answer=f"Sonnet 오류: {e}", route="error", elapsed_sec=time.time() - start)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _call_gemma_col(question: str, context: str) -> AnswerCol:
|
| 222 |
+
start = time.time()
|
| 223 |
+
answer, ok, info = llm_adapters.call_gemma(question, context)
|
| 224 |
+
if ok:
|
| 225 |
+
return AnswerCol(answer=answer, route="gemma", elapsed_sec=time.time() - start)
|
| 226 |
+
return AnswerCol(answer=f"Gemma 오류: {info}", route="error", elapsed_sec=time.time() - start)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ============================================================
|
| 230 |
+
# 정적 파일 — React 빌드 결과
|
| 231 |
+
# ============================================================
|
| 232 |
+
WEB_DIST = HF_APP_DIR / "web" / "dist"
|
| 233 |
+
if WEB_DIST.exists():
|
| 234 |
+
from fastapi.staticfiles import StaticFiles
|
| 235 |
+
app.mount("/", StaticFiles(directory=str(WEB_DIST), html=True), name="web")
|
api/requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
pydantic==2.10.4
|
| 4 |
+
python-multipart==0.0.20
|
| 5 |
+
|
| 6 |
+
# 기존 백엔드 의존성 (rag_engine, semantic_search)
|
| 7 |
+
rdflib==7.1.1
|
| 8 |
+
sentence-transformers==5.1.2
|
| 9 |
+
transformers==4.57.6
|
| 10 |
+
torch==2.8.0
|
| 11 |
+
numpy
|
| 12 |
+
|
| 13 |
+
# LLM 호출
|
| 14 |
+
anthropic==0.40.0
|
| 15 |
+
huggingface_hub==0.36.2
|
| 16 |
+
requests
|
| 17 |
+
ollama==0.4.4
|
| 18 |
+
|
| 19 |
+
# Spaces ZeroGPU
|
| 20 |
+
spaces
|
code/baseline_lib.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
baseline_lib.py
|
| 3 |
+
================
|
| 4 |
+
4가지 베이스라인 구성을 함수로 추상화한 라이브러리.
|
| 5 |
+
|
| 6 |
+
구성:
|
| 7 |
+
1. config_1_open_ontology_rag: Gemma 4 e4b + 온톨로지(SPARQL) + RAG + Lookup (= v5 시스템 그대로)
|
| 8 |
+
2. config_2_open_rag_only: Gemma 4 e4b + TTL 텍스트 + RAG (라우팅 X, lookup X)
|
| 9 |
+
3. config_3_frontier_ontology_rag: Claude Sonnet 4.6 + 온톨로지(SPARQL) + RAG + Lookup
|
| 10 |
+
4. config_4_frontier_rag_only: Claude Sonnet 4.6 + TTL 텍스트 + RAG
|
| 11 |
+
|
| 12 |
+
모든 구성은 동일한 인터페이스 ask(question) → dict 를 가진다.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
# ============================================
|
| 22 |
+
# 환경변수 / 모델명
|
| 23 |
+
# ============================================
|
| 24 |
+
|
| 25 |
+
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:e4b")
|
| 26 |
+
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
| 27 |
+
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ============================================
|
| 31 |
+
# 데이터 로드 (캐시)
|
| 32 |
+
# ============================================
|
| 33 |
+
|
| 34 |
+
_data_cache = {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_data(data_dir):
|
| 38 |
+
"""데이터 한 번만 로드해서 캐시"""
|
| 39 |
+
data_dir = Path(data_dir)
|
| 40 |
+
if str(data_dir) in _data_cache:
|
| 41 |
+
return _data_cache[str(data_dir)]
|
| 42 |
+
|
| 43 |
+
# rag_engine 모듈 활용 (같은 폴더에 있어야 함)
|
| 44 |
+
import sys
|
| 45 |
+
streamlit_app_dir = data_dir.parent
|
| 46 |
+
if str(streamlit_app_dir) not in sys.path:
|
| 47 |
+
sys.path.insert(0, str(streamlit_app_dir))
|
| 48 |
+
|
| 49 |
+
import rag_engine
|
| 50 |
+
|
| 51 |
+
g = rag_engine.load_ttl(data_dir / "investment_ontology_v1_10.ttl")
|
| 52 |
+
chunks = rag_engine.load_chunks(data_dir / "regulations_chunks_v14.jsonl")
|
| 53 |
+
alias = rag_engine.load_alias(data_dir / "alias_dictionary.json")
|
| 54 |
+
lookup = rag_engine.load_lookup(data_dir / "risk_weight_lookup.json")
|
| 55 |
+
|
| 56 |
+
_data_cache[str(data_dir)] = {
|
| 57 |
+
"g": g, "chunks": chunks, "alias": alias, "lookup": lookup,
|
| 58 |
+
"rag_engine": rag_engine,
|
| 59 |
+
}
|
| 60 |
+
return _data_cache[str(data_dir)]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ============================================
|
| 64 |
+
# TTL → 자연어 텍스트 변환 (RAG only 구성용)
|
| 65 |
+
# ============================================
|
| 66 |
+
|
| 67 |
+
_ttl_text_cache = None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_ttl_as_text(g):
|
| 71 |
+
"""
|
| 72 |
+
TTL 그래프를 자연어 텍스트로 변환.
|
| 73 |
+
RAG only 구성에서 LLM 컨텍스트로 주입할 형태.
|
| 74 |
+
|
| 75 |
+
크기 제약: 약 8,000~12,000 토큰 (한국어 기준 ~16,000~24,000자) 이내.
|
| 76 |
+
"""
|
| 77 |
+
global _ttl_text_cache
|
| 78 |
+
if _ttl_text_cache is not None:
|
| 79 |
+
return _ttl_text_cache
|
| 80 |
+
|
| 81 |
+
sections = []
|
| 82 |
+
|
| 83 |
+
# 섹션 1: 모든 검토건과 분기 (가장 중요)
|
| 84 |
+
sections.append("## 검토건 및 분기 정보\n")
|
| 85 |
+
sections.append("본 시스템에 등록된 가상 검토건은 다음과 같다:\n")
|
| 86 |
+
|
| 87 |
+
q = """
|
| 88 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 89 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 90 |
+
SELECT ?inv ?invLabel ?branchLabel ?productLabel ?amount ?stageLabel ?stateLabel ?stateOrder WHERE {
|
| 91 |
+
?inv a inv:Investment ;
|
| 92 |
+
rdfs:label ?invLabel ;
|
| 93 |
+
inv:hasBranch ?branch .
|
| 94 |
+
?branch rdfs:label ?branchLabel ;
|
| 95 |
+
inv:hasProductType ?product ;
|
| 96 |
+
inv:hasInvestmentAmount ?amount ;
|
| 97 |
+
inv:hasCurrentStage ?stage ;
|
| 98 |
+
inv:hasBranchState ?state .
|
| 99 |
+
?product rdfs:label ?productLabel .
|
| 100 |
+
?stage rdfs:label ?stageLabel .
|
| 101 |
+
?state rdfs:label ?stateLabel ;
|
| 102 |
+
inv:hasStateOrder ?stateOrder .
|
| 103 |
+
FILTER(LANG(?invLabel)="ko")
|
| 104 |
+
FILTER(LANG(?branchLabel)="ko")
|
| 105 |
+
FILTER(LANG(?productLabel)="ko")
|
| 106 |
+
FILTER(LANG(?stageLabel)="ko")
|
| 107 |
+
FILTER(LANG(?stateLabel)="ko")
|
| 108 |
+
} ORDER BY ?inv ?branchLabel
|
| 109 |
+
"""
|
| 110 |
+
current_inv = None
|
| 111 |
+
for row in g.query(q):
|
| 112 |
+
inv_id = str(row.inv).split("#")[-1]
|
| 113 |
+
if inv_id != current_inv:
|
| 114 |
+
current_inv = inv_id
|
| 115 |
+
sections.append(f"\n**{row.invLabel}** ({inv_id}):")
|
| 116 |
+
amount_eok = int(float(str(row.amount))) // 100000000
|
| 117 |
+
sections.append(
|
| 118 |
+
f" - {row.branchLabel}: 상품={row.productLabel}, 금액={amount_eok}억, "
|
| 119 |
+
f"단계={row.stageLabel}, 상태={row.stateLabel} (state_order={row.stateOrder})"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# 섹션 2: 분류 원칙 (ClassificationPrinciple)
|
| 123 |
+
sections.append("\n## 도메인 분류 원칙 (ClassificationPrinciple)\n")
|
| 124 |
+
q2 = """
|
| 125 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 126 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 127 |
+
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
| 128 |
+
SELECT ?p ?label ?comment WHERE {
|
| 129 |
+
?p a inv:ClassificationPrinciple ;
|
| 130 |
+
rdfs:label ?label .
|
| 131 |
+
OPTIONAL { ?p rdfs:comment ?comment . FILTER(LANG(?comment)="ko") }
|
| 132 |
+
FILTER(LANG(?label)="ko")
|
| 133 |
+
}
|
| 134 |
+
"""
|
| 135 |
+
for row in g.query(q2):
|
| 136 |
+
sections.append(f"- **{row.label}**")
|
| 137 |
+
if row.comment:
|
| 138 |
+
sections.append(f" - 설명: {row.comment}")
|
| 139 |
+
|
| 140 |
+
# 섹션 3: 주요 클래스 정의 (간단히)
|
| 141 |
+
sections.append("\n## 주요 클래스 정의\n")
|
| 142 |
+
important_classes = [
|
| 143 |
+
"Investment", "InvestmentBranch", "BusinessProcess", "BranchState",
|
| 144 |
+
"Counterparty", "GP", "RecipientFund", "PortfolioCompany",
|
| 145 |
+
"RegulatoryClause", "RegulatoryConcept", "ClassificationPrinciple",
|
| 146 |
+
]
|
| 147 |
+
for cls in important_classes:
|
| 148 |
+
q3 = f"""
|
| 149 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 150 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 151 |
+
SELECT ?label ?comment WHERE {{
|
| 152 |
+
inv:{cls} rdfs:label ?label .
|
| 153 |
+
OPTIONAL {{ inv:{cls} rdfs:comment ?comment . FILTER(LANG(?comment)="ko") }}
|
| 154 |
+
FILTER(LANG(?label)="ko")
|
| 155 |
+
}}
|
| 156 |
+
"""
|
| 157 |
+
for row in g.query(q3):
|
| 158 |
+
line = f"- **{cls}** ({row.label})"
|
| 159 |
+
if row.comment:
|
| 160 |
+
line += f": {str(row.comment)[:200]}"
|
| 161 |
+
sections.append(line)
|
| 162 |
+
|
| 163 |
+
# 섹션 4: BranchState 인스턴스 (state_order 포함)
|
| 164 |
+
sections.append("\n## 브랜치 상태(BranchState) 정의 및 순서\n")
|
| 165 |
+
q4 = """
|
| 166 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 167 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 168 |
+
SELECT ?s ?label ?order WHERE {
|
| 169 |
+
?s a inv:BranchState ;
|
| 170 |
+
rdfs:label ?label ;
|
| 171 |
+
inv:hasStateOrder ?order .
|
| 172 |
+
FILTER(LANG(?label)="ko")
|
| 173 |
+
} ORDER BY ?order
|
| 174 |
+
"""
|
| 175 |
+
for row in g.query(q4):
|
| 176 |
+
s_id = str(row.s).split("#")[-1]
|
| 177 |
+
sections.append(f"- {s_id} ({row.label}): state_order={row.order}")
|
| 178 |
+
|
| 179 |
+
text = "\n".join(sections)
|
| 180 |
+
_ttl_text_cache = text
|
| 181 |
+
return text
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ============================================
|
| 185 |
+
# 정제된 RAG 청크 검색 (RAG only 구성용)
|
| 186 |
+
# ============================================
|
| 187 |
+
|
| 188 |
+
def search_chunks_for_rag_only(chunks, question, top_k=5):
|
| 189 |
+
"""
|
| 190 |
+
RAG only 구성에서 사용할 청크 검색.
|
| 191 |
+
키워드 매칭 기반.
|
| 192 |
+
"""
|
| 193 |
+
# 한국어 단어 추출 (2자 이상)
|
| 194 |
+
import re
|
| 195 |
+
keywords = re.findall(r'[가-힣A-Za-z0-9]{2,}', question)
|
| 196 |
+
keywords = [k for k in keywords if len(k) >= 2]
|
| 197 |
+
|
| 198 |
+
scored = []
|
| 199 |
+
for c in chunks:
|
| 200 |
+
text = c.get("text", "")
|
| 201 |
+
score = 0
|
| 202 |
+
for kw in keywords:
|
| 203 |
+
score += text.count(kw)
|
| 204 |
+
# concept 매칭도 가중치
|
| 205 |
+
meta = c.get("metadata", {})
|
| 206 |
+
concepts = meta.get("regulatory_concepts", [])
|
| 207 |
+
for concept_id in concepts:
|
| 208 |
+
if any(kw in concept_id for kw in keywords):
|
| 209 |
+
score += 5
|
| 210 |
+
if score > 0:
|
| 211 |
+
scored.append((score, c))
|
| 212 |
+
|
| 213 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 214 |
+
return [c for _, c in scored[:top_k]]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ============================================
|
| 218 |
+
# LLM 호출 함수 (오픈/프론티어)
|
| 219 |
+
# ============================================
|
| 220 |
+
|
| 221 |
+
def call_open_llm(question, context, system_prompt=None, model_name=None, timeout=180):
|
| 222 |
+
"""
|
| 223 |
+
Ollama (Gemma 4 e4b) 호출.
|
| 224 |
+
실패 시 (None, False, error_msg) 반환.
|
| 225 |
+
"""
|
| 226 |
+
if model_name is None:
|
| 227 |
+
model_name = OLLAMA_MODEL
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
import ollama
|
| 231 |
+
except ImportError:
|
| 232 |
+
return None, False, "ollama 패키지 미설치"
|
| 233 |
+
|
| 234 |
+
if system_prompt is None:
|
| 235 |
+
system_prompt = (
|
| 236 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 237 |
+
"아래 컨텍스트의 정보만 사용하여 답변하세요. "
|
| 238 |
+
"컨텍스트에 없는 내용은 추측하지 말고, 모르면 모른다고 답하세요. "
|
| 239 |
+
"한국어 격식체로 간결하게 답변하세요."
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
full_prompt = f"[컨텍스트]\n{context}\n\n[질문]\n{question}\n\n[답변]"
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
start = time.time()
|
| 246 |
+
resp = ollama.chat(
|
| 247 |
+
model=model_name,
|
| 248 |
+
messages=[
|
| 249 |
+
{"role": "system", "content": system_prompt},
|
| 250 |
+
{"role": "user", "content": full_prompt},
|
| 251 |
+
],
|
| 252 |
+
options={"num_predict": 1500, "temperature": 0.3, "repeat_penalty": 1.15},
|
| 253 |
+
)
|
| 254 |
+
elapsed = time.time() - start
|
| 255 |
+
return resp["message"]["content"], True, f"elapsed={elapsed:.2f}s"
|
| 256 |
+
except Exception as e:
|
| 257 |
+
return None, False, f"{type(e).__name__}: {str(e)[:200]}"
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def call_frontier_llm(question, context, system_prompt=None, model_name=None, timeout=120):
|
| 261 |
+
"""
|
| 262 |
+
Anthropic Claude API (Sonnet 4.6) 호출.
|
| 263 |
+
실패 시 (None, False, error_msg) 반환.
|
| 264 |
+
"""
|
| 265 |
+
if model_name is None:
|
| 266 |
+
model_name = ANTHROPIC_MODEL
|
| 267 |
+
|
| 268 |
+
if not ANTHROPIC_API_KEY:
|
| 269 |
+
return None, False, "ANTHROPIC_API_KEY 환경변수 미설정"
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
import anthropic
|
| 273 |
+
except ImportError:
|
| 274 |
+
return None, False, "anthropic 패키지 미설치"
|
| 275 |
+
|
| 276 |
+
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
| 277 |
+
|
| 278 |
+
if system_prompt is None:
|
| 279 |
+
system_prompt = (
|
| 280 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 281 |
+
"아래 컨텍스트의 정보만 사용하여 답변하세요. "
|
| 282 |
+
"컨텍스트에 없는 내용은 추측하지 말고, 모르면 모른다고 답하세요. "
|
| 283 |
+
"한국어 격식체로 간결하게 답변하세요."
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
full_prompt = f"[컨텍스트]\n{context}\n\n[질문]\n{question}\n\n[답변]"
|
| 287 |
+
|
| 288 |
+
try:
|
| 289 |
+
start = time.time()
|
| 290 |
+
resp = client.messages.create(
|
| 291 |
+
model=model_name,
|
| 292 |
+
max_tokens=1500,
|
| 293 |
+
temperature=0.3,
|
| 294 |
+
system=system_prompt,
|
| 295 |
+
messages=[
|
| 296 |
+
{"role": "user", "content": full_prompt},
|
| 297 |
+
],
|
| 298 |
+
)
|
| 299 |
+
elapsed = time.time() - start
|
| 300 |
+
# Claude 응답은 content 배열
|
| 301 |
+
text = ""
|
| 302 |
+
for block in resp.content:
|
| 303 |
+
if hasattr(block, "text"):
|
| 304 |
+
text += block.text
|
| 305 |
+
return text, True, f"elapsed={elapsed:.2f}s"
|
| 306 |
+
except Exception as e:
|
| 307 |
+
return None, False, f"{type(e).__name__}: {str(e)[:200]}"
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ============================================
|
| 311 |
+
# 4가지 구성
|
| 312 |
+
# ============================================
|
| 313 |
+
|
| 314 |
+
def config_1_open_ontology_rag(question, data, **kwargs):
|
| 315 |
+
"""
|
| 316 |
+
구성 1: Gemma 4 e4b + 온톨로지(SPARQL) + RAG + Lookup
|
| 317 |
+
= v5 시스템 그대로 활용 (rag_engine.answer_question)
|
| 318 |
+
"""
|
| 319 |
+
rag_engine = data["rag_engine"]
|
| 320 |
+
g = data["g"]
|
| 321 |
+
chunks = data["chunks"]
|
| 322 |
+
alias = data["alias"]
|
| 323 |
+
lookup = data["lookup"]
|
| 324 |
+
|
| 325 |
+
start = time.time()
|
| 326 |
+
try:
|
| 327 |
+
result = rag_engine.answer_question(
|
| 328 |
+
question, g, chunks, alias, lookup,
|
| 329 |
+
user_instances=None,
|
| 330 |
+
use_gemma=True,
|
| 331 |
+
model_name=OLLAMA_MODEL,
|
| 332 |
+
)
|
| 333 |
+
elapsed = time.time() - start
|
| 334 |
+
return {
|
| 335 |
+
"answer": result["answer"],
|
| 336 |
+
"route": result["route"],
|
| 337 |
+
"context_summary": result["context_summary"],
|
| 338 |
+
"elapsed_sec": elapsed,
|
| 339 |
+
"success": True,
|
| 340 |
+
"error": "",
|
| 341 |
+
}
|
| 342 |
+
except Exception as e:
|
| 343 |
+
return {
|
| 344 |
+
"answer": "",
|
| 345 |
+
"route": "error",
|
| 346 |
+
"context_summary": "",
|
| 347 |
+
"elapsed_sec": time.time() - start,
|
| 348 |
+
"success": False,
|
| 349 |
+
"error": f"{type(e).__name__}: {str(e)[:300]}",
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def config_2_open_rag_only(question, data, **kwargs):
|
| 354 |
+
"""
|
| 355 |
+
구성 2: Gemma 4 e4b + TTL 텍스트 + RAG (라우팅 X, lookup X)
|
| 356 |
+
"""
|
| 357 |
+
g = data["g"]
|
| 358 |
+
chunks = data["chunks"]
|
| 359 |
+
|
| 360 |
+
# 컨텍스트 구성
|
| 361 |
+
ttl_text = get_ttl_as_text(g)
|
| 362 |
+
relevant_chunks = search_chunks_for_rag_only(chunks, question, top_k=5)
|
| 363 |
+
chunk_context = "\n\n".join([
|
| 364 |
+
f"[규제 청크 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 365 |
+
for i, c in enumerate(relevant_chunks)
|
| 366 |
+
])
|
| 367 |
+
|
| 368 |
+
full_context = (
|
| 369 |
+
f"# 도메인 온톨로지 (텍스트 형식)\n\n{ttl_text}\n\n"
|
| 370 |
+
f"---\n\n# 관련 규제 청크\n\n{chunk_context}"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
start = time.time()
|
| 374 |
+
answer, ok, info = call_open_llm(question, full_context)
|
| 375 |
+
elapsed = time.time() - start
|
| 376 |
+
|
| 377 |
+
if ok:
|
| 378 |
+
return {
|
| 379 |
+
"answer": answer,
|
| 380 |
+
"route": "rag_only_open",
|
| 381 |
+
"context_summary": f"TTL 텍스트 + {len(relevant_chunks)}개 청크",
|
| 382 |
+
"elapsed_sec": elapsed,
|
| 383 |
+
"success": True,
|
| 384 |
+
"error": "",
|
| 385 |
+
}
|
| 386 |
+
else:
|
| 387 |
+
return {
|
| 388 |
+
"answer": "",
|
| 389 |
+
"route": "error",
|
| 390 |
+
"context_summary": "",
|
| 391 |
+
"elapsed_sec": elapsed,
|
| 392 |
+
"success": False,
|
| 393 |
+
"error": info,
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def config_3_frontier_ontology_rag(question, data, **kwargs):
|
| 398 |
+
"""
|
| 399 |
+
구성 3: Claude Sonnet 4.6 + 온톨로지(SPARQL) + RAG + Lookup
|
| 400 |
+
|
| 401 |
+
v5 시스템의 라우팅·SPARQL·lookup은 그대로 사용하되,
|
| 402 |
+
Gemma 호출 부분만 Claude API로 교체.
|
| 403 |
+
"""
|
| 404 |
+
rag_engine = data["rag_engine"]
|
| 405 |
+
g = data["g"]
|
| 406 |
+
chunks = data["chunks"]
|
| 407 |
+
alias = data["alias"]
|
| 408 |
+
lookup = data["lookup"]
|
| 409 |
+
|
| 410 |
+
start = time.time()
|
| 411 |
+
try:
|
| 412 |
+
# v5 라우팅을 그대로 따라가되, LLM 호출은 Claude로
|
| 413 |
+
# 라우팅 결정만 받기 위해 use_gemma=False로 호출 후, LLM 필요한 경우 직접 처리
|
| 414 |
+
# 단, Q9 (deterministic_lookup)이나 Q1~Q5 (template) 은 LLM 호출 없으므로 그대로 사용
|
| 415 |
+
|
| 416 |
+
# 먼저 use_gemma=False로 호출 → 라우팅 결과 + (LLM이 필요했다면) raw 컨텍스트 받기
|
| 417 |
+
result_no_llm = rag_engine.answer_question(
|
| 418 |
+
question, g, chunks, alias, lookup,
|
| 419 |
+
user_instances=None,
|
| 420 |
+
use_gemma=False,
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
route = result_no_llm["route"]
|
| 424 |
+
|
| 425 |
+
# 라우팅별 처리
|
| 426 |
+
# - investment_status, stage_threshold, review_stalled, deterministic_lookup, lookup_table_overview, guidance:
|
| 427 |
+
# LLM 호출 없는 라우팅 → 그대로 반환
|
| 428 |
+
# - instance_with_concept, rag_concept:
|
| 429 |
+
# LLM 호출 필요 → Claude로 다시 호출
|
| 430 |
+
|
| 431 |
+
no_llm_routes = ("investment_status", "stage_threshold", "review_stalled",
|
| 432 |
+
"deterministic_lookup", "lookup_table_overview", "guidance",
|
| 433 |
+
"rag_concept_no_match", "system_notification")
|
| 434 |
+
|
| 435 |
+
if any(r in route for r in no_llm_routes):
|
| 436 |
+
# LLM 안 쓰는 라우팅 → 결과 그대로
|
| 437 |
+
return {
|
| 438 |
+
"answer": result_no_llm["answer"],
|
| 439 |
+
"route": route + " (no_llm)",
|
| 440 |
+
"context_summary": result_no_llm["context_summary"],
|
| 441 |
+
"elapsed_sec": time.time() - start,
|
| 442 |
+
"success": True,
|
| 443 |
+
"error": "",
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
# LLM 필요한 라우팅 → 컨텍스트 재구성 후 Claude 호출
|
| 447 |
+
# rag_engine 내부 로직 일부 재현해야 함
|
| 448 |
+
|
| 449 |
+
# 인스턴스 매칭 시도 (instance_with_concept인 경우)
|
| 450 |
+
# 또는 단순 RAG 검색 (rag_concept인 경우)
|
| 451 |
+
|
| 452 |
+
concept_id = rag_engine.detect_concept_from_question(question)
|
| 453 |
+
|
| 454 |
+
if concept_id:
|
| 455 |
+
# 펀드명 매칭 시도
|
| 456 |
+
import re
|
| 457 |
+
GENERIC_WORDS = {
|
| 458 |
+
"검토", "검토건", "단계", "진행", "어디", "상태", "약정", "출자",
|
| 459 |
+
"어떻게", "지금", "현재", "이후", "정체", "관리", "신청", "등록",
|
| 460 |
+
"펀드", "투자", "금융", "규제", "조항", "법령",
|
| 461 |
+
"RWA", "rwa", "위험가중치", "익스포져", "lp출자", "LP출자",
|
| 462 |
+
}
|
| 463 |
+
matched_inv = None
|
| 464 |
+
all_invs = rag_engine.query_all_investments_with_label(g)
|
| 465 |
+
for inv in all_invs:
|
| 466 |
+
if inv.get("n_branches", 0) == 0:
|
| 467 |
+
continue
|
| 468 |
+
label_words = re.findall(r"[가-힣A-Za-z0-9]{2,}", inv["label"] + " " + inv["fund_label"])
|
| 469 |
+
distinctive = [w for w in label_words if w not in GENERIC_WORDS]
|
| 470 |
+
for word in distinctive:
|
| 471 |
+
if len(word) >= 2 and word in question:
|
| 472 |
+
matched_inv = inv["iri"]
|
| 473 |
+
break
|
| 474 |
+
if matched_inv:
|
| 475 |
+
break
|
| 476 |
+
|
| 477 |
+
prefer_summary = (concept_id == "Concept_RWA_Calculation")
|
| 478 |
+
relevant_chunks = rag_engine.search_chunks_by_concept(
|
| 479 |
+
chunks, concept_id, top_k=3, prefer_summary=prefer_summary
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if matched_inv:
|
| 483 |
+
meta = rag_engine.query_investment_meta(g, matched_inv)
|
| 484 |
+
branches = rag_engine.query_investment_branches(g, matched_inv)
|
| 485 |
+
instance_context = rag_engine.template_investment_status(meta, branches)
|
| 486 |
+
|
| 487 |
+
chunk_context = "\n\n".join([
|
| 488 |
+
f"[규제 근거 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 489 |
+
for i, c in enumerate(relevant_chunks)
|
| 490 |
+
])
|
| 491 |
+
full_context = f"{instance_context}\n\n---\n\n[관련 규제 근거]\n{chunk_context}"
|
| 492 |
+
else:
|
| 493 |
+
chunk_context = "\n\n".join([
|
| 494 |
+
f"[근거 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 495 |
+
for i, c in enumerate(relevant_chunks)
|
| 496 |
+
])
|
| 497 |
+
full_context = chunk_context
|
| 498 |
+
|
| 499 |
+
answer, ok, info = call_frontier_llm(question, full_context)
|
| 500 |
+
elapsed = time.time() - start
|
| 501 |
+
|
| 502 |
+
if ok:
|
| 503 |
+
return {
|
| 504 |
+
"answer": answer,
|
| 505 |
+
"route": route + " (frontier_llm)",
|
| 506 |
+
"context_summary": f"{result_no_llm['context_summary']} + Frontier LLM 다듬기",
|
| 507 |
+
"elapsed_sec": elapsed,
|
| 508 |
+
"success": True,
|
| 509 |
+
"error": "",
|
| 510 |
+
}
|
| 511 |
+
else:
|
| 512 |
+
return {
|
| 513 |
+
"answer": "",
|
| 514 |
+
"route": "error",
|
| 515 |
+
"context_summary": "",
|
| 516 |
+
"elapsed_sec": elapsed,
|
| 517 |
+
"success": False,
|
| 518 |
+
"error": info,
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
# concept 못 찾았는데 LLM 라우팅이었다면 — fallback
|
| 522 |
+
return {
|
| 523 |
+
"answer": result_no_llm["answer"],
|
| 524 |
+
"route": route + " (no_llm_fallback)",
|
| 525 |
+
"context_summary": result_no_llm["context_summary"],
|
| 526 |
+
"elapsed_sec": time.time() - start,
|
| 527 |
+
"success": True,
|
| 528 |
+
"error": "",
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
return {
|
| 533 |
+
"answer": "",
|
| 534 |
+
"route": "error",
|
| 535 |
+
"context_summary": "",
|
| 536 |
+
"elapsed_sec": time.time() - start,
|
| 537 |
+
"success": False,
|
| 538 |
+
"error": f"{type(e).__name__}: {str(e)[:300]}",
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _axisB_rag_only_context(g, chunks, question, top_k=5):
|
| 543 |
+
"""RAG-only 구성용 axisB 컨텍스트: TTL 텍스트 + KoSimCSE 청크 의미검색."""
|
| 544 |
+
ttl_text = get_ttl_as_text(g)
|
| 545 |
+
try:
|
| 546 |
+
import semantic_search as ss
|
| 547 |
+
sem_chunks = ss.search_chunks_semantic(chunks, question, top_k=top_k, min_score=0.30)
|
| 548 |
+
except Exception:
|
| 549 |
+
# 의미검색 실패(import 실패 등) 시 키워드 fallback
|
| 550 |
+
sem_chunks = search_chunks_for_rag_only(chunks, question, top_k=top_k)
|
| 551 |
+
chunk_context = "\n\n".join([
|
| 552 |
+
f"[규제 청크 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 553 |
+
for i, c in enumerate(sem_chunks)
|
| 554 |
+
])
|
| 555 |
+
return (
|
| 556 |
+
f"# 도메인 온톨로지 (텍스트 형식)\n\n{ttl_text}\n\n"
|
| 557 |
+
f"---\n\n# 관련 규제 청크 (KoSimCSE 의미검색)\n\n{chunk_context}"
|
| 558 |
+
), len(sem_chunks)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def config_1_axisB(question, data, **kwargs):
|
| 562 |
+
"""구성 1 axisB: Gemma + 온톨로지 + LLM 의도파서 + KoSimCSE 의미검색.
|
| 563 |
+
answer_question_llm(use_anthropic=False, use_semantic=True) 호출.
|
| 564 |
+
"""
|
| 565 |
+
rag_engine = data["rag_engine"]
|
| 566 |
+
g, chunks, alias, lookup = data["g"], data["chunks"], data["alias"], data["lookup"]
|
| 567 |
+
# 임베딩 캐시 미리 (첫 호출 지연 분산)
|
| 568 |
+
try:
|
| 569 |
+
import semantic_search as ss; ss.warm_up(chunks)
|
| 570 |
+
except Exception:
|
| 571 |
+
pass
|
| 572 |
+
start = time.time()
|
| 573 |
+
try:
|
| 574 |
+
result = rag_engine.answer_question_llm(
|
| 575 |
+
question, g, chunks, alias, lookup,
|
| 576 |
+
model_name=OLLAMA_MODEL,
|
| 577 |
+
use_anthropic=False, use_gemma_gen=True, use_semantic=True,
|
| 578 |
+
)
|
| 579 |
+
return {
|
| 580 |
+
"answer": result["answer"], "route": result["route"],
|
| 581 |
+
"context_summary": result["context_summary"],
|
| 582 |
+
"elapsed_sec": time.time() - start, "success": True, "error": "",
|
| 583 |
+
}
|
| 584 |
+
except Exception as e:
|
| 585 |
+
return {"answer": "", "route": "error", "context_summary": "",
|
| 586 |
+
"elapsed_sec": time.time() - start, "success": False,
|
| 587 |
+
"error": f"{type(e).__name__}: {str(e)[:300]}"}
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def config_2_axisB(question, data, **kwargs):
|
| 591 |
+
"""구성 2 axisB: Gemma + RAG only + KoSimCSE 청크 의미검색 (LLM 파서는 라우팅 없으므로 미적용)."""
|
| 592 |
+
g, chunks = data["g"], data["chunks"]
|
| 593 |
+
full_context, n_chunks = _axisB_rag_only_context(g, chunks, question, top_k=5)
|
| 594 |
+
start = time.time()
|
| 595 |
+
answer, ok, info = call_open_llm(question, full_context)
|
| 596 |
+
elapsed = time.time() - start
|
| 597 |
+
if ok:
|
| 598 |
+
return {"answer": answer, "route": "rag_only_open_axisB",
|
| 599 |
+
"context_summary": f"TTL 텍스트 + {n_chunks}개 청크(KoSimCSE)",
|
| 600 |
+
"elapsed_sec": elapsed, "success": True, "error": ""}
|
| 601 |
+
return {"answer": "", "route": "error", "context_summary": "",
|
| 602 |
+
"elapsed_sec": elapsed, "success": False, "error": info}
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def config_3_axisB(question, data, **kwargs):
|
| 606 |
+
"""구성 3 axisB: Sonnet + 온톨로지 + LLM 의도파서 + KoSimCSE.
|
| 607 |
+
answer_question_llm은 LLM 파서/생성 둘 다 같은 모델 가정 → use_anthropic=True로 Sonnet.
|
| 608 |
+
답변 생성도 Sonnet로 통일하기 위해, use_gemma_gen=False + 후처리로 Sonnet 호출.
|
| 609 |
+
"""
|
| 610 |
+
rag_engine = data["rag_engine"]
|
| 611 |
+
g, chunks, alias, lookup = data["g"], data["chunks"], data["alias"], data["lookup"]
|
| 612 |
+
try:
|
| 613 |
+
import semantic_search as ss; ss.warm_up(chunks)
|
| 614 |
+
except Exception:
|
| 615 |
+
pass
|
| 616 |
+
start = time.time()
|
| 617 |
+
try:
|
| 618 |
+
# 파서·라우팅까지 Sonnet으로. answer_question_llm은 답변 생성이 use_gemma_gen 플래그.
|
| 619 |
+
# Sonnet 생성으로 통일하려면 use_gemma_gen=False로 raw 컨텍스트 받고 call_frontier_llm.
|
| 620 |
+
# 단 answer_question_llm는 use_gemma_gen=False 시 raw 텍스트 답변(템플릿 그대로) 반환 → 그걸 그대로 쓰거나 후처리.
|
| 621 |
+
# 여기선 단순화: use_gemma_gen=True 의미는 "온톨로지 라우트에서 LLM 다듬기"인데 Sonnet 통일을 위해
|
| 622 |
+
# 1차로 use_gemma_gen=False 호출, 2차로 rag_concept 류이면 컨텍스트만 잡아 Sonnet 호출.
|
| 623 |
+
result = rag_engine.answer_question_llm(
|
| 624 |
+
question, g, chunks, alias, lookup,
|
| 625 |
+
model_name=OLLAMA_MODEL, # 안 쓰임(파서가 use_anthropic=True)
|
| 626 |
+
use_anthropic=True, use_gemma_gen=False, use_semantic=True,
|
| 627 |
+
)
|
| 628 |
+
route = result.get("route", "")
|
| 629 |
+
# Sonnet 답변 생성이 필요한 라우트 (LLM 다듬기 필요)
|
| 630 |
+
llm_needed_routes = ("rag_concept_semantic", "instance_with_concept", "rag_concept ")
|
| 631 |
+
if any(rt in route for rt in llm_needed_routes):
|
| 632 |
+
# raw 답변(=청크/컨텍스트 나열)을 Sonnet에게 다듬게 함
|
| 633 |
+
raw_ctx = result["answer"]
|
| 634 |
+
answer, ok, info = call_frontier_llm(question, raw_ctx)
|
| 635 |
+
if ok:
|
| 636 |
+
return {"answer": answer, "route": route + " (frontier_llm)",
|
| 637 |
+
"context_summary": result["context_summary"] + " + Sonnet 다듬기",
|
| 638 |
+
"elapsed_sec": time.time() - start, "success": True, "error": ""}
|
| 639 |
+
return {"answer": "", "route": "error", "context_summary": "",
|
| 640 |
+
"elapsed_sec": time.time() - start, "success": False, "error": info}
|
| 641 |
+
# 그 외(템플릿 라우트, deterministic_lookup 등) — 답변이 이미 결정적이라 LLM 다듬기 불필요
|
| 642 |
+
return {"answer": result["answer"], "route": route + " (no_llm)",
|
| 643 |
+
"context_summary": result["context_summary"],
|
| 644 |
+
"elapsed_sec": time.time() - start, "success": True, "error": ""}
|
| 645 |
+
except Exception as e:
|
| 646 |
+
return {"answer": "", "route": "error", "context_summary": "",
|
| 647 |
+
"elapsed_sec": time.time() - start, "success": False,
|
| 648 |
+
"error": f"{type(e).__name__}: {str(e)[:300]}"}
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def config_4_axisB(question, data, **kwargs):
|
| 652 |
+
"""구성 4 axisB: Sonnet + RAG only + KoSimCSE 청크 의미검색."""
|
| 653 |
+
g, chunks = data["g"], data["chunks"]
|
| 654 |
+
full_context, n_chunks = _axisB_rag_only_context(g, chunks, question, top_k=5)
|
| 655 |
+
start = time.time()
|
| 656 |
+
answer, ok, info = call_frontier_llm(question, full_context)
|
| 657 |
+
elapsed = time.time() - start
|
| 658 |
+
if ok:
|
| 659 |
+
return {"answer": answer, "route": "rag_only_frontier_axisB",
|
| 660 |
+
"context_summary": f"TTL 텍스트 + {n_chunks}개 청크(KoSimCSE)",
|
| 661 |
+
"elapsed_sec": elapsed, "success": True, "error": ""}
|
| 662 |
+
return {"answer": "", "route": "error", "context_summary": "",
|
| 663 |
+
"elapsed_sec": elapsed, "success": False, "error": info}
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def config_4_frontier_rag_only(question, data, **kwargs):
|
| 667 |
+
"""
|
| 668 |
+
구성 4: Claude Sonnet 4.6 + TTL 텍스트 + RAG (라우팅 X, lookup X)
|
| 669 |
+
"""
|
| 670 |
+
g = data["g"]
|
| 671 |
+
chunks = data["chunks"]
|
| 672 |
+
|
| 673 |
+
ttl_text = get_ttl_as_text(g)
|
| 674 |
+
relevant_chunks = search_chunks_for_rag_only(chunks, question, top_k=5)
|
| 675 |
+
chunk_context = "\n\n".join([
|
| 676 |
+
f"[규제 청크 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 677 |
+
for i, c in enumerate(relevant_chunks)
|
| 678 |
+
])
|
| 679 |
+
|
| 680 |
+
full_context = (
|
| 681 |
+
f"# 도메인 온톨로지 (텍스트 형식)\n\n{ttl_text}\n\n"
|
| 682 |
+
f"---\n\n# 관련 규제 청크\n\n{chunk_context}"
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
start = time.time()
|
| 686 |
+
answer, ok, info = call_frontier_llm(question, full_context)
|
| 687 |
+
elapsed = time.time() - start
|
| 688 |
+
|
| 689 |
+
if ok:
|
| 690 |
+
return {
|
| 691 |
+
"answer": answer,
|
| 692 |
+
"route": "rag_only_frontier",
|
| 693 |
+
"context_summary": f"TTL 텍스트 + {len(relevant_chunks)}개 청크",
|
| 694 |
+
"elapsed_sec": elapsed,
|
| 695 |
+
"success": True,
|
| 696 |
+
"error": "",
|
| 697 |
+
}
|
| 698 |
+
else:
|
| 699 |
+
return {
|
| 700 |
+
"answer": "",
|
| 701 |
+
"route": "error",
|
| 702 |
+
"context_summary": "",
|
| 703 |
+
"elapsed_sec": elapsed,
|
| 704 |
+
"success": False,
|
| 705 |
+
"error": info,
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
# ============================================
|
| 710 |
+
# 자동 측정 (응답 텍스트 분석)
|
| 711 |
+
# ============================================
|
| 712 |
+
|
| 713 |
+
def measure_response(answer):
|
| 714 |
+
"""답변에서 자동 측정 가능한 지표 추출"""
|
| 715 |
+
if not answer:
|
| 716 |
+
return {
|
| 717 |
+
"answer_chars": 0,
|
| 718 |
+
"markdown_bold_count": 0,
|
| 719 |
+
"emoji_count": 0,
|
| 720 |
+
"has_disclaimer": False,
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
# 마크다운 강조 횟수 (** 쌍)
|
| 724 |
+
bold_count = answer.count("**") // 2
|
| 725 |
+
|
| 726 |
+
# 이모지 개수 (간단 추정 — 한국어/영문/숫자 외 비ASCII 문자)
|
| 727 |
+
emoji_count = sum(1 for c in answer if ord(c) > 0x2600 and ord(c) < 0x1FFFF)
|
| 728 |
+
|
| 729 |
+
# 면책/모름 표현
|
| 730 |
+
disclaimers = ["모르겠", "확실하지 않", "정보가 없", "찾을 수 없", "답변하기 어려",
|
| 731 |
+
"추측", "가정", "정확한 정보가"]
|
| 732 |
+
has_disclaimer = any(d in answer for d in disclaimers)
|
| 733 |
+
|
| 734 |
+
return {
|
| 735 |
+
"answer_chars": len(answer),
|
| 736 |
+
"markdown_bold_count": bold_count,
|
| 737 |
+
"emoji_count": emoji_count,
|
| 738 |
+
"has_disclaimer": has_disclaimer,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
# ============================================
|
| 743 |
+
# 구성 레지스트리
|
| 744 |
+
# ============================================
|
| 745 |
+
|
| 746 |
+
CONFIGS = {
|
| 747 |
+
"config_1_open_ontology_rag": {
|
| 748 |
+
"name": "오픈 LLM + 온톨로지 + RAG (키워드 라우터)",
|
| 749 |
+
"fn": config_1_open_ontology_rag, "llm": "open", "mode": "keyword",
|
| 750 |
+
},
|
| 751 |
+
"config_2_open_rag_only": {
|
| 752 |
+
"name": "오픈 LLM + RAG only (키워드 검색)",
|
| 753 |
+
"fn": config_2_open_rag_only, "llm": "open", "mode": "keyword",
|
| 754 |
+
},
|
| 755 |
+
"config_3_frontier_ontology_rag": {
|
| 756 |
+
"name": "프론티어 LLM + 온톨로지 + RAG (키워드 라우터)",
|
| 757 |
+
"fn": config_3_frontier_ontology_rag, "llm": "frontier", "mode": "keyword",
|
| 758 |
+
},
|
| 759 |
+
"config_4_frontier_rag_only": {
|
| 760 |
+
"name": "프론티어 LLM + RAG only (키워드 검색)",
|
| 761 |
+
"fn": config_4_frontier_rag_only, "llm": "frontier", "mode": "keyword",
|
| 762 |
+
},
|
| 763 |
+
# === 축 B: LLM 의도파서 + KoSimCSE 의미검색 ===
|
| 764 |
+
"config_1_axisB": {
|
| 765 |
+
"name": "오픈 LLM + 온톨로지 + LLM파서 + KoSimCSE",
|
| 766 |
+
"fn": config_1_axisB, "llm": "open", "mode": "axisB",
|
| 767 |
+
},
|
| 768 |
+
"config_2_axisB": {
|
| 769 |
+
"name": "오픈 LLM + RAG only + KoSimCSE 청크검색",
|
| 770 |
+
"fn": config_2_axisB, "llm": "open", "mode": "axisB",
|
| 771 |
+
},
|
| 772 |
+
"config_3_axisB": {
|
| 773 |
+
"name": "프론티어 LLM + 온톨로지 + LLM파서 + KoSimCSE",
|
| 774 |
+
"fn": config_3_axisB, "llm": "frontier", "mode": "axisB",
|
| 775 |
+
},
|
| 776 |
+
"config_4_axisB": {
|
| 777 |
+
"name": "프론티어 LLM + RAG only + KoSimCSE 청크검색",
|
| 778 |
+
"fn": config_4_axisB, "llm": "frontier", "mode": "axisB",
|
| 779 |
+
},
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def run_single(config_id, question, data):
|
| 784 |
+
"""단일 (구성, 질문) 호출"""
|
| 785 |
+
config = CONFIGS[config_id]
|
| 786 |
+
result = config["fn"](question, data)
|
| 787 |
+
|
| 788 |
+
# 자동 측정 추가
|
| 789 |
+
measures = measure_response(result.get("answer", ""))
|
| 790 |
+
result.update(measures)
|
| 791 |
+
|
| 792 |
+
return result
|
code/rag_engine.py
ADDED
|
@@ -0,0 +1,1265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
rag_engine.py
|
| 3 |
+
=============
|
| 4 |
+
v5 RAG 데모 로직을 리팩토링한 엔진.
|
| 5 |
+
|
| 6 |
+
핵심 차이:
|
| 7 |
+
1. 하드코딩된 Q1~Q10 대신 자유 질문 라우팅
|
| 8 |
+
2. 사용자 추가 인스턴스 인지 (instance_manager 연동)
|
| 9 |
+
3. Streamlit 앱에서 호출 가능한 함수 형태
|
| 10 |
+
|
| 11 |
+
라우팅 흐름:
|
| 12 |
+
사용자 질문
|
| 13 |
+
↓
|
| 14 |
+
[route_question]
|
| 15 |
+
├─ 펀드명 매칭 + 진행상태 키워드 → ontology_template (Q1~Q5 패턴)
|
| 16 |
+
├─ 펀드명 매칭 + RWA/규제 키워드 → instance_with_concept (Q10 패턴 + 인스턴스 컨텍스트)
|
| 17 |
+
├─ 위험가중치 키워드 → deterministic_lookup (Q9 패턴)
|
| 18 |
+
├─ 일반 규제 키워드 → rag_concept (Q6~Q8 패턴)
|
| 19 |
+
└─ 매칭 안됨 → guidance (안내 메시지)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import re
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from rdflib import Graph, Namespace, RDF, RDFS
|
| 27 |
+
|
| 28 |
+
INV = Namespace("http://company.com/investment-ontology#")
|
| 29 |
+
|
| 30 |
+
# ============================================
|
| 31 |
+
# 모델 설정
|
| 32 |
+
# ============================================
|
| 33 |
+
# 환경변수 OLLAMA_MODEL 로 모델 변경 가능
|
| 34 |
+
# - 페이퍼 실험 모델: gemma4:e4b (Gemma 4, ~4B effective params, edge device용)
|
| 35 |
+
# - 시연 환경에 따라 더 작은 모델로 swap 가능 (예: gemma4:e2b, gemma3:4b 등)
|
| 36 |
+
# - 환경변수 미설정 시 기본 gemma4:e4b
|
| 37 |
+
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:e4b")
|
| 38 |
+
|
| 39 |
+
# ============================================
|
| 40 |
+
# 설정
|
| 41 |
+
# ============================================
|
| 42 |
+
|
| 43 |
+
# Streamlit 앱 폴더에서 상대 경로로 데이터 로드
|
| 44 |
+
DATA_DIR = Path(__file__).parent / "data"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================
|
| 48 |
+
# 1. 데이터 로드
|
| 49 |
+
# ============================================
|
| 50 |
+
|
| 51 |
+
def load_ttl(ttl_path):
|
| 52 |
+
g = Graph()
|
| 53 |
+
g.parse(str(ttl_path), format='turtle')
|
| 54 |
+
return g
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_chunks(jsonl_path):
|
| 58 |
+
chunks = []
|
| 59 |
+
with open(jsonl_path, 'r', encoding='utf-8') as f:
|
| 60 |
+
for line in f:
|
| 61 |
+
chunks.append(json.loads(line))
|
| 62 |
+
return chunks
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_alias(alias_path):
|
| 66 |
+
with open(alias_path, 'r', encoding='utf-8') as f:
|
| 67 |
+
return json.load(f)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_lookup(lookup_path):
|
| 71 |
+
with open(lookup_path, 'r', encoding='utf-8') as f:
|
| 72 |
+
return json.load(f)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ============================================
|
| 76 |
+
# 2. SPARQL 쿼리 (v5 로직 그대로)
|
| 77 |
+
# ============================================
|
| 78 |
+
|
| 79 |
+
def query_investment_branches(g, investment_iri):
|
| 80 |
+
"""특정 Investment의 모든 브랜치와 상태 조회"""
|
| 81 |
+
q = f"""
|
| 82 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 83 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 84 |
+
SELECT ?branchLabel ?productLabel ?amount ?stageLabel ?stateLabel ?stateOrder WHERE {{
|
| 85 |
+
inv:{investment_iri} inv:hasBranch ?branch .
|
| 86 |
+
?branch rdfs:label ?branchLabel ;
|
| 87 |
+
inv:hasProductType ?product ;
|
| 88 |
+
inv:hasInvestmentAmount ?amount ;
|
| 89 |
+
inv:hasCurrentStage ?stage ;
|
| 90 |
+
inv:hasBranchState ?state .
|
| 91 |
+
?product rdfs:label ?productLabel .
|
| 92 |
+
?stage rdfs:label ?stageLabel .
|
| 93 |
+
?state rdfs:label ?stateLabel ;
|
| 94 |
+
inv:hasStateOrder ?stateOrder .
|
| 95 |
+
FILTER(LANG(?branchLabel)="ko")
|
| 96 |
+
FILTER(LANG(?productLabel)="ko")
|
| 97 |
+
FILTER(LANG(?stageLabel)="ko")
|
| 98 |
+
FILTER(LANG(?stateLabel)="ko")
|
| 99 |
+
}} ORDER BY ?branchLabel
|
| 100 |
+
"""
|
| 101 |
+
branches = []
|
| 102 |
+
for row in g.query(q):
|
| 103 |
+
branches.append({
|
| 104 |
+
"label": str(row.branchLabel),
|
| 105 |
+
"product": str(row.productLabel),
|
| 106 |
+
"amount": int(float(str(row.amount))),
|
| 107 |
+
"stage": str(row.stageLabel),
|
| 108 |
+
"state": str(row.stateLabel),
|
| 109 |
+
"state_order": int(float(str(row.stateOrder))),
|
| 110 |
+
})
|
| 111 |
+
return branches
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def query_investment_meta(g, investment_iri):
|
| 115 |
+
"""Investment 메타 정보 (펀드·운용사·최종투자대상·검토개요)"""
|
| 116 |
+
q = f"""
|
| 117 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 118 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 119 |
+
SELECT ?label ?comment ?gpLabel ?fundLabel WHERE {{
|
| 120 |
+
inv:{investment_iri} rdfs:label ?label .
|
| 121 |
+
OPTIONAL {{ inv:{investment_iri} rdfs:comment ?comment . FILTER(LANG(?comment)="ko") }}
|
| 122 |
+
OPTIONAL {{ inv:{investment_iri} inv:managedByGP ?gp . ?gp rdfs:label ?gpLabel . FILTER(LANG(?gpLabel)="ko") }}
|
| 123 |
+
OPTIONAL {{ ?fund inv:isDirectRecipient inv:{investment_iri} . ?fund rdfs:label ?fundLabel . FILTER(LANG(?fundLabel)="ko") }}
|
| 124 |
+
FILTER(LANG(?label)="ko")
|
| 125 |
+
}}
|
| 126 |
+
"""
|
| 127 |
+
meta = {}
|
| 128 |
+
for row in g.query(q):
|
| 129 |
+
meta = {
|
| 130 |
+
"label": str(row.label),
|
| 131 |
+
"comment": str(row.comment) if row.comment else "",
|
| 132 |
+
"gp": str(row.gpLabel) if row.gpLabel else "",
|
| 133 |
+
"fund": str(row.fundLabel) if row.fundLabel else "",
|
| 134 |
+
}
|
| 135 |
+
break
|
| 136 |
+
if not meta:
|
| 137 |
+
return {}
|
| 138 |
+
# 최종 투자대상: 펀드(isDirectRecipient)의 간접투자대상(PortfolioCompany)
|
| 139 |
+
tq = f"""
|
| 140 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 141 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 142 |
+
SELECT ?targetLabel WHERE {{
|
| 143 |
+
?fund inv:isDirectRecipient inv:{investment_iri} ;
|
| 144 |
+
inv:hasIndirectTarget ?t .
|
| 145 |
+
?t rdfs:label ?targetLabel . FILTER(LANG(?targetLabel)="ko")
|
| 146 |
+
}}
|
| 147 |
+
"""
|
| 148 |
+
meta["targets"] = sorted({str(r.targetLabel) for r in g.query(tq)})
|
| 149 |
+
return meta
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def query_all_investments_with_label(g):
|
| 153 |
+
"""모든 Investment 인스턴스의 IRI와 label 조회 (펀드명 매칭용).
|
| 154 |
+
분기가 있는 인스턴스를 우선 정렬하여 매칭 시 빈 껍데기 인스턴스를 회피."""
|
| 155 |
+
q = """
|
| 156 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 157 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 158 |
+
SELECT ?inv ?label ?fundLabel (COUNT(?branch) AS ?nBranches) WHERE {
|
| 159 |
+
?inv a inv:Investment ;
|
| 160 |
+
rdfs:label ?label .
|
| 161 |
+
OPTIONAL { ?inv inv:hasBranch ?branch }
|
| 162 |
+
OPTIONAL { ?fund inv:isDirectRecipient ?inv . ?fund rdfs:label ?fundLabel . FILTER(LANG(?fundLabel)="ko") }
|
| 163 |
+
FILTER(LANG(?label)="ko")
|
| 164 |
+
} GROUP BY ?inv ?label ?fundLabel
|
| 165 |
+
ORDER BY DESC(?nBranches)
|
| 166 |
+
"""
|
| 167 |
+
results = []
|
| 168 |
+
for row in g.query(q):
|
| 169 |
+
results.append({
|
| 170 |
+
"iri": str(row.inv).split("#")[-1],
|
| 171 |
+
"label": str(row.label),
|
| 172 |
+
"fund_label": str(row.fundLabel) if row.fundLabel else "",
|
| 173 |
+
"n_branches": int(float(str(row.nBranches))),
|
| 174 |
+
})
|
| 175 |
+
return results
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ============================================
|
| 179 |
+
# 3. 답변 템플릿
|
| 180 |
+
# ============================================
|
| 181 |
+
|
| 182 |
+
def format_amount(amount):
|
| 183 |
+
if amount >= 100000000:
|
| 184 |
+
return f"{amount // 100000000}억 원"
|
| 185 |
+
return f"{amount:,}원"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def template_investment_status(meta, branches):
|
| 189 |
+
"""검토건 진행 상태 템플릿 (Q1~Q3 패턴)"""
|
| 190 |
+
if not branches:
|
| 191 |
+
return f"{meta.get('label', '해당 검토건')}의 분기 정보를 찾을 수 없습니다."
|
| 192 |
+
|
| 193 |
+
fund_name = meta.get('fund', meta.get('label', '해당 검토건'))
|
| 194 |
+
n_branches = len(branches)
|
| 195 |
+
|
| 196 |
+
lines = [f"**[{fund_name} 검토 현황]**", ""]
|
| 197 |
+
|
| 198 |
+
# 최종 투자대상 + 검토 개요 (Q1~3 핵심사실: 인수 목적·대상 기업)
|
| 199 |
+
targets = meta.get("targets", [])
|
| 200 |
+
if targets:
|
| 201 |
+
lines.append(f"- 최종 투자대상: {', '.join(targets)}")
|
| 202 |
+
if meta.get("comment"):
|
| 203 |
+
lines.append(f"- 검토 개요: {meta['comment']}")
|
| 204 |
+
if targets or meta.get("comment"):
|
| 205 |
+
lines.append("")
|
| 206 |
+
|
| 207 |
+
if n_branches == 1:
|
| 208 |
+
lines.append(f"이 검토 건은 단일 분기로 진행 중입니다.")
|
| 209 |
+
else:
|
| 210 |
+
lines.append(f"이 검토 건은 총 {n_branches}개의 분기로 진행 중입니다.")
|
| 211 |
+
lines.append("")
|
| 212 |
+
|
| 213 |
+
for idx, b in enumerate(branches, start=1):
|
| 214 |
+
amount_str = format_amount(b["amount"])
|
| 215 |
+
lines.append(
|
| 216 |
+
f"**분기 {idx}**: {b['product']} ({amount_str})"
|
| 217 |
+
)
|
| 218 |
+
lines.append(f" - 현재 단계: {b['stage']}")
|
| 219 |
+
lines.append(f" - 브랜치 상태: {b['state']}")
|
| 220 |
+
lines.append("")
|
| 221 |
+
|
| 222 |
+
return "\n".join(lines).strip()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def template_stage_threshold(g, threshold_order=5):
|
| 226 |
+
"""약정·실행 도달 검토건 추출 (Q4 패턴)"""
|
| 227 |
+
q = f"""
|
| 228 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 229 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 230 |
+
SELECT ?invLabel ?branchLabel ?stageLabel ?stateLabel ?stateOrder WHERE {{
|
| 231 |
+
?inv a inv:Investment ;
|
| 232 |
+
rdfs:label ?invLabel ;
|
| 233 |
+
inv:hasBranch ?branch .
|
| 234 |
+
?branch rdfs:label ?branchLabel ;
|
| 235 |
+
inv:hasCurrentStage ?stage ;
|
| 236 |
+
inv:hasBranchState ?state .
|
| 237 |
+
?stage rdfs:label ?stageLabel .
|
| 238 |
+
?state rdfs:label ?stateLabel ;
|
| 239 |
+
inv:hasStateOrder ?stateOrder .
|
| 240 |
+
FILTER(?stateOrder >= {threshold_order})
|
| 241 |
+
FILTER(LANG(?invLabel)="ko")
|
| 242 |
+
FILTER(LANG(?branchLabel)="ko")
|
| 243 |
+
FILTER(LANG(?stageLabel)="ko")
|
| 244 |
+
FILTER(LANG(?stateLabel)="ko")
|
| 245 |
+
}} ORDER BY ?invLabel
|
| 246 |
+
"""
|
| 247 |
+
|
| 248 |
+
results = []
|
| 249 |
+
for row in g.query(q):
|
| 250 |
+
results.append({
|
| 251 |
+
"inv_label": str(row.invLabel),
|
| 252 |
+
"branch_label": str(row.branchLabel),
|
| 253 |
+
"stage": str(row.stageLabel),
|
| 254 |
+
"state": str(row.stateLabel),
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
if not results:
|
| 258 |
+
return "약정 단계 이후로 진행된 검토 건이 없습니다."
|
| 259 |
+
|
| 260 |
+
lines = [f"**약정 단계 이후로 진행된 검토 건 ({len(results)}건)**", ""]
|
| 261 |
+
for r in results:
|
| 262 |
+
lines.append(f"- **{r['inv_label']}** / {r['branch_label']}")
|
| 263 |
+
lines.append(f" 단계: {r['stage']}, 상태: {r['state']}")
|
| 264 |
+
return "\n".join(lines)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def template_review_stalled(g):
|
| 268 |
+
"""정체 상태 검토건 추출 (Q5 패턴)"""
|
| 269 |
+
q = """
|
| 270 |
+
PREFIX inv: <http://company.com/investment-ontology#>
|
| 271 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 272 |
+
SELECT ?invLabel ?branchLabel ?stageLabel WHERE {
|
| 273 |
+
?inv a inv:Investment ;
|
| 274 |
+
rdfs:label ?invLabel ;
|
| 275 |
+
inv:hasBranch ?branch .
|
| 276 |
+
?branch rdfs:label ?branchLabel ;
|
| 277 |
+
inv:hasCurrentStage ?stage ;
|
| 278 |
+
inv:hasBranchState inv:State_ReviewStalled .
|
| 279 |
+
?stage rdfs:label ?stageLabel .
|
| 280 |
+
FILTER(LANG(?invLabel)="ko")
|
| 281 |
+
FILTER(LANG(?branchLabel)="ko")
|
| 282 |
+
FILTER(LANG(?stageLabel)="ko")
|
| 283 |
+
} ORDER BY ?invLabel
|
| 284 |
+
"""
|
| 285 |
+
|
| 286 |
+
results = []
|
| 287 |
+
for row in g.query(q):
|
| 288 |
+
results.append({
|
| 289 |
+
"inv_label": str(row.invLabel),
|
| 290 |
+
"branch_label": str(row.branchLabel),
|
| 291 |
+
"stage": str(row.stageLabel),
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
if not results:
|
| 295 |
+
return "현재 정체 상태인 검토 건이 없습니다."
|
| 296 |
+
|
| 297 |
+
lines = [f"**예비검토 단계에서 정체된 검토 건 ({len(results)}건)**", ""]
|
| 298 |
+
for r in results:
|
| 299 |
+
lines.append(f"- **{r['inv_label']}** / {r['branch_label']}")
|
| 300 |
+
lines.append(f" 단계: {r['stage']}, 상태: 예비검토 정체")
|
| 301 |
+
return "\n".join(lines)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ============================================
|
| 305 |
+
# 3-b. 축 A 신규 라우트 템플릿 (열린 질의 대응)
|
| 306 |
+
# ============================================
|
| 307 |
+
|
| 308 |
+
def template_list_active(g):
|
| 309 |
+
"""진행 중(폐기 아님) 검토건 전체 목록 (Q11)"""
|
| 310 |
+
q = """PREFIX inv: <http://company.com/investment-ontology#>
|
| 311 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 312 |
+
SELECT DISTINCT ?inv ?label ?fundLabel WHERE {
|
| 313 |
+
?inv a inv:Investment ; rdfs:label ?label .
|
| 314 |
+
FILTER NOT EXISTS { ?inv inv:isDeprecated true }
|
| 315 |
+
OPTIONAL { ?fund inv:isDirectRecipient ?inv ; rdfs:label ?fundLabel . FILTER(LANG(?fundLabel)="ko") }
|
| 316 |
+
FILTER(LANG(?label)="ko")
|
| 317 |
+
} ORDER BY ?inv"""
|
| 318 |
+
rows = list(g.query(q))
|
| 319 |
+
if not rows:
|
| 320 |
+
return "현재 진행 중인 검토 건이 없습니다."
|
| 321 |
+
lines = [f"**현재 검토 진행 중인 건 ({len(rows)}건)**", ""]
|
| 322 |
+
for row in rows:
|
| 323 |
+
iri = str(row.inv).split("#")[-1]
|
| 324 |
+
head = str(row.fundLabel) if row.fundLabel else str(row.label)
|
| 325 |
+
branches = query_investment_branches(g, iri)
|
| 326 |
+
bsum = ", ".join(f"{b['product']} {format_amount(b['amount'])}({b['state']})" for b in branches) or "(분기 정보 없음)"
|
| 327 |
+
lines.append(f"- **{head}**: {bsum}")
|
| 328 |
+
return "\n".join(lines)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def template_max_amount(g):
|
| 332 |
+
"""금액이 가장 큰 분기/검토건 (Q12)"""
|
| 333 |
+
q = """PREFIX inv: <http://company.com/investment-ontology#>
|
| 334 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 335 |
+
SELECT ?invLabel ?branchLabel ?amount ?prodLabel WHERE {
|
| 336 |
+
?inv a inv:Investment ; rdfs:label ?invLabel ; inv:hasBranch ?b .
|
| 337 |
+
FILTER NOT EXISTS { ?inv inv:isDeprecated true }
|
| 338 |
+
?b rdfs:label ?branchLabel ; inv:hasInvestmentAmount ?amount ; inv:hasProductType ?p .
|
| 339 |
+
?p rdfs:label ?prodLabel .
|
| 340 |
+
FILTER(LANG(?invLabel)="ko") FILTER(LANG(?branchLabel)="ko") FILTER(LANG(?prodLabel)="ko")
|
| 341 |
+
} ORDER BY DESC(?amount) LIMIT 1"""
|
| 342 |
+
rows = list(g.query(q))
|
| 343 |
+
if not rows:
|
| 344 |
+
return "검토 건 금액 정보를 찾을 수 없습니다."
|
| 345 |
+
r = rows[0]
|
| 346 |
+
return ("**금액이 가장 큰 건**\n\n"
|
| 347 |
+
f"- {r.branchLabel} ({r.prodLabel})\n"
|
| 348 |
+
f"- 금액: **{format_amount(int(float(str(r.amount))))}**\n"
|
| 349 |
+
f"- 소속 검토건: {r.invLabel}")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def template_multiplicity(g):
|
| 353 |
+
"""다중 투자: 분기≥2 또는 피투자≥2 (Q13)"""
|
| 354 |
+
qb = """PREFIX inv: <http://company.com/investment-ontology#>
|
| 355 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 356 |
+
SELECT ?invLabel (COUNT(?b) AS ?n) WHERE {
|
| 357 |
+
?inv a inv:Investment ; rdfs:label ?invLabel ; inv:hasBranch ?b .
|
| 358 |
+
FILTER NOT EXISTS { ?inv inv:isDeprecated true } FILTER(LANG(?invLabel)="ko")
|
| 359 |
+
} GROUP BY ?inv ?invLabel HAVING(COUNT(?b) > 1)"""
|
| 360 |
+
qf = """PREFIX inv: <http://company.com/investment-ontology#>
|
| 361 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 362 |
+
SELECT ?fundLabel (COUNT(?t) AS ?n) WHERE {
|
| 363 |
+
?fund inv:hasIndirectTarget ?t ; rdfs:label ?fundLabel . FILTER(LANG(?fundLabel)="ko")
|
| 364 |
+
} GROUP BY ?fund ?fundLabel HAVING(COUNT(?t) > 1)"""
|
| 365 |
+
mb = list(g.query(qb)); mf = list(g.query(qf))
|
| 366 |
+
lines = ["**한 번에 여러 건/대상에 투자하는 경우**", ""]
|
| 367 |
+
if mb:
|
| 368 |
+
lines.append("▶ 한 검토건에서 복수 상품 동시 진행 (분기):")
|
| 369 |
+
for r in mb:
|
| 370 |
+
lines.append(f" - {r.invLabel}: {int(r.n)}개 분기 (예: LP출자 + 인수금융)")
|
| 371 |
+
if mf:
|
| 372 |
+
lines.append("▶ 한 펀드가 복수 피투자사에 동시 투자:")
|
| 373 |
+
for r in mf:
|
| 374 |
+
lines.append(f" - {r.fundLabel}: {int(r.n)}개 피투자사")
|
| 375 |
+
if not mb and not mf:
|
| 376 |
+
lines.append("해당 사례가 없습니다.")
|
| 377 |
+
return "\n".join(lines)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def template_counterparty_overview(g, investment_iri=None):
|
| 381 |
+
"""거래상대방 다층성 식별 (Q8)"""
|
| 382 |
+
lines = [
|
| 383 |
+
"**LP출자 검토 시 고려할 거래상대방 (다층 구조)**", "",
|
| 384 |
+
"LP출자의 거래상대방은 단일 주체가 아니라 다층적이며, 모두 Counterparty 메타 클래스 하위로 분류됩니다:",
|
| 385 |
+
"- **운��사(GP)**: 펀드를 운용하는 주체 — KYC·운용능력 검토",
|
| 386 |
+
"- **출자대상펀드(RecipientFund)**: 우리가 직접 출자하는 펀드 — 약정·RWA 산정",
|
| 387 |
+
"- **피투자기업(PortfolioCompany)**: 펀드가 최종 투자하는 기업 — 자산건전성·대체투자 분류",
|
| 388 |
+
"- **차주(SPC 등)**: 인수금융 시 자금을 빌리는 특수목적법인",
|
| 389 |
+
"각 주체별로 ConsumerType(전문/일반 금융소비자)을 확인합니다.",
|
| 390 |
+
]
|
| 391 |
+
if investment_iri:
|
| 392 |
+
meta = query_investment_meta(g, investment_iri)
|
| 393 |
+
lines.append("")
|
| 394 |
+
lines.append(f"※ 본 검토건 기준 — 운용사: {meta.get('gp','')}, 펀드: {meta.get('fund','')}, 최종 투자대상: {', '.join(meta.get('targets', []))}")
|
| 395 |
+
return "\n".join(lines)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def template_listing_count(g):
|
| 399 |
+
"""피투자사 상장/비상장 카운트 (Q15)"""
|
| 400 |
+
from collections import defaultdict
|
| 401 |
+
q = """PREFIX inv: <http://company.com/investment-ontology#>
|
| 402 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 403 |
+
SELECT ?status ?label ?industry WHERE {
|
| 404 |
+
?c inv:hasCounterpartyRole inv:Role_PortfolioTarget ;
|
| 405 |
+
inv:listingStatus ?status ; rdfs:label ?label .
|
| 406 |
+
OPTIONAL { ?c inv:industry ?industry }
|
| 407 |
+
FILTER(LANG(?label)="ko")
|
| 408 |
+
} ORDER BY ?status ?label"""
|
| 409 |
+
by = defaultdict(list)
|
| 410 |
+
for r in g.query(q):
|
| 411 |
+
ind = f"({r.industry})" if r.industry else ""
|
| 412 |
+
by[str(r.status)].append(f"{r.label}{ind}")
|
| 413 |
+
sang = by.get("상장", []); bee = by.get("비상장", [])
|
| 414 |
+
lines = [f"**최종 검토 대상 기업 — 상장 {len(sang)}개 / 비상장 {len(bee)}개**", ""]
|
| 415 |
+
if sang:
|
| 416 |
+
lines.append(f"- 상장사 ({len(sang)}개): {', '.join(sang)}")
|
| 417 |
+
if bee:
|
| 418 |
+
lines.append(f"- 비상장사 ({len(bee)}개): {', '.join(bee)}")
|
| 419 |
+
return "\n".join(lines)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def template_approval(g, investment_iri=None):
|
| 423 |
+
"""결재/전결권 순서 (Q14)"""
|
| 424 |
+
full_order = ["거래상대방등록", "투자상담", "사전검토", "예비검토", "실무심의(실무협의회)",
|
| 425 |
+
"투자품의", "한도약정", "개별출자품의", "사후관리"]
|
| 426 |
+
order = ["사전검토", "예비검토", "실무심의", "투자품의", "한도약정", "개별출자"]
|
| 427 |
+
lines = ["**투자 검토 결재(전결권) 체계**", "",
|
| 428 |
+
"업무 진행 순서 (9단계): " + " → ".join(full_order), "",
|
| 429 |
+
"결재(전결권) 대상 단계 및 전결권자:"]
|
| 430 |
+
for i, st in enumerate(order, 1):
|
| 431 |
+
lines.append(f" {i}. {st} → {STAGE_APPROVER[st]}")
|
| 432 |
+
lines += ["", "실무심의 전결권 (투자금액 × 상장여부, 복수 분기 시 최대 금액 기준):",
|
| 433 |
+
"| 금액 | 비상장사 | 상장사 |", "|---|---|---|"]
|
| 434 |
+
rowtxt = ["10억 이하", "20억 이하", "50억 이하", "100억 이하", "100억 초과"]
|
| 435 |
+
for (thr, ul, li), label in zip(WORKING_REVIEW_TABLE, rowtxt):
|
| 436 |
+
lines.append(f"| {label} | {ul} | {li} |")
|
| 437 |
+
if investment_iri:
|
| 438 |
+
branches = query_investment_branches(g, investment_iri)
|
| 439 |
+
if branches:
|
| 440 |
+
maxamt = max(b["amount"] for b in branches)
|
| 441 |
+
lsq = f"""PREFIX inv: <http://company.com/investment-ontology#>
|
| 442 |
+
SELECT ?ls WHERE {{ ?fund inv:isDirectRecipient inv:{investment_iri} ; inv:hasIndirectTarget ?t . ?t inv:listingStatus ?ls }} LIMIT 1"""
|
| 443 |
+
lss = [str(r[0]) for r in g.query(lsq)]
|
| 444 |
+
is_listed = bool(lss) and lss[0] == "상장"
|
| 445 |
+
auth = working_review_authority(maxamt, is_listed)
|
| 446 |
+
lines += ["", f"※ 본 검토건: 최대 {format_amount(maxamt)}, {'상장' if is_listed else '비상장'} → 실무심의 전결권 **{auth}**"]
|
| 447 |
+
return "\n".join(lines)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
# ============================================
|
| 451 |
+
# 4. 결정론적 위험가중치 lookup (Q9 패턴)
|
| 452 |
+
# ============================================
|
| 453 |
+
|
| 454 |
+
def lookup_risk_weight(lookup, asset_class, credit_rating=None):
|
| 455 |
+
"""
|
| 456 |
+
위험가중치 결정론적 조회 (Q9 패턴 — LLM 호출 없이 직접 표 조회).
|
| 457 |
+
|
| 458 |
+
실제 데이터 구조:
|
| 459 |
+
lookup = {
|
| 460 |
+
"asset_classes": {
|
| 461 |
+
"중앙정부": {
|
| 462 |
+
"asset_id": "CentralGov",
|
| 463 |
+
"clause_id": "BSER_App3_Asset_CentralGov",
|
| 464 |
+
"lookup_method": "신용등급",
|
| 465 |
+
"table": { "AAA~AA-": "0%", "A+~A-": "20%", ... },
|
| 466 |
+
"special_rules": { ... }
|
| 467 |
+
}, ...
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
Args:
|
| 472 |
+
lookup: 전체 lookup dict
|
| 473 |
+
asset_class: 자산 분류 (예: "중앙정부")
|
| 474 |
+
credit_rating: 신용등급 (예: "AAA~AA-", "AAA", "A+~A-")
|
| 475 |
+
|
| 476 |
+
Returns:
|
| 477 |
+
dict {
|
| 478 |
+
"weight": "0%", # 위험가중치 문자열
|
| 479 |
+
"asset_class": "중앙정부",
|
| 480 |
+
"credit_rating": "AAA~AA-",
|
| 481 |
+
"matched_key": "AAA~AA-",
|
| 482 |
+
"clause_id": "BSER_App3_Asset_CentralGov",
|
| 483 |
+
}
|
| 484 |
+
또는 None (매칭 실패)
|
| 485 |
+
"""
|
| 486 |
+
asset_classes = lookup.get("asset_classes", {})
|
| 487 |
+
asset_data = asset_classes.get(asset_class)
|
| 488 |
+
if not asset_data:
|
| 489 |
+
return None
|
| 490 |
+
|
| 491 |
+
table = asset_data.get("table", {})
|
| 492 |
+
|
| 493 |
+
# 1) 정확 매칭
|
| 494 |
+
if credit_rating and credit_rating in table:
|
| 495 |
+
return {
|
| 496 |
+
"weight": table[credit_rating],
|
| 497 |
+
"asset_class": asset_class,
|
| 498 |
+
"credit_rating": credit_rating,
|
| 499 |
+
"matched_key": credit_rating,
|
| 500 |
+
"clause_id": asset_data.get("clause_id", ""),
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
# 2) 부분 매칭 (예: "AAA" → "AAA~AA-" 키 찾기)
|
| 504 |
+
if credit_rating:
|
| 505 |
+
for key in table.keys():
|
| 506 |
+
# 사용자가 "AAA"라고 입력했고 키가 "AAA~AA-"라면 매칭
|
| 507 |
+
if credit_rating in key:
|
| 508 |
+
return {
|
| 509 |
+
"weight": table[key],
|
| 510 |
+
"asset_class": asset_class,
|
| 511 |
+
"credit_rating": credit_rating,
|
| 512 |
+
"matched_key": key,
|
| 513 |
+
"clause_id": asset_data.get("clause_id", ""),
|
| 514 |
+
}
|
| 515 |
+
# 키가 "AAA"이고 사용자가 "AAA~AA-"라고 입력했다면 매칭 (역방향)
|
| 516 |
+
if key in credit_rating:
|
| 517 |
+
return {
|
| 518 |
+
"weight": table[key],
|
| 519 |
+
"asset_class": asset_class,
|
| 520 |
+
"credit_rating": credit_rating,
|
| 521 |
+
"matched_key": key,
|
| 522 |
+
"clause_id": asset_data.get("clause_id", ""),
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
return None
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def template_risk_weight_answer(result):
|
| 529 |
+
"""
|
| 530 |
+
위험가중치 lookup 결과를 한국어 답변으로 포맷.
|
| 531 |
+
|
| 532 |
+
Args:
|
| 533 |
+
result: lookup_risk_weight() 반환값 (dict)
|
| 534 |
+
"""
|
| 535 |
+
if result is None:
|
| 536 |
+
return None
|
| 537 |
+
|
| 538 |
+
return (
|
| 539 |
+
f"**[위험가중치 조회 결과]**\n\n"
|
| 540 |
+
f"- 자산 분류: **{result['asset_class']}**\n"
|
| 541 |
+
f"- 신용등급: **{result['credit_rating']}** (매칭 키: `{result['matched_key']}`)\n"
|
| 542 |
+
f"- 위험가중치: **{result['weight']}**\n\n"
|
| 543 |
+
f"※ 출처: 은행업감독업무시행세칙 별표 3 (표준방법 기준)\n"
|
| 544 |
+
f"※ 본 답변은 LLM 호출 없이 정형 lookup table에서 직접 조회되었습니다."
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
def render_supporting_chunks(chunks_list, max_n=2, max_text=300):
|
| 548 |
+
"""위험가중치 답변에 보조로 붙일 RAG 청크 요약"""
|
| 549 |
+
if not chunks_list:
|
| 550 |
+
return ""
|
| 551 |
+
lines = ["\n\n---\n\n**📚 보조 근거 (관련 규제 청크):**\n"]
|
| 552 |
+
for c in chunks_list[:max_n]:
|
| 553 |
+
cid = c.get("id", "?")
|
| 554 |
+
text = c.get("text", "")[:max_text]
|
| 555 |
+
lines.append(f"- **{cid}**: {text}{'...' if len(c.get('text', '')) > max_text else ''}")
|
| 556 |
+
return "\n\n".join(lines)
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
# ============================================
|
| 560 |
+
# 5. RAG 청크 검색
|
| 561 |
+
# ============================================
|
| 562 |
+
|
| 563 |
+
def search_chunks_by_concept(chunks, concept_id, top_k=5, prefer_summary=False):
|
| 564 |
+
"""Concept ID로 RAG 청크 검색"""
|
| 565 |
+
matched = []
|
| 566 |
+
for c in chunks:
|
| 567 |
+
meta = c.get("metadata", {})
|
| 568 |
+
concepts = meta.get("regulatory_concepts", [])
|
| 569 |
+
if concept_id in concepts:
|
| 570 |
+
matched.append(c)
|
| 571 |
+
|
| 572 |
+
# 요약 청크 우선 (Q10 패턴)
|
| 573 |
+
if prefer_summary:
|
| 574 |
+
summary = [c for c in matched if "Summary" in c.get("id", "") or c.get("metadata", {}).get("is_summary")]
|
| 575 |
+
non_summary = [c for c in matched if c not in summary]
|
| 576 |
+
matched = summary + non_summary
|
| 577 |
+
|
| 578 |
+
return matched[:top_k]
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def search_chunks_by_keyword(chunks, keywords, top_k=5):
|
| 582 |
+
"""키워드 매칭으로 청크 검색 (concept ID 매칭 실패 시 fallback)"""
|
| 583 |
+
scored = []
|
| 584 |
+
for c in chunks:
|
| 585 |
+
text = c.get("text", "").lower()
|
| 586 |
+
score = sum(1 for kw in keywords if kw.lower() in text)
|
| 587 |
+
if score > 0:
|
| 588 |
+
scored.append((score, c))
|
| 589 |
+
|
| 590 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 591 |
+
return [c for _, c in scored[:top_k]]
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
# ============================================
|
| 595 |
+
# 6. Gemma 호출 (Streamlit 환경에서 지연 import)
|
| 596 |
+
# ============================================
|
| 597 |
+
|
| 598 |
+
def call_gemma(question, context, mode="standard", model_name=None):
|
| 599 |
+
"""
|
| 600 |
+
Gemma 호출. ollama 패키지 + Ollama 서버 + 모델이 모두 준비되어야 함.
|
| 601 |
+
어느 단계에서 실패해도 예외를 던지지 않고 None 또는 안내 문자열을 반환.
|
| 602 |
+
|
| 603 |
+
Args:
|
| 604 |
+
question: 사용자 질문
|
| 605 |
+
context: 컨텍스트 (인스턴스 정보 + 청크)
|
| 606 |
+
mode: "standard" 또는 "polish"
|
| 607 |
+
model_name: 모델명. None이면 환경변수 OLLAMA_MODEL 또는 DEFAULT_MODEL 사용
|
| 608 |
+
|
| 609 |
+
Returns:
|
| 610 |
+
(answer_str, success_bool)
|
| 611 |
+
- 성공: (Gemma 답변, True)
|
| 612 |
+
- 실패: (None, False) → 호출자가 fallback 처리해야 함
|
| 613 |
+
"""
|
| 614 |
+
if model_name is None:
|
| 615 |
+
model_name = DEFAULT_MODEL
|
| 616 |
+
|
| 617 |
+
try:
|
| 618 |
+
import ollama
|
| 619 |
+
except ImportError:
|
| 620 |
+
return None, False
|
| 621 |
+
|
| 622 |
+
if mode == "polish":
|
| 623 |
+
# 다듬기 모드: 컨텍스트의 사실을 그대로 유지하며 자연스럽게 표현
|
| 624 |
+
system = (
|
| 625 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 626 |
+
"아래 컨텍스트의 사실을 그대로 유지하면서 자연스러운 한국어로 답변을 다듬어 주세요. "
|
| 627 |
+
"사실을 추가하거나 추측하지 마세요. 제공된 정보만 사용하세요."
|
| 628 |
+
)
|
| 629 |
+
else:
|
| 630 |
+
# 표준 모드: 컨텍스트 기반 답변
|
| 631 |
+
system = (
|
| 632 |
+
"당신은 한국 LP출자 도메인 전문 금융회사 직원의 보조 AI입니다. "
|
| 633 |
+
"아래 컨텍스트(온톨로지 + 규제 청크)의 정보만 사용하여 답변하세요. "
|
| 634 |
+
"컨텍스트에 없는 내용은 추측하지 말고, 모르면 모른다고 답하세요. "
|
| 635 |
+
"마크다운 강조(**)나 이모지를 과하게 사용하지 마세요. "
|
| 636 |
+
"한국어 격식체로 간결하게 답변하세요."
|
| 637 |
+
)
|
| 638 |
+
|
| 639 |
+
full_prompt = f"[컨텍스트]\n{context}\n\n[질문]\n{question}\n\n[답변]"
|
| 640 |
+
|
| 641 |
+
try:
|
| 642 |
+
resp = ollama.chat(
|
| 643 |
+
model=model_name,
|
| 644 |
+
messages=[
|
| 645 |
+
{"role": "system", "content": system},
|
| 646 |
+
{"role": "user", "content": full_prompt},
|
| 647 |
+
],
|
| 648 |
+
options={"num_predict": 1500, "temperature": 0.3, "repeat_penalty": 1.15},
|
| 649 |
+
)
|
| 650 |
+
return resp['message']['content'], True
|
| 651 |
+
except Exception:
|
| 652 |
+
# 모델 미존재, 서버 미실행, 네트워크 오류 등 모두 여기서 처리
|
| 653 |
+
return None, False
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
# ============================================
|
| 657 |
+
# 7. 라우터 (자유 질문 분류)
|
| 658 |
+
# ============================================
|
| 659 |
+
|
| 660 |
+
# 키워드 사전
|
| 661 |
+
PROGRESS_KEYWORDS = ["어디까지", "어떻게 진행", "현재", "진행 상태", "상태", "어디", "어느 단계", "단계가 어떻게"]
|
| 662 |
+
RWA_KEYWORDS = ["RWA", "위험가중치", "rwa", "익스포져", "익스포저", "자산분류",
|
| 663 |
+
"자기자본 비율", "자기자본비율", "BIS", "경영지도비율", "리스크 가중치",
|
| 664 |
+
"자기자본 산정"]
|
| 665 |
+
|
| 666 |
+
# 자산분류 별칭 — lookup의 asset_classes 키와 사용자 표현 매핑
|
| 667 |
+
# (substring 매칭 실패 시 시도. 가장 도메인 한정된 표현부터 우선)
|
| 668 |
+
ASSET_ALIASES = {
|
| 669 |
+
"정부 채권": "중앙정부", "정부채권": "중앙정부",
|
| 670 |
+
"국채": "중앙정부", "국공채": "중앙정부",
|
| 671 |
+
"지방채": "지방정부",
|
| 672 |
+
"회사채": "일반기업", "기업채권": "일반기업",
|
| 673 |
+
"은행채": "은행",
|
| 674 |
+
"주식": "주식",
|
| 675 |
+
"부동산": "상업용 부동산",
|
| 676 |
+
"모기지": "주거용 모기지", "주택담보": "주거용 모기지",
|
| 677 |
+
}
|
| 678 |
+
ALTERNATIVE_KEYWORDS = ["대체투자", "대체 투자"]
|
| 679 |
+
SUITABILITY_KEYWORDS = ["적합성", "적합성 원칙"]
|
| 680 |
+
EXPLANATION_KEYWORDS = ["설명의무", "설명 의무"]
|
| 681 |
+
CONSUMER_KEYWORDS = ["전문금융소비자", "일반금융소비자", "금융소비자"]
|
| 682 |
+
SCREENING_KEYWORDS = ["사전심사", "사전 심사", "사전협의", "사전 협의"]
|
| 683 |
+
PRODUCT_KEYWORDS = ["펀드 형태", "펀드 종류", "어떤 펀드", "LP출자 대상"]
|
| 684 |
+
THRESHOLD_KEYWORDS = ["약정 후", "약정 이후", "약정 단계 이후", "실행 단계", "약정 단계"]
|
| 685 |
+
STALLED_KEYWORDS = ["정체", "결재 안", "막힌", "지체", "올렸는데"]
|
| 686 |
+
|
| 687 |
+
# === 축 A 신규 라우트 키워드 (열린 질의 대응) ===
|
| 688 |
+
LIST_ACTIVE_KEYWORDS = ["진행 중인 건", "진행중인 건", "검토 중인 건", "검토중인 건", "검토 진행 중", "어떤 건들", "전체 검토건", "건들 좀", "건들은", "목록", "리스트", "다 보여", "전부 보여"]
|
| 689 |
+
MAXAMOUNT_KEYWORDS = ["가장 큰", "제일 큰", "최대 금액", "금액이 큰", "금액이 가장", "가장 많은 금액", "제일 많은"]
|
| 690 |
+
MULTIPLICITY_KEYWORDS = ["여러개", "여러 개", "여러건", "여러 건", "한번에", "한 번에", "동시에", "동시 투자", "복수"]
|
| 691 |
+
COUNTERPARTY_KEYWORDS = ["거래상대방", "거래 상대방", "상대방을 식별", "누구를 고려", "누구를 봐야", "상대방은 누구"]
|
| 692 |
+
APPROVAL_KEYWORDS = ["결재", "전결", "승인 순서", "승인권", "결재권", "어떤 순서로 받"]
|
| 693 |
+
LISTING_KEYWORDS = ["상장사", "비상장사", "상장 비상장", "상장여부", "상장 여부", "상장/비상장"]
|
| 694 |
+
|
| 695 |
+
# 결재 전결권 (approval_authority_lookup.json 미러 — 복수 분기 시 최대 금액 기준)
|
| 696 |
+
STAGE_APPROVER = {"사전검토": "부서장", "예비검토": "부서장", "실무심의": "금액·상장여부 따름",
|
| 697 |
+
"투자품의": "부서장", "한도약정": "부서장", "개별출자": "부서장"}
|
| 698 |
+
WORKING_REVIEW_TABLE = [ # (금액 억원 이하, 비상장 전결권, 상장 전결권)
|
| 699 |
+
(10, "본부장", "부서장"), (20, "본부장", "본부장"), (50, "그룹장", "그룹장"),
|
| 700 |
+
(100, "사장", "그룹장"), (None, "사장", "사장"),
|
| 701 |
+
]
|
| 702 |
+
def working_review_authority(max_amount_won, is_listed):
|
| 703 |
+
eok = max_amount_won / 100000000
|
| 704 |
+
for thr, unlisted, listed in WORKING_REVIEW_TABLE:
|
| 705 |
+
if thr is None or eok <= thr:
|
| 706 |
+
return listed if is_listed else unlisted
|
| 707 |
+
return "사장"
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def detect_concept_from_question(question):
|
| 711 |
+
"""질문에서 RegulatoryConcept ID 추출"""
|
| 712 |
+
q = question.lower()
|
| 713 |
+
|
| 714 |
+
if any(kw in question for kw in RWA_KEYWORDS):
|
| 715 |
+
return "Concept_RWA_Calculation"
|
| 716 |
+
if any(kw in question for kw in ALTERNATIVE_KEYWORDS):
|
| 717 |
+
return "Concept_AlternativeInvestmentClassification"
|
| 718 |
+
if any(kw in question for kw in SUITABILITY_KEYWORDS):
|
| 719 |
+
return "Concept_SuitabilityCheck"
|
| 720 |
+
if any(kw in question for kw in EXPLANATION_KEYWORDS):
|
| 721 |
+
return "Concept_ExplanationDuty"
|
| 722 |
+
if any(kw in question for kw in CONSUMER_KEYWORDS):
|
| 723 |
+
return "Concept_ConsumerClassification"
|
| 724 |
+
if any(kw in question for kw in SCREENING_KEYWORDS):
|
| 725 |
+
return None # 사전심사 단계는 stage_overview로 처리
|
| 726 |
+
if any(kw in question for kw in PRODUCT_KEYWORDS):
|
| 727 |
+
return "Concept_ProductDefinition"
|
| 728 |
+
|
| 729 |
+
return None
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def detect_risk_weight_query(question, lookup):
|
| 733 |
+
"""
|
| 734 |
+
위험가중치 질의 자동 감지 + 자산분류·신용등급 추출.
|
| 735 |
+
|
| 736 |
+
Returns:
|
| 737 |
+
dict {"asset_class": str, "credit_rating": str|None} 또는 None
|
| 738 |
+
"""
|
| 739 |
+
# RWA/위험가중치 키워드가 없으면 무관한 질문
|
| 740 |
+
if not any(kw in question for kw in RWA_KEYWORDS):
|
| 741 |
+
return None
|
| 742 |
+
|
| 743 |
+
# 자산분류 탐지 (lookup의 asset_classes 키 사용)
|
| 744 |
+
asset_classes = lookup.get("asset_classes", {})
|
| 745 |
+
asset_class = None
|
| 746 |
+
for asset_name in asset_classes.keys():
|
| 747 |
+
if asset_name in question:
|
| 748 |
+
asset_class = asset_name
|
| 749 |
+
break
|
| 750 |
+
|
| 751 |
+
# asset 직접 매칭 실패 시 alias 사전 시도 ("정부 채권"→"중앙정부" 등)
|
| 752 |
+
if not asset_class:
|
| 753 |
+
for alias_term, canonical in ASSET_ALIASES.items():
|
| 754 |
+
if alias_term in question and canonical in asset_classes:
|
| 755 |
+
asset_class = canonical
|
| 756 |
+
break
|
| 757 |
+
|
| 758 |
+
if not asset_class:
|
| 759 |
+
return None
|
| 760 |
+
|
| 761 |
+
# 신용등급 탐지 - 실제 lookup table 키와 사용자 표현을 모두 시도
|
| 762 |
+
table_keys = list(asset_classes[asset_class].get("table", {}).keys())
|
| 763 |
+
credit_rating = None
|
| 764 |
+
|
| 765 |
+
# 1) lookup table의 실제 키 직접 매칭
|
| 766 |
+
for key in table_keys:
|
| 767 |
+
if key in question:
|
| 768 |
+
credit_rating = key
|
| 769 |
+
break
|
| 770 |
+
|
| 771 |
+
# 2) 일반 신용등급 패턴 매칭 (사용자가 "AAA"만 입력한 경우 등)
|
| 772 |
+
if not credit_rating:
|
| 773 |
+
# 긴 패턴부터 매칭 (AAA가 AA보다 먼저)
|
| 774 |
+
rating_patterns = [
|
| 775 |
+
"AAA~AA-", "AAA", "AA-", "AA+", "AA",
|
| 776 |
+
"A+~A-", "A+", "A-", "A",
|
| 777 |
+
"BBB+~BBB-", "BBB+", "BBB-", "BBB",
|
| 778 |
+
"BB+~B-", "BB+", "BB-", "BB",
|
| 779 |
+
"B-미만", "B-이하", "B-",
|
| 780 |
+
"투자등급", "투기등급", "무등급",
|
| 781 |
+
]
|
| 782 |
+
for pattern in rating_patterns:
|
| 783 |
+
if pattern in question:
|
| 784 |
+
credit_rating = pattern
|
| 785 |
+
break
|
| 786 |
+
|
| 787 |
+
return {"asset_class": asset_class, "credit_rating": credit_rating}
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
# ============================================
|
| 791 |
+
# 8. 메인 라우팅 함수
|
| 792 |
+
# ============================================
|
| 793 |
+
|
| 794 |
+
def answer_question(question, g, chunks, alias_dict, lookup, user_instances=None,
|
| 795 |
+
use_gemma=True, model_name="gemma2:2b"):
|
| 796 |
+
"""
|
| 797 |
+
자유 질문 처리 메인 함수.
|
| 798 |
+
|
| 799 |
+
Args:
|
| 800 |
+
question: 사용자 질문 (자연어)
|
| 801 |
+
g: rdflib Graph (TTL + 사용자 추가 인스턴스 포함)
|
| 802 |
+
chunks: RAG 청크 리스트
|
| 803 |
+
alias_dict: 동의어 사전
|
| 804 |
+
lookup: 위험가중치 lookup
|
| 805 |
+
user_instances: 사용자가 추가한 인스턴스 record 리스트 (instance_manager.add_user_investment 결과)
|
| 806 |
+
use_gemma: Gemma 호출 여부 (False면 템플릿/lookup만, 빠른 시연용)
|
| 807 |
+
model_name: Ollama 모델명
|
| 808 |
+
|
| 809 |
+
Returns:
|
| 810 |
+
dict: {
|
| 811 |
+
"answer": str, # 최종 답변
|
| 812 |
+
"route": str, # 라우팅 결과 (디버깅용)
|
| 813 |
+
"context_summary": str, # 사용된 컨텍스트 요약
|
| 814 |
+
}
|
| 815 |
+
"""
|
| 816 |
+
# ============================================
|
| 817 |
+
# 1. 펀드명 매칭 — 사용자 추가 인스턴스 우선
|
| 818 |
+
# ============================================
|
| 819 |
+
matched_investment = None
|
| 820 |
+
matched_source = None # "user" 또는 "demo"
|
| 821 |
+
|
| 822 |
+
# 1-1. 사용자가 추가한 인스턴스 매칭
|
| 823 |
+
if user_instances:
|
| 824 |
+
from instance_manager import find_user_investment_by_keyword
|
| 825 |
+
# 질문에서 펀드명 후보 추출 (간단히 단어 단위로)
|
| 826 |
+
question_words = re.findall(r'[가-힣A-Za-z0-9]{2,}', question)
|
| 827 |
+
for word in question_words:
|
| 828 |
+
record = find_user_investment_by_keyword(user_instances, word)
|
| 829 |
+
if record:
|
| 830 |
+
matched_investment = record["investment_iri"]
|
| 831 |
+
matched_source = "user"
|
| 832 |
+
break
|
| 833 |
+
|
| 834 |
+
# 1-2. 데모 인스턴스 매칭
|
| 835 |
+
# 일반 단어 (질문 의도 키워드)는 매칭 후보에서 제외
|
| 836 |
+
GENERIC_WORDS = {
|
| 837 |
+
"검토", "검토건", "검토 건", "단계", "진행", "어디", "상태", "약정", "출자",
|
| 838 |
+
"어떻게", "지금", "현재", "상황", "처리", "이후", "올렸", "결재", "정체",
|
| 839 |
+
"관리", "신청", "등록", "승인", "산정", "분류", "원칙", "기준",
|
| 840 |
+
"펀드", "투자", "금융", "규제", "조항", "법령", "계약", "체결",
|
| 841 |
+
"예비", "사전", "협의", "실무", "품의", "대체", "적합성", "설명",
|
| 842 |
+
"그리고", "또는", "그래서", "하지만", "지금까지", "그동안",
|
| 843 |
+
"RWA", "rwa", "위험가중치", "익스포져", "익스포저", "자산", "자산분류",
|
| 844 |
+
"lp출자", "LP출자", "lp", "LP", "한도", "공여", "신용", "전문",
|
| 845 |
+
"가상", "기준", "조건", "방법",
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
if not matched_investment:
|
| 849 |
+
all_investments = query_all_investments_with_label(g)
|
| 850 |
+
for inv in all_investments:
|
| 851 |
+
# 빈 껍데기 인스턴스 (분기 0개) 스킵
|
| 852 |
+
if inv.get("n_branches", 0) == 0:
|
| 853 |
+
continue
|
| 854 |
+
# 이미 매칭된 사용자 인스턴스면 스킵
|
| 855 |
+
if user_instances and any(r["investment_iri"] == inv["iri"] for r in user_instances):
|
| 856 |
+
continue
|
| 857 |
+
|
| 858 |
+
# label/fund_label에서 단어 추출, 일반 단어 제거
|
| 859 |
+
label_words = re.findall(r"[가-힣A-Za-z0-9]{2,}", inv["label"] + " " + inv["fund_label"])
|
| 860 |
+
distinctive_words = [w for w in label_words if w not in GENERIC_WORDS]
|
| 861 |
+
|
| 862 |
+
for word in distinctive_words:
|
| 863 |
+
if len(word) >= 2 and word in question:
|
| 864 |
+
matched_investment = inv["iri"]
|
| 865 |
+
matched_source = "demo"
|
| 866 |
+
break
|
| 867 |
+
if matched_investment:
|
| 868 |
+
break
|
| 869 |
+
|
| 870 |
+
# ============================================
|
| 871 |
+
# 2. 라우팅
|
| 872 |
+
# ============================================
|
| 873 |
+
|
| 874 |
+
# 2-1. 약정 단계 도달 검토건 (Q4 패턴) — 펀드명 매칭이 없을 때만
|
| 875 |
+
if not matched_investment and any(kw in question for kw in THRESHOLD_KEYWORDS):
|
| 876 |
+
answer = template_stage_threshold(g, threshold_order=1)
|
| 877 |
+
return {
|
| 878 |
+
"answer": answer,
|
| 879 |
+
"route": "stage_threshold",
|
| 880 |
+
"context_summary": "약정·실행 도달 검토건 SPARQL",
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
# 2-2. 정체 상태 (Q5 패턴) — 펀드명 매칭이 없을 때만
|
| 884 |
+
if not matched_investment and any(kw in question for kw in STALLED_KEYWORDS):
|
| 885 |
+
answer = template_review_stalled(g)
|
| 886 |
+
return {
|
| 887 |
+
"answer": answer,
|
| 888 |
+
"route": "review_stalled",
|
| 889 |
+
"context_summary": "정체 상태 검토건 SPARQL",
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
# 2-3. 위험가중치 (Q9 패턴) — Lookup 우선, RAG 청크는 보조 근거
|
| 893 |
+
# 페이퍼 §4.5의 핵심 메시지: "결정론적 사실은 코드가 보장, 자연어만 LLM"
|
| 894 |
+
# - asset_class 감지되면 무조건 lookup 시도 (LLM 호출 없음)
|
| 895 |
+
# - 매칭 성공: lookup 결과를 메인 답변으로, 관련 청크는 보조 근거로 첨부
|
| 896 |
+
# - 매칭 실패: 보조 안내 + 일반 RWA 청크로 fallback
|
| 897 |
+
rw_query = detect_risk_weight_query(question, lookup)
|
| 898 |
+
if rw_query:
|
| 899 |
+
result = lookup_risk_weight(lookup, rw_query["asset_class"], rw_query["credit_rating"])
|
| 900 |
+
|
| 901 |
+
if result is not None:
|
| 902 |
+
# 메인 답변: lookup 결과 (LLM 호출 없음)
|
| 903 |
+
main_answer = template_risk_weight_answer(result)
|
| 904 |
+
|
| 905 |
+
# 보조 근거: 관련 RAG 청크 1~2개
|
| 906 |
+
supporting = search_chunks_by_concept(chunks, "Concept_RWA_Calculation",
|
| 907 |
+
top_k=2, prefer_summary=True)
|
| 908 |
+
supporting_text = render_supporting_chunks(supporting, max_n=2, max_text=300)
|
| 909 |
+
|
| 910 |
+
return {
|
| 911 |
+
"answer": main_answer + supporting_text,
|
| 912 |
+
"route": "deterministic_lookup",
|
| 913 |
+
"context_summary": (
|
| 914 |
+
f"Lookup 직접 조회: {result['asset_class']} / {result['credit_rating']} "
|
| 915 |
+
f"(매칭: {result['matched_key']}) "
|
| 916 |
+
f"+ 보조 청크 {len(supporting)}개"
|
| 917 |
+
),
|
| 918 |
+
}
|
| 919 |
+
else:
|
| 920 |
+
# 자산 분류는 감지됐으나 신용등급이 명확하지 않은 경우
|
| 921 |
+
# → asset_class 표 전체를 안내 + RAG 청크
|
| 922 |
+
asset_classes = lookup.get("asset_classes", {})
|
| 923 |
+
asset_data = asset_classes.get(rw_query["asset_class"], {})
|
| 924 |
+
table = asset_data.get("table", {})
|
| 925 |
+
|
| 926 |
+
table_lines = [f"**[{rw_query['asset_class']} 자산 분류 위험가중치 표]**", ""]
|
| 927 |
+
for k, v in table.items():
|
| 928 |
+
table_lines.append(f"- {k}: **{v}**")
|
| 929 |
+
|
| 930 |
+
supporting = search_chunks_by_concept(chunks, "Concept_RWA_Calculation",
|
| 931 |
+
top_k=2, prefer_summary=True)
|
| 932 |
+
supporting_text = render_supporting_chunks(supporting, max_n=2, max_text=300)
|
| 933 |
+
|
| 934 |
+
note = "\n\n신용등급을 명확히 지정하시면 정확한 가중치를 조회할 수 있습니다 (예: 'AAA~AA-')."
|
| 935 |
+
|
| 936 |
+
return {
|
| 937 |
+
"answer": "\n".join(table_lines) + note + supporting_text,
|
| 938 |
+
"route": "lookup_table_overview",
|
| 939 |
+
"context_summary": f"Lookup 표 전체: {rw_query['asset_class']} + 보조 청크 {len(supporting)}개",
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
# 2-4. 펀드명 매칭 + 진행상태 (Q1~Q3 패턴)
|
| 943 |
+
if matched_investment and any(kw in question for kw in PROGRESS_KEYWORDS):
|
| 944 |
+
meta = query_investment_meta(g, matched_investment)
|
| 945 |
+
branches = query_investment_branches(g, matched_investment)
|
| 946 |
+
answer = template_investment_status(meta, branches)
|
| 947 |
+
return {
|
| 948 |
+
"answer": answer,
|
| 949 |
+
"route": f"investment_status ({matched_source})",
|
| 950 |
+
"context_summary": f"인스턴스 SPARQL: {matched_investment}",
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
# 2-5. 펀드명 매칭 + 규제 키워드 (인스턴스 + 청크 컨텍스트)
|
| 954 |
+
concept_id = detect_concept_from_question(question)
|
| 955 |
+
|
| 956 |
+
if matched_investment and concept_id:
|
| 957 |
+
# 인스턴스 컨텍스트 + 청크 컨텍스트 결합
|
| 958 |
+
meta = query_investment_meta(g, matched_investment)
|
| 959 |
+
branches = query_investment_branches(g, matched_investment)
|
| 960 |
+
|
| 961 |
+
instance_context = template_investment_status(meta, branches)
|
| 962 |
+
|
| 963 |
+
prefer_summary = (concept_id == "Concept_RWA_Calculation")
|
| 964 |
+
relevant_chunks = search_chunks_by_concept(chunks, concept_id, top_k=3, prefer_summary=prefer_summary)
|
| 965 |
+
|
| 966 |
+
chunk_context = "\n\n".join([
|
| 967 |
+
f"[규제 근거 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 968 |
+
for i, c in enumerate(relevant_chunks)
|
| 969 |
+
])
|
| 970 |
+
|
| 971 |
+
full_context = f"{instance_context}\n\n---\n\n[관련 규제 근거]\n{chunk_context}"
|
| 972 |
+
|
| 973 |
+
if use_gemma:
|
| 974 |
+
gemma_answer, gemma_ok = call_gemma(question, full_context, mode="standard", model_name=model_name)
|
| 975 |
+
else:
|
| 976 |
+
gemma_answer, gemma_ok = None, False
|
| 977 |
+
|
| 978 |
+
if gemma_ok:
|
| 979 |
+
answer = gemma_answer
|
| 980 |
+
else:
|
| 981 |
+
# Gemma 미사용/실패 시: 시연 모드 (구조화된 raw 컨텍스트 표시)
|
| 982 |
+
answer = (
|
| 983 |
+
f"{instance_context}\n\n"
|
| 984 |
+
f"---\n\n"
|
| 985 |
+
f"**관련 규제 근거 ({len(relevant_chunks)}개 청크 검색됨):**\n\n"
|
| 986 |
+
+ "\n\n".join([f"📄 **{c.get('id', '')}**\n{c.get('text', '')[:500]}..." for c in relevant_chunks])
|
| 987 |
+
)
|
| 988 |
+
|
| 989 |
+
return {
|
| 990 |
+
"answer": answer,
|
| 991 |
+
"route": f"instance_with_concept ({matched_source}, {concept_id})",
|
| 992 |
+
"context_summary": f"인스턴스 + {len(relevant_chunks)}개 규제 청크",
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
# 2-6. 일반 규제 질문 (펀드명 없음, Q6~Q8 패턴)
|
| 996 |
+
if concept_id:
|
| 997 |
+
prefer_summary = (concept_id == "Concept_RWA_Calculation")
|
| 998 |
+
relevant_chunks = search_chunks_by_concept(chunks, concept_id, top_k=3, prefer_summary=prefer_summary)
|
| 999 |
+
|
| 1000 |
+
if not relevant_chunks:
|
| 1001 |
+
return {
|
| 1002 |
+
"answer": "관련 규제 정보를 찾을 수 없습니다. 다른 키워드로 다시 질문해주세요.",
|
| 1003 |
+
"route": "rag_concept_no_match",
|
| 1004 |
+
"context_summary": f"concept {concept_id} 청크 0개",
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
chunk_context = "\n\n".join([
|
| 1008 |
+
f"[근거 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 1009 |
+
for i, c in enumerate(relevant_chunks)
|
| 1010 |
+
])
|
| 1011 |
+
|
| 1012 |
+
if use_gemma:
|
| 1013 |
+
gemma_answer, gemma_ok = call_gemma(question, chunk_context, mode="standard", model_name=model_name)
|
| 1014 |
+
else:
|
| 1015 |
+
gemma_answer, gemma_ok = None, False
|
| 1016 |
+
|
| 1017 |
+
if gemma_ok:
|
| 1018 |
+
answer = gemma_answer
|
| 1019 |
+
else:
|
| 1020 |
+
answer = (
|
| 1021 |
+
f"**관련 규제 근거 ({len(relevant_chunks)}개 청크 검색됨):**\n\n"
|
| 1022 |
+
+ "\n\n".join([f"📄 **{c.get('id', '')}**\n{c.get('text', '')[:500]}..." for c in relevant_chunks])
|
| 1023 |
+
)
|
| 1024 |
+
|
| 1025 |
+
return {
|
| 1026 |
+
"answer": answer,
|
| 1027 |
+
"route": f"rag_concept ({concept_id})",
|
| 1028 |
+
"context_summary": f"{len(relevant_chunks)}개 규제 청크",
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
# === 축 A 신규 라우트 (guidance 폴백 직전) — 열린 질의를 거부 대신 처리 ===
|
| 1032 |
+
# Q15 상장/비상장 카운트
|
| 1033 |
+
if any(kw in question for kw in LISTING_KEYWORDS):
|
| 1034 |
+
return {"answer": template_listing_count(g), "route": "listing_count",
|
| 1035 |
+
"context_summary": "피투자사 상장구분 SPARQL"}
|
| 1036 |
+
# Q14 결재/전결권 순서
|
| 1037 |
+
if any(kw in question for kw in APPROVAL_KEYWORDS):
|
| 1038 |
+
return {"answer": template_approval(g, matched_investment), "route": "approval_order",
|
| 1039 |
+
"context_summary": "전��권 lookup"}
|
| 1040 |
+
# Q12 금액 최대
|
| 1041 |
+
if any(kw in question for kw in MAXAMOUNT_KEYWORDS):
|
| 1042 |
+
return {"answer": template_max_amount(g), "route": "aggregation_max",
|
| 1043 |
+
"context_summary": "금액 MAX SPARQL"}
|
| 1044 |
+
# Q13 다중 투자
|
| 1045 |
+
if any(kw in question for kw in MULTIPLICITY_KEYWORDS):
|
| 1046 |
+
return {"answer": template_multiplicity(g), "route": "multiplicity",
|
| 1047 |
+
"context_summary": "분기/피투자 카운트 SPARQL"}
|
| 1048 |
+
# Q11 진행 중 전체 목록
|
| 1049 |
+
if any(kw in question for kw in LIST_ACTIVE_KEYWORDS):
|
| 1050 |
+
return {"answer": template_list_active(g), "route": "list_active",
|
| 1051 |
+
"context_summary": "활성 검토건 SPARQL"}
|
| 1052 |
+
# Q8 거래상대방 식별
|
| 1053 |
+
if any(kw in question for kw in COUNTERPARTY_KEYWORDS):
|
| 1054 |
+
return {"answer": template_counterparty_overview(g, matched_investment), "route": "counterparty",
|
| 1055 |
+
"context_summary": "거래상대방 다층 구조"}
|
| 1056 |
+
|
| 1057 |
+
# 2-7. 매칭 안 됨 — 안내
|
| 1058 |
+
fallback = (
|
| 1059 |
+
"이 시스템은 다음과 같은 질문에 답변할 수 있습니다:\n\n"
|
| 1060 |
+
"**1. 검토건 진행 상태** (예시 검토건 또는 직접 등록한 검토건 대상)\n"
|
| 1061 |
+
" • \"ABC펀드 검토건은 어디까지 갔어?\"\n"
|
| 1062 |
+
" • \"방금 등록한 펀드는 단계가 어떻게 돼?\"\n\n"
|
| 1063 |
+
"**2. 단계별 검토건 추출**\n"
|
| 1064 |
+
" • \"약정 단계 이후로 진행된 검토건은?\"\n"
|
| 1065 |
+
" • \"정체된 검토건 있어?\"\n\n"
|
| 1066 |
+
"**3. 위험가중치 조회**\n"
|
| 1067 |
+
" • \"AAA 등급 중앙정부 익스포져 위험가중치는?\"\n\n"
|
| 1068 |
+
"**4. 규제 설명**\n"
|
| 1069 |
+
" • \"LP출자한 펀드의 RWA는 어떻게 산정해?\"\n"
|
| 1070 |
+
" • \"적합성 원칙은 어떻게 확인해?\"\n"
|
| 1071 |
+
" • \"대체투자 분류 기준은?\"\n\n"
|
| 1072 |
+
"📌 위 범위 외의 질문(일반 금융 상식, 회사 실제 데이터 등)에는 정확한 답변이 어렵습니다."
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
return {
|
| 1076 |
+
"answer": fallback,
|
| 1077 |
+
"route": "guidance",
|
| 1078 |
+
"context_summary": "매칭 실패, 가이드 응답",
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
|
| 1082 |
+
# ============================================
|
| 1083 |
+
# 5. 축 B — LLM 의도 파싱 (KoSimCSE 의미검색은 다음 단계)
|
| 1084 |
+
# ============================================
|
| 1085 |
+
INTENT_SYSTEM = (
|
| 1086 |
+
"너는 LP출자 검토 질의 분석기다. 질문을 읽고 아래 스키마의 JSON만 출력해라. 설명·코드블록·주석 금지.\n"
|
| 1087 |
+
'스키마: {"intent":"...","fund":null,"operation":null,"concept":null}\n'
|
| 1088 |
+
"\n"
|
| 1089 |
+
"[intent 가이드 — 공식어/구어체 모두 포함]\n"
|
| 1090 |
+
"- investment_status: 특정 펀드/검토건의 현재 단계 (예: \"ive 어디까지\", \"aespa 건 작업 상황\", \"twice 진행 단계\", \"ive 쪽은 지금 일 어디까지\")\n"
|
| 1091 |
+
"- stage_threshold: 특정 단계 이후 진행된 검토건 (예: \"약정 이후\", \"사인 끝내고 다음 단계로 넘어간\", \"한도약정 통과한\")\n"
|
| 1092 |
+
"- review_stalled: 정체·결재 안 난 검토건 (예: \"막힌\", \"지체\", \"결재 못 받고 멈춰있는\", \"올렸는데 답이 없는\")\n"
|
| 1093 |
+
"- list_active: 진행 중인 검토건 전체 목록 (예: \"검토 진행 중인 건들\", \"회사가 들여다보는 딜\", \"전체 검토건\")\n"
|
| 1094 |
+
"- aggregation: 금액 최대·최소·합계 (예: \"가장 큰\", \"제일 큰\", \"규모가 제일 센\", \"최대 금액\", \"한도가 가장 높은\")\n"
|
| 1095 |
+
"- multiplicity: 한 건에 분기 다수 또는 한 펀드가 다수 투자 (예: \"한 방에 여러 군데 꽂는\", \"여러 개 동시\", \"복수 투자\", \"한번에 여러\")\n"
|
| 1096 |
+
"- approval_order: 결재·전결권 순서 (예: \"결재 순서\", \"사인 누구한테 받아야\", \"승인권자\", \"전결 누구\")\n"
|
| 1097 |
+
"- listing_count: 피투자사 상장/비상장 카운트 (예: \"상장사 몇 개\", \"비상장 몇 개\", \"코스피·코스닥 올라간 회사\", \"상장 여부 갯수\")\n"
|
| 1098 |
+
"- counterparty: 거래상대방 식별·다층 구조 (예: \"거래상대방\", \"딜할 때 상대방\", \"누구를 봐야\", \"상대편 식별\")\n"
|
| 1099 |
+
"- deterministic_lookup: 정형 수치 조회(자산분류+신용등급→%, BIS/위험가중치 표). 트리거: 신용등급(AAA/A+ 등) + 자산명 + 수치 질문 (예: \"AAA 정부채권 위험가중치\", \"BBB 회사채 자기자본 비율 몇 %\", \"중앙정부 가중치\")\n"
|
| 1100 |
+
"- rag_concept: 규제 개념·원칙·산정 방법 설명 (예: \"적합성 원칙\", \"대체투자 분류 기준\", \"RWA 산정 방법\", \"고객한테 적합한 상품인지\")\n"
|
| 1101 |
+
"- unknown: 위 어디에도 해당 없음\n"
|
| 1102 |
+
"\n"
|
| 1103 |
+
"[fund]: 검토건의 펀드/별칭. 비일상 영문 소문자 토큰(ive/aespa/twice/abc 등) 또는 한글 펀드명. 없으면 null.\n"
|
| 1104 |
+
"[operation]: aggregation일 때만 max/min/sum/count, 아니면 null.\n"
|
| 1105 |
+
"[concept]: rag_concept일 때만 RWA_Calculation/SuitabilityCheck/AlternativeInvestmentClassification/ExplanationDuty/ConsumerClassification/ProductDefinition/CustomerIdentification/TotalExposureLimit/PortfolioMonitoring/SoundnessGrading 등, 아니면 null.\n"
|
| 1106 |
+
"\n"
|
| 1107 |
+
"[예시]\n"
|
| 1108 |
+
"Q: \"aespa 건 작업 상황 좀 알려줘.\"\n"
|
| 1109 |
+
'{"intent":"investment_status","fund":"aespa","operation":null,"concept":null}\n'
|
| 1110 |
+
"Q: \"딜할 때 상대방 쪽은 누구를 살펴야 해?\"\n"
|
| 1111 |
+
'{"intent":"counterparty","fund":null,"operation":null,"concept":null}\n'
|
| 1112 |
+
"Q: \"AAA 등급 정부 채권 자기자본 비율 산정할 때 몇 % 잡아?\"\n"
|
| 1113 |
+
'{"intent":"deterministic_lookup","fund":null,"operation":null,"concept":null}\n'
|
| 1114 |
+
"Q: \"코스피·코스닥 올라간 회사랑 안 올라간 회사 숫자 좀.\"\n"
|
| 1115 |
+
'{"intent":"listing_count","fund":null,"operation":null,"concept":null}\n'
|
| 1116 |
+
"Q: \"규모가 제일 센 투자가 뭐야?\"\n"
|
| 1117 |
+
'{"intent":"aggregation","fund":null,"operation":"max","concept":null}\n'
|
| 1118 |
+
"Q: \"한 방에 여러 군데 꽂는 거 있어?\"\n"
|
| 1119 |
+
'{"intent":"multiplicity","fund":null,"operation":null,"concept":null}\n'
|
| 1120 |
+
"Q: \"고객한테 적합한 상품인지 어떻게 보지?\"\n"
|
| 1121 |
+
'{"intent":"rag_concept","fund":null,"operation":null,"concept":"SuitabilityCheck"}'
|
| 1122 |
+
)
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
def llm_parse_intent(question, model_name="gemma4:e4b", use_anthropic=False):
|
| 1126 |
+
"""1차 LLM 호출 — 질의를 구조화 JSON으로 파싱. 실패 시 None (→ 키워드 라우터 fallback)."""
|
| 1127 |
+
import json as _json
|
| 1128 |
+
try:
|
| 1129 |
+
if use_anthropic:
|
| 1130 |
+
import os, anthropic
|
| 1131 |
+
key = os.environ.get("ANTHROPIC_API_KEY", "")
|
| 1132 |
+
if not key:
|
| 1133 |
+
return None
|
| 1134 |
+
cli = anthropic.Anthropic(api_key=key)
|
| 1135 |
+
resp = cli.messages.create(
|
| 1136 |
+
model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
|
| 1137 |
+
max_tokens=200, temperature=0, system=INTENT_SYSTEM,
|
| 1138 |
+
messages=[{"role": "user", "content": question}])
|
| 1139 |
+
txt = "".join(b.text for b in resp.content if hasattr(b, "text"))
|
| 1140 |
+
else:
|
| 1141 |
+
import requests
|
| 1142 |
+
r = requests.post("http://localhost:11434/api/generate",
|
| 1143 |
+
json={"model": model_name, "system": INTENT_SYSTEM, "prompt": question,
|
| 1144 |
+
"format": "json", "stream": False, "options": {"temperature": 0}}, timeout=60)
|
| 1145 |
+
txt = r.json().get("response", "")
|
| 1146 |
+
s, e = txt.find("{"), txt.rfind("}")
|
| 1147 |
+
if s < 0 or e < 0:
|
| 1148 |
+
return None
|
| 1149 |
+
d = _json.loads(txt[s:e + 1])
|
| 1150 |
+
return d if isinstance(d, dict) and d.get("intent") else None
|
| 1151 |
+
except Exception:
|
| 1152 |
+
return None
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
def _resolve_fund(g, fund_name):
|
| 1156 |
+
if not fund_name:
|
| 1157 |
+
return None
|
| 1158 |
+
for inv in query_all_investments_with_label(g):
|
| 1159 |
+
if inv.get("n_branches", 0) == 0:
|
| 1160 |
+
continue
|
| 1161 |
+
blob = inv["label"] + " " + inv["fund_label"]
|
| 1162 |
+
if str(fund_name) and str(fund_name).lower() in blob.lower():
|
| 1163 |
+
return inv["iri"]
|
| 1164 |
+
return None
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
def _resolve_concept(question, parser_concept):
|
| 1168 |
+
"""rag_concept intent에서 concept_id를 확정.
|
| 1169 |
+
우선순위: ① LLM 파서 명시값(유효 시) → ② 키워드 ID 매칭 → ③ KoSimCSE 의미검색.
|
| 1170 |
+
"""
|
| 1171 |
+
valid = {
|
| 1172 |
+
"RWA_Calculation", "SuitabilityCheck", "AlternativeInvestmentClassification",
|
| 1173 |
+
"ExplanationDuty", "ConsumerClassification", "ProductDefinition",
|
| 1174 |
+
"CustomerIdentification", "TotalExposureLimit", "CommitmentRegistration",
|
| 1175 |
+
"PortfolioMonitoring", "SoundnessGrading", "OngoingReporting",
|
| 1176 |
+
}
|
| 1177 |
+
if parser_concept:
|
| 1178 |
+
pc = parser_concept.replace("Concept_", "")
|
| 1179 |
+
if pc in valid:
|
| 1180 |
+
return f"Concept_{pc}"
|
| 1181 |
+
kw = detect_concept_from_question(question)
|
| 1182 |
+
if kw:
|
| 1183 |
+
return kw
|
| 1184 |
+
try:
|
| 1185 |
+
from semantic_search import detect_concept_semantic
|
| 1186 |
+
return detect_concept_semantic(question, top_k=1, min_score=0.40)
|
| 1187 |
+
except Exception:
|
| 1188 |
+
return None
|
| 1189 |
+
|
| 1190 |
+
|
| 1191 |
+
def _answer_rag_concept_semantic(question, g, chunks, lookup, concept_id,
|
| 1192 |
+
use_gemma=True, model_name="gemma4:e4b"):
|
| 1193 |
+
"""rag_concept 라우트의 의미검색 버전. 청크는 항상 KoSimCSE top-k."""
|
| 1194 |
+
from semantic_search import search_chunks_semantic
|
| 1195 |
+
# 의미검색으로 top-5 → 점수 0.30 미만은 잘라 노이즈 컷
|
| 1196 |
+
sem_chunks = search_chunks_semantic(chunks, question, top_k=5, min_score=0.30)
|
| 1197 |
+
# concept이 잡혔으면 그 concept 메타 청크를 boost(앞으로): "의미 1위 + concept 매칭 청크 우선"
|
| 1198 |
+
if concept_id:
|
| 1199 |
+
meta_chunks = search_chunks_by_concept(chunks, concept_id, top_k=3)
|
| 1200 |
+
meta_ids = {c.get("id") for c in meta_chunks}
|
| 1201 |
+
sem_ids = {c.get("id") for c in sem_chunks}
|
| 1202 |
+
boosted = meta_chunks + [c for c in sem_chunks if c.get("id") not in meta_ids]
|
| 1203 |
+
relevant = boosted[:5]
|
| 1204 |
+
else:
|
| 1205 |
+
relevant = sem_chunks[:3]
|
| 1206 |
+
|
| 1207 |
+
if not relevant:
|
| 1208 |
+
return {
|
| 1209 |
+
"answer": "관련 규제 정보를 찾을 수 없습니다. 다른 표현으로 다시 질문해주세요.",
|
| 1210 |
+
"route": "llm:rag_concept_semantic_no_match",
|
| 1211 |
+
"context_summary": "의미검색 결과 없음",
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
chunk_context = "\n\n".join([
|
| 1215 |
+
f"[근거 {i+1}] {c.get('id', '')}\n{c.get('text', '')[:1500]}"
|
| 1216 |
+
for i, c in enumerate(relevant)
|
| 1217 |
+
])
|
| 1218 |
+
if use_gemma:
|
| 1219 |
+
gemma_answer, gemma_ok = call_gemma(question, chunk_context, mode="standard", model_name=model_name)
|
| 1220 |
+
else:
|
| 1221 |
+
gemma_answer, gemma_ok = None, False
|
| 1222 |
+
if gemma_ok:
|
| 1223 |
+
answer = gemma_answer
|
| 1224 |
+
else:
|
| 1225 |
+
answer = (
|
| 1226 |
+
f"**관련 규제 근거 ({len(relevant)}개 청크 — KoSimCSE 의미검색):**\n\n"
|
| 1227 |
+
+ "\n\n".join([f"📄 **{c.get('id', '')}**\n{c.get('text', '')[:500]}..." for c in relevant])
|
| 1228 |
+
)
|
| 1229 |
+
return {
|
| 1230 |
+
"answer": answer,
|
| 1231 |
+
"route": f"llm:rag_concept_semantic ({concept_id or '의미만'})",
|
| 1232 |
+
"context_summary": f"KoSimCSE {len(relevant)}개 청크 (concept={concept_id})",
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
|
| 1236 |
+
def answer_question_llm(question, g, chunks, alias, lookup, model_name="gemma4:e4b",
|
| 1237 |
+
use_anthropic=False, use_gemma_gen=True, use_semantic=False):
|
| 1238 |
+
"""축 B 진입점: LLM 의도파싱 → 라우팅.
|
| 1239 |
+
|
| 1240 |
+
use_semantic=True 면 rag_concept 라우트에 KoSimCSE 의미검색 활성화 (Q6·Q7 견고화).
|
| 1241 |
+
파싱 실패/unknown → 키워드 answer_question로 fallback.
|
| 1242 |
+
"""
|
| 1243 |
+
p = llm_parse_intent(question, model_name, use_anthropic)
|
| 1244 |
+
if not p:
|
| 1245 |
+
return answer_question(question, g, chunks, alias, lookup, use_gemma=use_gemma_gen, model_name=model_name)
|
| 1246 |
+
intent = p.get("intent")
|
| 1247 |
+
inv_iri = _resolve_fund(g, p.get("fund"))
|
| 1248 |
+
R = lambda ans, rt: {"answer": ans, "route": f"llm:{rt}", "context_summary": f"LLM의도파싱 intent={intent}"}
|
| 1249 |
+
if intent == "list_active": return R(template_list_active(g), "list_active")
|
| 1250 |
+
if intent == "aggregation": return R(template_max_amount(g), "aggregation")
|
| 1251 |
+
if intent == "multiplicity": return R(template_multiplicity(g), "multiplicity")
|
| 1252 |
+
if intent == "approval_order": return R(template_approval(g, inv_iri), "approval_order")
|
| 1253 |
+
if intent == "listing_count": return R(template_listing_count(g), "listing_count")
|
| 1254 |
+
if intent == "counterparty": return R(template_counterparty_overview(g, inv_iri), "counterparty")
|
| 1255 |
+
if intent == "stage_threshold": return R(template_stage_threshold(g, threshold_order=1), "stage_threshold")
|
| 1256 |
+
if intent == "review_stalled": return R(template_review_stalled(g), "review_stalled")
|
| 1257 |
+
if intent == "investment_status" and inv_iri:
|
| 1258 |
+
meta = query_investment_meta(g, inv_iri)
|
| 1259 |
+
return R(template_investment_status(meta, query_investment_branches(g, inv_iri)), "investment_status")
|
| 1260 |
+
if intent == "rag_concept" and use_semantic:
|
| 1261 |
+
cid = _resolve_concept(question, p.get("concept"))
|
| 1262 |
+
return _answer_rag_concept_semantic(question, g, chunks, lookup, cid,
|
| 1263 |
+
use_gemma=use_gemma_gen, model_name=model_name)
|
| 1264 |
+
# deterministic_lookup / rag_concept(키워드모드) / unknown / fund 미해결 → 키워드 라우터 재사용
|
| 1265 |
+
return answer_question(question, g, chunks, alias, lookup, use_gemma=use_gemma_gen, model_name=model_name)
|
code/semantic_search.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KoSimCSE 의미검색 — 축 B.
|
| 2 |
+
|
| 3 |
+
청크 274개 + RegulatoryConcept 12개를 KoSimCSE 문장 임베딩으로 인코딩하고,
|
| 4 |
+
질의 임베딩과 코사인 유사도 top-k 반환. 기존 키워드 매칭(search_chunks_by_concept/
|
| 5 |
+
search_chunks_by_keyword, detect_concept_from_question)의 의미적 대체.
|
| 6 |
+
|
| 7 |
+
캐시: ontology/_embeddings_cache/*.npz (소스 파일 mtime 동봉; 변경 시 재계산).
|
| 8 |
+
모델: BM-K/KoSimCSE-roberta (~440MB, 폐쇄망에서는 사전 prebake 필요).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import os, json, hashlib
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
# 기본 경로 — rag_engine과 같은 'code/' 디렉토리에 위치한다고 가정
|
| 19 |
+
_ROOT = Path(__file__).resolve().parent.parent # active/
|
| 20 |
+
_CACHE_DIR = _ROOT / "ontology" / "_embeddings_cache"
|
| 21 |
+
_TTL_PATH = _ROOT / "ontology" / "investment_ontology_v1_10.ttl"
|
| 22 |
+
_CHUNKS_PATH = _ROOT / "ontology" / "regulations_chunks_v14.jsonl"
|
| 23 |
+
|
| 24 |
+
MODEL_NAME = os.environ.get("KOSIMCSE_MODEL", "BM-K/KoSimCSE-roberta")
|
| 25 |
+
|
| 26 |
+
# 모듈 전역 — lazy 로드
|
| 27 |
+
_tokenizer = None
|
| 28 |
+
_hf_model = None
|
| 29 |
+
_chunk_cache = None # {"ids": [str], "vecs": ndarray, "chunks": [dict]}
|
| 30 |
+
_concept_cache = None # {"ids": [str], "vecs": ndarray, "labels": [str], "comments": [str]}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ----------------------------------------------------------------------
|
| 34 |
+
# 모델 로드 — KoSimCSE는 sentence-transformers 네이티브가 아니므로
|
| 35 |
+
# transformers로 직접 로드해 CLS 토큰(=문장 임베딩)을 뽑는다.
|
| 36 |
+
# (sentence-transformers로 감싸면 mean pooling이 자동 적용돼 품질이 떨어짐.)
|
| 37 |
+
# ----------------------------------------------------------------------
|
| 38 |
+
def _get_model():
|
| 39 |
+
global _tokenizer, _hf_model
|
| 40 |
+
if _hf_model is None:
|
| 41 |
+
from transformers import AutoModel, AutoTokenizer
|
| 42 |
+
import torch
|
| 43 |
+
_tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 44 |
+
_hf_model = AutoModel.from_pretrained(MODEL_NAME)
|
| 45 |
+
_hf_model.eval()
|
| 46 |
+
# MPS는 짧은 시퀀스에서 간헐적 NaN/Inf 관측 → CPU 기본. 환경변수로 강제 가능.
|
| 47 |
+
force = os.environ.get("KOSIMCSE_DEVICE", "cpu")
|
| 48 |
+
device = force if force else "cpu"
|
| 49 |
+
_hf_model.to(device)
|
| 50 |
+
return _tokenizer, _hf_model
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _embed(texts: list[str]) -> np.ndarray:
|
| 54 |
+
"""문장 리스트 → (n, d) L2-normalized ndarray (KoSimCSE CLS token)."""
|
| 55 |
+
import torch
|
| 56 |
+
tok, model = _get_model()
|
| 57 |
+
device = next(model.parameters()).device
|
| 58 |
+
out = []
|
| 59 |
+
batch_size = 32
|
| 60 |
+
with torch.no_grad():
|
| 61 |
+
for i in range(0, len(texts), batch_size):
|
| 62 |
+
batch = texts[i:i + batch_size]
|
| 63 |
+
enc = tok(batch, padding=True, truncation=True,
|
| 64 |
+
max_length=128, return_tensors="pt").to(device)
|
| 65 |
+
embeddings, _ = model(**enc, return_dict=False)
|
| 66 |
+
cls = embeddings[:, 0] # [CLS] = 문장 표현
|
| 67 |
+
cls = torch.nn.functional.normalize(cls, p=2, dim=1, eps=1e-8)
|
| 68 |
+
arr = cls.cpu().numpy().astype(np.float32)
|
| 69 |
+
# 안전망: NaN/Inf → 0벡터 (검색 점수 0)
|
| 70 |
+
bad = ~np.isfinite(arr).all(axis=1)
|
| 71 |
+
if bad.any():
|
| 72 |
+
arr[bad] = 0.0
|
| 73 |
+
out.append(arr)
|
| 74 |
+
return np.vstack(out)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ----------------------------------------------------------------------
|
| 78 |
+
# 캐시 헬퍼
|
| 79 |
+
# ----------------------------------------------------------------------
|
| 80 |
+
def _file_fingerprint(path: Path) -> str:
|
| 81 |
+
"""파일 mtime+size로 간단한 fingerprint. 내용 hash까지 가면 느려서 보류."""
|
| 82 |
+
if not path.exists():
|
| 83 |
+
return "missing"
|
| 84 |
+
st = path.stat()
|
| 85 |
+
return f"{int(st.st_mtime)}-{st.st_size}"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _cache_path(name: str) -> Path:
|
| 89 |
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
return _CACHE_DIR / name
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _load_npz_cache(path: Path, expected_fp: str, expected_model: str):
|
| 94 |
+
"""fingerprint·모델명이 일치하면 로드, 아니면 None."""
|
| 95 |
+
if not path.exists():
|
| 96 |
+
return None
|
| 97 |
+
try:
|
| 98 |
+
d = np.load(path, allow_pickle=True)
|
| 99 |
+
if str(d["fingerprint"]) != expected_fp or str(d["model"]) != expected_model:
|
| 100 |
+
return None
|
| 101 |
+
return d
|
| 102 |
+
except Exception:
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ----------------------------------------------------------------------
|
| 107 |
+
# 청크 임베딩
|
| 108 |
+
# ----------------------------------------------------------------------
|
| 109 |
+
def _build_chunk_text(chunk: dict) -> str:
|
| 110 |
+
"""임베딩용 텍스트 구성. 본문 + 법령명(있으면) — 메타 신호 강화."""
|
| 111 |
+
meta = chunk.get("metadata", {})
|
| 112 |
+
head = []
|
| 113 |
+
if meta.get("law_name"):
|
| 114 |
+
head.append(meta["law_name"])
|
| 115 |
+
if meta.get("article_label"):
|
| 116 |
+
head.append(meta["article_label"])
|
| 117 |
+
prefix = " ".join(head)
|
| 118 |
+
body = chunk.get("text", "")
|
| 119 |
+
# KoSimCSE roberta max_seq_length 보통 128. 너무 길면 잘림 — 짧게 유지.
|
| 120 |
+
return (prefix + " " + body).strip()[:512]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _load_or_build_chunk_cache(chunks: list[dict]):
|
| 124 |
+
global _chunk_cache
|
| 125 |
+
if _chunk_cache is not None:
|
| 126 |
+
return _chunk_cache
|
| 127 |
+
|
| 128 |
+
fp = _file_fingerprint(_CHUNKS_PATH)
|
| 129 |
+
cache_path = _cache_path("chunks_v14_kosimcse.npz")
|
| 130 |
+
cached = _load_npz_cache(cache_path, fp, MODEL_NAME)
|
| 131 |
+
if cached is not None:
|
| 132 |
+
_chunk_cache = {
|
| 133 |
+
"ids": list(cached["ids"]),
|
| 134 |
+
"vecs": cached["vecs"],
|
| 135 |
+
"chunks": chunks,
|
| 136 |
+
}
|
| 137 |
+
return _chunk_cache
|
| 138 |
+
|
| 139 |
+
texts = [_build_chunk_text(c) for c in chunks]
|
| 140 |
+
ids = [c.get("id", f"_idx_{i}") for i, c in enumerate(chunks)]
|
| 141 |
+
vecs = _embed(texts)
|
| 142 |
+
np.savez(cache_path,
|
| 143 |
+
ids=np.array(ids, dtype=object),
|
| 144 |
+
vecs=vecs,
|
| 145 |
+
fingerprint=fp,
|
| 146 |
+
model=MODEL_NAME)
|
| 147 |
+
_chunk_cache = {"ids": ids, "vecs": vecs, "chunks": chunks}
|
| 148 |
+
return _chunk_cache
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ----------------------------------------------------------------------
|
| 152 |
+
# RegulatoryConcept 임베딩
|
| 153 |
+
# ----------------------------------------------------------------------
|
| 154 |
+
def _load_concepts_from_ttl():
|
| 155 |
+
"""TTL에서 RegulatoryConcept (id, label, comment) 추출."""
|
| 156 |
+
from rdflib import Graph
|
| 157 |
+
g = Graph()
|
| 158 |
+
g.parse(str(_TTL_PATH), format="turtle")
|
| 159 |
+
q = """
|
| 160 |
+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 161 |
+
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
| 162 |
+
PREFIX ex: <http://company.com/investment-ontology#>
|
| 163 |
+
SELECT ?c ?label ?comment WHERE {
|
| 164 |
+
?c rdf:type ex:RegulatoryConcept .
|
| 165 |
+
OPTIONAL { ?c rdfs:label ?label }
|
| 166 |
+
OPTIONAL { ?c rdfs:comment ?comment }
|
| 167 |
+
} ORDER BY ?c
|
| 168 |
+
"""
|
| 169 |
+
out = []
|
| 170 |
+
for row in g.query(q):
|
| 171 |
+
cid = str(row[0]).split("#")[-1]
|
| 172 |
+
lbl = str(row[1]) if row[1] is not None else ""
|
| 173 |
+
cmt = str(row[2]) if row[2] is not None else ""
|
| 174 |
+
out.append((cid, lbl, cmt))
|
| 175 |
+
return out
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _load_or_build_concept_cache():
|
| 179 |
+
global _concept_cache
|
| 180 |
+
if _concept_cache is not None:
|
| 181 |
+
return _concept_cache
|
| 182 |
+
|
| 183 |
+
fp = _file_fingerprint(_TTL_PATH)
|
| 184 |
+
cache_path = _cache_path("concepts_v1_10_kosimcse.npz")
|
| 185 |
+
cached = _load_npz_cache(cache_path, fp, MODEL_NAME)
|
| 186 |
+
if cached is not None:
|
| 187 |
+
_concept_cache = {
|
| 188 |
+
"ids": list(cached["ids"]),
|
| 189 |
+
"labels": list(cached["labels"]),
|
| 190 |
+
"comments": list(cached["comments"]),
|
| 191 |
+
"vecs": cached["vecs"],
|
| 192 |
+
}
|
| 193 |
+
return _concept_cache
|
| 194 |
+
|
| 195 |
+
rows = _load_concepts_from_ttl()
|
| 196 |
+
ids = [r[0] for r in rows]
|
| 197 |
+
labels = [r[1] for r in rows]
|
| 198 |
+
comments = [r[2] for r in rows]
|
| 199 |
+
texts = [(lbl + " " + cmt).strip() or cid for cid, lbl, cmt in rows]
|
| 200 |
+
vecs = _embed(texts)
|
| 201 |
+
np.savez(cache_path,
|
| 202 |
+
ids=np.array(ids, dtype=object),
|
| 203 |
+
labels=np.array(labels, dtype=object),
|
| 204 |
+
comments=np.array(comments, dtype=object),
|
| 205 |
+
vecs=vecs,
|
| 206 |
+
fingerprint=fp,
|
| 207 |
+
model=MODEL_NAME)
|
| 208 |
+
_concept_cache = {"ids": ids, "labels": labels, "comments": comments, "vecs": vecs}
|
| 209 |
+
return _concept_cache
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ----------------------------------------------------------------------
|
| 213 |
+
# 공개 API
|
| 214 |
+
# ----------------------------------------------------------------------
|
| 215 |
+
def _cosine_scores(vecs: np.ndarray, qv: np.ndarray) -> np.ndarray:
|
| 216 |
+
"""코사인 점수 — NaN/Inf 안전 (점수 -∞ 처리)."""
|
| 217 |
+
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
|
| 218 |
+
s = vecs @ qv
|
| 219 |
+
s = np.nan_to_num(s, nan=-np.inf, posinf=-np.inf, neginf=-np.inf)
|
| 220 |
+
return s
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def search_chunks_semantic(chunks: list[dict], query: str, top_k: int = 5,
|
| 224 |
+
min_score: float = 0.0) -> list[dict]:
|
| 225 |
+
"""질의 의미와 가장 가까운 청크 top_k. 각 청크에 _semantic_score 추가."""
|
| 226 |
+
cache = _load_or_build_chunk_cache(chunks)
|
| 227 |
+
qv = _embed([query])[0]
|
| 228 |
+
scores = _cosine_scores(cache["vecs"], qv)
|
| 229 |
+
order = np.argsort(-scores)[:top_k]
|
| 230 |
+
out = []
|
| 231 |
+
for idx in order:
|
| 232 |
+
s = float(scores[idx])
|
| 233 |
+
if not np.isfinite(s) or s < min_score:
|
| 234 |
+
continue
|
| 235 |
+
c = dict(cache["chunks"][idx])
|
| 236 |
+
c["_semantic_score"] = s
|
| 237 |
+
out.append(c)
|
| 238 |
+
return out
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def detect_concept_semantic(query: str, top_k: int = 1,
|
| 242 |
+
min_score: float = 0.35) -> Optional[str]:
|
| 243 |
+
"""질의 의미와 가장 가까운 RegulatoryConcept ID. min_score 미만이면 None."""
|
| 244 |
+
cache = _load_or_build_concept_cache()
|
| 245 |
+
qv = _embed([query])[0]
|
| 246 |
+
scores = _cosine_scores(cache["vecs"], qv)
|
| 247 |
+
order = np.argsort(-scores)[:top_k]
|
| 248 |
+
best_idx = int(order[0])
|
| 249 |
+
best_score = float(scores[best_idx])
|
| 250 |
+
if not np.isfinite(best_score) or best_score < min_score:
|
| 251 |
+
return None
|
| 252 |
+
return cache["ids"][best_idx]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def detect_concept_semantic_topk(query: str, top_k: int = 3) -> list[tuple[str, float, str]]:
|
| 256 |
+
"""디버깅용: (concept_id, score, label) top_k 반환."""
|
| 257 |
+
cache = _load_or_build_concept_cache()
|
| 258 |
+
qv = _embed([query])[0]
|
| 259 |
+
scores = _cosine_scores(cache["vecs"], qv)
|
| 260 |
+
order = np.argsort(-scores)[:top_k]
|
| 261 |
+
return [(cache["ids"][i], float(scores[i]), cache["labels"][i]) for i in order]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def warm_up(chunks: list[dict]):
|
| 265 |
+
"""모델·캐시를 미리 로드. 첫 질의 응답 지연 회피."""
|
| 266 |
+
_get_model()
|
| 267 |
+
_load_or_build_chunk_cache(chunks)
|
| 268 |
+
_load_or_build_concept_cache()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ----------------------------------------------------------------------
|
| 272 |
+
# CLI: 캐시 빌드 단독 실행
|
| 273 |
+
# ----------------------------------------------------------------------
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
import argparse, sys
|
| 276 |
+
parser = argparse.ArgumentParser(description="KoSimCSE 임베딩 캐시 빌드 + 간단 테스트")
|
| 277 |
+
parser.add_argument("--query", help="테스트 질의")
|
| 278 |
+
parser.add_argument("--top-k", type=int, default=5)
|
| 279 |
+
parser.add_argument("--concepts-only", action="store_true",
|
| 280 |
+
help="청크 임베딩 빌드 생략 (Concept만)")
|
| 281 |
+
args = parser.parse_args()
|
| 282 |
+
|
| 283 |
+
print(f"[load] 모델 = {MODEL_NAME}")
|
| 284 |
+
_get_model()
|
| 285 |
+
print("[load] 모델 OK")
|
| 286 |
+
|
| 287 |
+
print("[build] concept 캐시")
|
| 288 |
+
c_cache = _load_or_build_concept_cache()
|
| 289 |
+
print(f" → {len(c_cache['ids'])} concept 임베딩")
|
| 290 |
+
|
| 291 |
+
chunks = []
|
| 292 |
+
if not args.concepts_only:
|
| 293 |
+
print("[build] 청크 캐시")
|
| 294 |
+
with open(_CHUNKS_PATH, encoding="utf-8") as f:
|
| 295 |
+
chunks = [json.loads(line) for line in f if line.strip()]
|
| 296 |
+
ch_cache = _load_or_build_chunk_cache(chunks)
|
| 297 |
+
print(f" → {len(ch_cache['ids'])} 청크 임베딩")
|
| 298 |
+
|
| 299 |
+
if args.query:
|
| 300 |
+
print(f"\n[test] 질의 = {args.query!r}")
|
| 301 |
+
print(" concept top-3:")
|
| 302 |
+
for cid, sc, lbl in detect_concept_semantic_topk(args.query, top_k=3):
|
| 303 |
+
print(f" {sc:.3f} {cid} ({lbl})")
|
| 304 |
+
if chunks:
|
| 305 |
+
print(" chunk top-k:")
|
| 306 |
+
for c in search_chunks_semantic(chunks, args.query, top_k=args.top_k):
|
| 307 |
+
meta = c.get("metadata", {})
|
| 308 |
+
print(f" {c['_semantic_score']:.3f} {c.get('id'):20} "
|
| 309 |
+
f"({meta.get('law_name','-')} {meta.get('article_label','-')})")
|
data/alias_dictionary.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_metadata": {
|
| 3 |
+
"version": "v1.0",
|
| 4 |
+
"purpose": "한국 LP출자 도메인 동의어·alias 사전. RAG query expansion 및 청크 검색에 사용.",
|
| 5 |
+
"perspective": "LP_조합원",
|
| 6 |
+
"note": "Gemma 4 e4b 등 영어 중심 LLM의 한국 금융 도메인 동의어 처리 한계 보완용"
|
| 7 |
+
},
|
| 8 |
+
"investment_actors": {
|
| 9 |
+
"LP출자": ["조합원 출자", "유한책임사원 출자", "펀드 출자", "간접 투자", "출자"],
|
| 10 |
+
"GP": ["업무집행조합원", "운용사", "자산운용사", "일반사원", "운용 GP", "Sponsor"],
|
| 11 |
+
"LP": ["유한책임사원", "조합원", "출자자", "Limited Partner"],
|
| 12 |
+
"출자대상펀드": ["피투자펀드", "투자대상펀드", "RecipientFund"],
|
| 13 |
+
"최종투자대상": ["피투자회사", "투자대상회사", "Portfolio Company", "최종 투자처"]
|
| 14 |
+
},
|
| 15 |
+
"fund_types": {
|
| 16 |
+
"PEF": ["사모투자합자회사", "사모펀드", "경영참여형 사모집합투자기구", "Private Equity Fund"],
|
| 17 |
+
"투자합자조합": ["투자합자조합", "PEF 투자합자조합"],
|
| 18 |
+
"벤처투자조합": ["벤처조합", "벤처투자펀드", "VC조합"],
|
| 19 |
+
"신기술사업투자조합": ["신기술조합", "신기술펀드", "NTF"],
|
| 20 |
+
"투자신탁": ["수익증권", "Investment Trust"],
|
| 21 |
+
"투자회사": ["뮤추얼펀드", "Investment Company"],
|
| 22 |
+
"사모집합투자기구": ["사모펀드", "Private Fund"],
|
| 23 |
+
"REITs": ["부동산투자회사", "리츠"]
|
| 24 |
+
},
|
| 25 |
+
"products": {
|
| 26 |
+
"LP출자": ["조합원 출자", "유한책임사원 출자", "펀드 출자"],
|
| 27 |
+
"인수금융": ["M&A 금융", "Acquisition Finance", "LBO 대출", "인수자금 대출"],
|
| 28 |
+
"메자닌": ["전환사채", "신주인수권부사채", "교환사채", "Mezzanine"],
|
| 29 |
+
"직접투자": ["직접 인수", "Direct Investment"]
|
| 30 |
+
},
|
| 31 |
+
"regulatory_concepts": {
|
| 32 |
+
"신용공여한도": ["동일차주 한도", "여신한도", "대주주 한도", "신용공여 한도", "Total Exposure Limit", "토탈 익스포져"],
|
| 33 |
+
"RWA": ["위험가중자산", "Risk Weighted Asset", "위험가중치"],
|
| 34 |
+
"위험가중치": ["RW", "Risk Weight", "리스크 웨이트"],
|
| 35 |
+
"익스포져": ["Exposure", "노출액", "익스포저"],
|
| 36 |
+
"표준방법": ["Standardised Approach", "SA", "표준 접근법"],
|
| 37 |
+
"내부등급법": ["IRB", "Internal Ratings-Based", "내부평가법"],
|
| 38 |
+
"자기자본비율": ["BIS 비율", "자본비율", "Capital Adequacy Ratio", "CAR"],
|
| 39 |
+
"대체투자": ["Alternative Investment", "AI", "대체자산투자"],
|
| 40 |
+
"자산건전성분류": ["건전성분류", "건전성 등급", "자산분류", "Asset Quality Classification"],
|
| 41 |
+
"적합성원칙": ["Suitability", "Suitability Rule"],
|
| 42 |
+
"설명의무": ["Disclosure", "설명 의무", "Explanation Duty"],
|
| 43 |
+
"고객확인": ["KYC", "Know Your Customer", "고객확인의무", "CDD"],
|
| 44 |
+
"자금세탁방지": ["AML", "Anti-Money Laundering", "자금세탁 방지"],
|
| 45 |
+
"내부통제": ["Internal Control", "내부 통제"]
|
| 46 |
+
},
|
| 47 |
+
"process_steps": {
|
| 48 |
+
"거래상대방 식별": ["고객등록", "거래상대방 등록", "Customer Registration"],
|
| 49 |
+
"투자상담": ["상담", "Investment Consultation", "사전 상담"],
|
| 50 |
+
"사전심사": ["사전협의", "Pre-screening", "Preliminary Screening", "사전 검토"],
|
| 51 |
+
"예비검토": ["Preliminary Review", "1차 검토"],
|
| 52 |
+
"실무협의회": ["실무협의", "Working Level Review", "실무 검토"],
|
| 53 |
+
"투자품의": ["품의", "Investment Proposal", "Proposal"],
|
| 54 |
+
"한도약정": ["약정", "Commitment", "약정 체결"],
|
| 55 |
+
"실행품의": ["개별품의", "Drawdown Proposal"],
|
| 56 |
+
"사후관리": ["Post Management", "사후 관리"]
|
| 57 |
+
},
|
| 58 |
+
"asset_classes_byulpyo3": {
|
| 59 |
+
"중앙정부 익스포져": ["국채", "국가 익스포져", "Sovereign", "Central Government"],
|
| 60 |
+
"은행 익스포져": ["은행 차주", "Bank Exposure"],
|
| 61 |
+
"기업 익스포져": ["회사 익스포져", "Corporate Exposure", "기업 차주"],
|
| 62 |
+
"주식 익스포져": ["Equity Exposure", "지분 익스포져"],
|
| 63 |
+
"집합투자증권 익스포져": ["펀드 익스포져", "수익증권", "집합투자기구 익스포져", "CIS Exposure", "LP출자 RWA"],
|
| 64 |
+
"소매 익스포져": ["Retail Exposure", "리테일", "개인 익스포져"],
|
| 65 |
+
"주거용주택담보 익스포져": ["주담대", "Residential Mortgage", "주택 담보"],
|
| 66 |
+
"상업용부동산 익스포져": ["CRE", "Commercial Real Estate"],
|
| 67 |
+
"부동산개발금융": ["부동산 PF", "PF", "Project Financing", "브릿지론"]
|
| 68 |
+
},
|
| 69 |
+
"laws": {
|
| 70 |
+
"자본시장법": ["자본시장과 금융투자업에 관한 법률", "CMFA", "Capital Markets Act"],
|
| 71 |
+
"여전법": ["여신전문금융업법", "SFA", "여전금융업법"],
|
| 72 |
+
"은행법": ["BA", "Banking Act"],
|
| 73 |
+
"은행업감독규정": ["감독규정", "BSR"],
|
| 74 |
+
"은행업감독업무시행세칙": ["시행세칙", "BSER", "은감세칙"],
|
| 75 |
+
"특금법": ["특정금융정보법", "AMLA", "자금세탁방지법"],
|
| 76 |
+
"신용정보법": ["CIA", "Credit Information Act"],
|
| 77 |
+
"금소법": ["금융소비자보호법", "FCPA"],
|
| 78 |
+
"벤처투자법": ["VIA", "벤처기업육성에 관한 특별조치법"],
|
| 79 |
+
"대체투자모범규준": ["여전사 대체투자 리스크관리 모범규준", "SFAAI", "AI Best Practices"]
|
| 80 |
+
}
|
| 81 |
+
}
|
data/investment_ontology_v1_10.ttl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/regulations_chunks_v14.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/risk_weight_lookup.json
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_metadata": {
|
| 3 |
+
"version": "v1.0",
|
| 4 |
+
"purpose": "별표 3 자산분류별 위험가중치 deterministic lookup. LLM 생성 대신 코드에서 직접 조회.",
|
| 5 |
+
"source": "은행업감독업무시행세칙 별표 3 제2장 제3절 (위험가중치)",
|
| 6 |
+
"perspective": "표준방법(SA) 적용. LP출자(집합투자증권 익스포져)는 '집합투자증권' 항목 참조."
|
| 7 |
+
},
|
| 8 |
+
"asset_classes": {
|
| 9 |
+
"중앙정부": {
|
| 10 |
+
"asset_id": "CentralGov",
|
| 11 |
+
"clause_id": "BSER_App3_Asset_CentralGov",
|
| 12 |
+
"lookup_method": "신용등급",
|
| 13 |
+
"table": {
|
| 14 |
+
"AAA~AA-": "0%",
|
| 15 |
+
"A+~A-": "20%",
|
| 16 |
+
"BBB+~BBB-": "50%",
|
| 17 |
+
"BB+~B-": "100%",
|
| 18 |
+
"B-미만": "150%",
|
| 19 |
+
"무등급": "100%"
|
| 20 |
+
},
|
| 21 |
+
"special_rules": {
|
| 22 |
+
"대한민국 중앙정부 (원화 표시·조달)": "0%",
|
| 23 |
+
"OECD 국가신용도 0~1": "0%",
|
| 24 |
+
"OECD 국가신용도 2": "20%",
|
| 25 |
+
"OECD 국가신용도 3": "50%",
|
| 26 |
+
"OECD 국가신용도 4~6": "100%",
|
| 27 |
+
"OECD 국가신용도 7": "150%"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"국제결제은행등": {
|
| 31 |
+
"asset_id": "IntlSettlementBank",
|
| 32 |
+
"clause_id": "BSER_App3_Asset_CentralGov",
|
| 33 |
+
"lookup_method": "고정",
|
| 34 |
+
"fixed_weight": "0%",
|
| 35 |
+
"applicable_to": ["BIS", "IMF", "ECB", "EU", "ESM", "EFSF"]
|
| 36 |
+
},
|
| 37 |
+
"은행": {
|
| 38 |
+
"asset_id": "Bank",
|
| 39 |
+
"clause_id": "BSER_App3_Asset_Bank",
|
| 40 |
+
"lookup_method": "신용등급+만기",
|
| 41 |
+
"table_short_term": {
|
| 42 |
+
"AAA~AA-": "20%",
|
| 43 |
+
"A+~A-": "20%",
|
| 44 |
+
"BBB+~BBB-": "20%",
|
| 45 |
+
"BB+~B-": "50%",
|
| 46 |
+
"B-미만": "150%",
|
| 47 |
+
"무등급": "20%"
|
| 48 |
+
},
|
| 49 |
+
"table_long_term": {
|
| 50 |
+
"AAA~AA-": "20%",
|
| 51 |
+
"A+~A-": "30%",
|
| 52 |
+
"BBB+~BBB-": "50%",
|
| 53 |
+
"BB+~B-": "100%",
|
| 54 |
+
"B-미만": "150%",
|
| 55 |
+
"무등급": "40~75%"
|
| 56 |
+
},
|
| 57 |
+
"note": "단기(3개월 이하)와 장기 위험가중치 다름. 자세한 경우는 별표 3 본문 참조."
|
| 58 |
+
},
|
| 59 |
+
"기업": {
|
| 60 |
+
"asset_id": "Corporate",
|
| 61 |
+
"clause_id": "BSER_App3_Asset_Corporate",
|
| 62 |
+
"lookup_method": "신용등급",
|
| 63 |
+
"table": {
|
| 64 |
+
"AAA~AA-": "20%",
|
| 65 |
+
"A+~A-": "50%",
|
| 66 |
+
"BBB+~BBB-": "75%",
|
| 67 |
+
"BB+~B-": "100%",
|
| 68 |
+
"B-미만": "150%",
|
| 69 |
+
"무등급": "100%"
|
| 70 |
+
},
|
| 71 |
+
"note": "투자등급 외 기업의 무등급은 기업의 매출액 등에 따라 추가 분류 필요"
|
| 72 |
+
},
|
| 73 |
+
"주식": {
|
| 74 |
+
"asset_id": "Equity",
|
| 75 |
+
"clause_id": "BSER_App3_Asset_Equity",
|
| 76 |
+
"lookup_method": "구분",
|
| 77 |
+
"table": {
|
| 78 |
+
"거래소 상장주식": "250%",
|
| 79 |
+
"비상장주식 (적격투자)": "400%",
|
| 80 |
+
"비상장주식 (기타)": "400%",
|
| 81 |
+
"신종자본증권": "150%"
|
| 82 |
+
},
|
| 83 |
+
"note": "2024.1.31 개정 기준. 일부 항목은 본문 참조."
|
| 84 |
+
},
|
| 85 |
+
"집합투자증권": {
|
| 86 |
+
"asset_id": "CIS",
|
| 87 |
+
"clause_id": "BSER_App3_Asset_CIS",
|
| 88 |
+
"lookup_method": "산정방법별",
|
| 89 |
+
"is_lp_default_asset_class": true,
|
| 90 |
+
"lp_note": "LP출자는 펀드 형태(신기술사업투자조합·벤처투자조합·사모투자신탁·투자회사·사모집합투자기구) 무관하게 모두 본 분류 적용",
|
| 91 |
+
"calculation_methods": {
|
| 92 |
+
"기초자산접근법": {
|
| 93 |
+
"code": "LBA",
|
| 94 |
+
"english": "Look-Through Approach",
|
| 95 |
+
"description": "집합투자증권의 기초자산을 은행이 실제 보유한 것으로 가정하여 위험가중치 산출",
|
| 96 |
+
"conditions": [
|
| 97 |
+
"은행이 기초자산에 대한 충분히 상세한 정보를 적시 입수 가능",
|
| 98 |
+
"정보가 증권예탁기관·수탁은행·집합투자업자 등 독립적 제3자에 의해 검증"
|
| 99 |
+
]
|
| 100 |
+
},
|
| 101 |
+
"위임접근법": {
|
| 102 |
+
"code": "MBA",
|
| 103 |
+
"english": "Mandate-Based Approach",
|
| 104 |
+
"description": "집합투자증권의 운용 위임 정보를 기반으로 위험가중치 산출",
|
| 105 |
+
"conditions": [
|
| 106 |
+
"기초자산접근법 요건 미충족 시 적용 가능",
|
| 107 |
+
"운용 정보(투자 가이드라인 등) 활용"
|
| 108 |
+
]
|
| 109 |
+
},
|
| 110 |
+
"대체접근법": {
|
| 111 |
+
"code": "FBA",
|
| 112 |
+
"english": "Fall-Back Approach",
|
| 113 |
+
"description": "위 두 방법 모두 적용 불가 시 1250% 위험가중치 일률 적용",
|
| 114 |
+
"weight": "1250%"
|
| 115 |
+
}
|
| 116 |
+
},
|
| 117 |
+
"additional_notes": [
|
| 118 |
+
"미실행된 출자약정 금액에 신용환산율을 적용한 금액 포함",
|
| 119 |
+
"RW 산출방식 3가지 중 하나 이상 선택 적용",
|
| 120 |
+
"파생상품 거래 포함 시 거래상대방 신용위험 RWA도 별도 산출"
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
"소매": {
|
| 124 |
+
"asset_id": "Retail",
|
| 125 |
+
"clause_id": "BSER_App3_Asset_Retail",
|
| 126 |
+
"table": {
|
| 127 |
+
"일반 소매": "75%",
|
| 128 |
+
"신용카드(거래자)": "45%",
|
| 129 |
+
"신용카드(기타)": "75%"
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
"주거용주택담보": {
|
| 133 |
+
"asset_id": "ResidentialMortgage",
|
| 134 |
+
"clause_id": "BSER_App3_Asset_ResidentialMortgage",
|
| 135 |
+
"lookup_method": "LTV",
|
| 136 |
+
"table": {
|
| 137 |
+
"LTV ≤ 50%": "20%",
|
| 138 |
+
"50% < LTV ≤ 60%": "25%",
|
| 139 |
+
"60% < LTV ≤ 80%": "30%",
|
| 140 |
+
"80% < LTV ≤ 90%": "40%",
|
| 141 |
+
"90% < LTV ≤ 100%": "50%",
|
| 142 |
+
"LTV > 100%": "70%"
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
"부도": {
|
| 146 |
+
"asset_id": "Defaulted",
|
| 147 |
+
"clause_id": "BSER_App3_Asset_Defaulted",
|
| 148 |
+
"table": {
|
| 149 |
+
"충당금 < 20%": "150%",
|
| 150 |
+
"충당금 ≥ 20%": "100%",
|
| 151 |
+
"주거용주택담보 부도": "100%"
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
}
|
prepare_data.sh
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# HF Space 배포 전 데이터·코드 동기화.
|
| 3 |
+
# active/ontology + active/code + paper/graph 자산을 hf_app/ 안으로 복사.
|
| 4 |
+
# HF Space 레포는 hf_app/ 폴더 통째를 푸시한다고 가정.
|
| 5 |
+
|
| 6 |
+
set -e
|
| 7 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)" # active/
|
| 8 |
+
HF="$ROOT/hf_app"
|
| 9 |
+
|
| 10 |
+
echo "[sync] $ROOT → $HF"
|
| 11 |
+
|
| 12 |
+
# 데이터
|
| 13 |
+
mkdir -p "$HF/data"
|
| 14 |
+
cp "$ROOT/ontology/investment_ontology_v1_10.ttl" "$HF/data/"
|
| 15 |
+
cp "$ROOT/ontology/regulations_chunks_v14.jsonl" "$HF/data/"
|
| 16 |
+
cp "$ROOT/ontology/alias_dictionary.json" "$HF/data/"
|
| 17 |
+
cp "$ROOT/ontology/risk_weight_lookup.json" "$HF/data/"
|
| 18 |
+
echo " ✅ data/ — ontology 4종 복사"
|
| 19 |
+
|
| 20 |
+
# 코드 (rag_engine·semantic_search·baseline_lib만 — 테스트 스크립트 제외)
|
| 21 |
+
mkdir -p "$HF/code"
|
| 22 |
+
cp "$ROOT/code/rag_engine.py" "$HF/code/"
|
| 23 |
+
cp "$ROOT/code/semantic_search.py" "$HF/code/"
|
| 24 |
+
cp "$ROOT/code/baseline_lib.py" "$HF/code/"
|
| 25 |
+
echo " ✅ code/ — 핵심 모듈 3종 복사"
|
| 26 |
+
|
| 27 |
+
# 자산 (다운로드용)
|
| 28 |
+
mkdir -p "$HF/assets"
|
| 29 |
+
if [ -f "$ROOT/paper_v5.pdf" ]; then
|
| 30 |
+
cp "$ROOT/paper_v5.pdf" "$HF/assets/"
|
| 31 |
+
echo " ✅ assets/paper_v5.pdf"
|
| 32 |
+
fi
|
| 33 |
+
if [ -f "$ROOT/../../온톨로지/v08_ontology_graph.png" ]; then
|
| 34 |
+
cp "$ROOT/../../온톨로지/v08_ontology_graph.png" "$HF/assets/"
|
| 35 |
+
echo " ✅ assets/v08_ontology_graph.png"
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
# 임베딩 캐시 (선택) — HF Space에서 첫 빌드 시 재생성 가능. 단축하려면 함께 푸시.
|
| 39 |
+
if [ -d "$ROOT/ontology/_embeddings_cache" ]; then
|
| 40 |
+
mkdir -p "$HF/data/_embeddings_cache"
|
| 41 |
+
cp -r "$ROOT/ontology/_embeddings_cache/"* "$HF/data/_embeddings_cache/" 2>/dev/null || true
|
| 42 |
+
echo " ✅ data/_embeddings_cache/ (KoSimCSE 캐시)"
|
| 43 |
+
fi
|
| 44 |
+
|
| 45 |
+
echo "[done] HF Space 푸시 준비 완료: $HF"
|
| 46 |
+
echo ""
|
| 47 |
+
echo "다음 단계:"
|
| 48 |
+
echo " cd $HF"
|
| 49 |
+
echo " git init (HF Space와 연결 시)"
|
| 50 |
+
echo " git remote add space https://huggingface.co/spaces/<user>/<space>"
|
| 51 |
+
echo " git push space main"
|
web/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="ko">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>LP출자 온톨로지 LLM 프로토타입</title>
|
| 7 |
+
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
web/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ontology-prototype-web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 17 |
+
"vite": "^6.0.7"
|
| 18 |
+
}
|
| 19 |
+
}
|
web/src/App.jsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import ExplainTab from './ExplainTab.jsx';
|
| 3 |
+
import TestTab from './TestTab.jsx';
|
| 4 |
+
import DataTab from './DataTab.jsx';
|
| 5 |
+
|
| 6 |
+
const TABS = [
|
| 7 |
+
{ id: 'explain', ico: '📖', label: '설명' },
|
| 8 |
+
{ id: 'test', ico: '🔍', label: '테스트' },
|
| 9 |
+
{ id: 'data', ico: '📋', label: '데이터 관리' },
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
export default function App() {
|
| 13 |
+
const [tab, setTab] = useState('test');
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="shell">
|
| 17 |
+
<header className="appbar">
|
| 18 |
+
<div className="wrap appbar-inner">
|
| 19 |
+
<div className="brand-badge">IBK</div>
|
| 20 |
+
<div className="brand-text">
|
| 21 |
+
<h1>LP출자 온톨로지 LLM 적용 프로토타입 테스트</h1>
|
| 22 |
+
<p>
|
| 23 |
+
사내 AI 경진대회 출품 ·{' '}
|
| 24 |
+
<span className="accent">온톨로지 기반 폐쇄망 RAG 시스템</span>
|
| 25 |
+
</p>
|
| 26 |
+
</div>
|
| 27 |
+
<div className="env-pill">
|
| 28 |
+
<span className="dot"></span> HF Spaces · GPU 데모
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</header>
|
| 32 |
+
|
| 33 |
+
<nav className="tabbar">
|
| 34 |
+
<div className="wrap tabbar-inner">
|
| 35 |
+
{TABS.map((t) => (
|
| 36 |
+
<button
|
| 37 |
+
key={t.id}
|
| 38 |
+
className={'tab' + (tab === t.id ? ' active' : '')}
|
| 39 |
+
onClick={() => setTab(t.id)}
|
| 40 |
+
>
|
| 41 |
+
<span className="tab-ico">{t.ico}</span> {t.label}
|
| 42 |
+
</button>
|
| 43 |
+
))}
|
| 44 |
+
</div>
|
| 45 |
+
</nav>
|
| 46 |
+
|
| 47 |
+
<main>
|
| 48 |
+
<div className="wrap">
|
| 49 |
+
{tab === 'explain' && <ExplainTab />}
|
| 50 |
+
{tab === 'test' && <TestTab />}
|
| 51 |
+
{tab === 'data' && <DataTab />}
|
| 52 |
+
</div>
|
| 53 |
+
</main>
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
web/src/DataTab.jsx
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const PRODUCT_OPTIONS = ['PEF', '벤처투자조합', '신기술사업투자조합', '투자합자조합', '인수금융'];
|
| 4 |
+
const STAGE_OPTIONS = ['거래상대방 등록', '투자상담', '사전협의', '예비검토', '실무심의', '투자품의', '한도약정', '개별출자품의', '사후관리'];
|
| 5 |
+
const STATUS_OPTIONS = ['진행중', '정체', '완료', '약정 진행 중', '약정체결 완료'];
|
| 6 |
+
const SEC_OPTIONS = ['보통주', 'CPS', 'RCPS', 'CB', 'BW', '기타'];
|
| 7 |
+
|
| 8 |
+
export default function DataTab() {
|
| 9 |
+
const [dataset, setDataset] = useState({ funds: [], gps: [] });
|
| 10 |
+
const [userRecords, setUserRecords] = useState([]); // 세션 추가분
|
| 11 |
+
|
| 12 |
+
// 폼 상태
|
| 13 |
+
const [label, setLabel] = useState('');
|
| 14 |
+
const [note, setNote] = useState('');
|
| 15 |
+
const [gpName, setGpName] = useState('');
|
| 16 |
+
const [gpNote, setGpNote] = useState('');
|
| 17 |
+
const [fundName, setFundName] = useState('');
|
| 18 |
+
const [fundProduct, setFundProduct] = useState('PEF');
|
| 19 |
+
const [invest, setInvest] = useState([{ name: '', listed: '상장', cps: '보통주', note: '' }]);
|
| 20 |
+
const [branch, setBranch] = useState([{ type: 'PEF', amt: '10억', stage: '예비검토', status: '진행중' }]);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
fetch('/api/dataset/summary')
|
| 24 |
+
.then((r) => r.json())
|
| 25 |
+
.then(setDataset)
|
| 26 |
+
.catch((e) => console.error(e));
|
| 27 |
+
}, []);
|
| 28 |
+
|
| 29 |
+
const setInv = (i, k, v) =>
|
| 30 |
+
setInvest(invest.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
| 31 |
+
const setBr = (i, k, v) =>
|
| 32 |
+
setBranch(branch.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
| 33 |
+
|
| 34 |
+
const addInvest = () => setInvest([...invest, { name: '', listed: '상장', cps: '보통주', note: '' }]);
|
| 35 |
+
const rmInvest = () => invest.length > 1 && setInvest(invest.slice(0, -1));
|
| 36 |
+
const addBranch = () => branch.length < 3 && setBranch([...branch, { type: 'PEF', amt: '', stage: '예비검토', status: '진행중' }]);
|
| 37 |
+
const rmBranch = () => branch.length > 1 && setBranch(branch.slice(0, -1));
|
| 38 |
+
|
| 39 |
+
function handleRegister() {
|
| 40 |
+
const errs = [];
|
| 41 |
+
if (!label.trim()) errs.push('검토건 라벨 비어있음');
|
| 42 |
+
if (!gpName.trim()) errs.push('GP명 비어있음');
|
| 43 |
+
if (!fundName.trim()) errs.push('펀드명 비어있음');
|
| 44 |
+
if (!invest.some((t) => t.name.trim())) errs.push('피투자 기업 최소 1개');
|
| 45 |
+
if (errs.length) {
|
| 46 |
+
alert('❌ ' + errs.join('\n'));
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
const nextN = userRecords.length + 7;
|
| 50 |
+
const rec = {
|
| 51 |
+
id: `Demo_Investment_${String(nextN).padStart(3, '0')}`,
|
| 52 |
+
label, note, gpName, gpNote, fundName, fundProduct,
|
| 53 |
+
invest: invest.filter((t) => t.name.trim()),
|
| 54 |
+
branch,
|
| 55 |
+
registeredAt: new Date().toISOString(),
|
| 56 |
+
};
|
| 57 |
+
setUserRecords([...userRecords, rec]);
|
| 58 |
+
alert(`✅ 등록 완료: ${rec.id} (세션 한정)`);
|
| 59 |
+
// 폼 리셋
|
| 60 |
+
setLabel(''); setNote(''); setGpName(''); setGpNote(''); setFundName('');
|
| 61 |
+
setInvest([{ name: '', listed: '상장', cps: '보통주', note: '' }]);
|
| 62 |
+
setBranch([{ type: 'PEF', amt: '10억', stage: '예비검토', status: '진행중' }]);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const allFunds = [
|
| 66 |
+
...dataset.funds.map((f) => ({ id: f.id, name: f.fund, amount: `${f.amount_eok}억` })),
|
| 67 |
+
...userRecords.map((r) => ({
|
| 68 |
+
id: r.id.split('_').pop(),
|
| 69 |
+
name: r.fundName,
|
| 70 |
+
amount: r.branch.reduce((s, b) => s + (parseInt(b.amt) || 0), 0) + '억',
|
| 71 |
+
})),
|
| 72 |
+
];
|
| 73 |
+
const allTargets = userRecords.flatMap((r) => r.invest.map((t) => ({ ...t, parent: r.id })));
|
| 74 |
+
const allGps = [
|
| 75 |
+
...dataset.gps,
|
| 76 |
+
...userRecords.map((r, i) => ({ name: r.gpName, id: `gp-u${i + 1}` })),
|
| 77 |
+
];
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div>
|
| 81 |
+
{/* 현재 등록 데이터 */}
|
| 82 |
+
<div className="sec-head">
|
| 83 |
+
<h2>📂 현재 등록된 데이터</h2>
|
| 84 |
+
<span className="sub">데모 적재분 + 세션 추가분</span>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="data-mgmt-grid">
|
| 88 |
+
<div className="card data-table-card">
|
| 89 |
+
<div className="dtc-head">
|
| 90 |
+
<span className="ico">💰</span> 펀드 데이터
|
| 91 |
+
<span className="count">{allFunds.length}건</span>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="dtc-body">
|
| 94 |
+
<table className="data-table">
|
| 95 |
+
<thead>
|
| 96 |
+
<tr><th>ID</th><th>펀드</th><th className="num">금액</th></tr>
|
| 97 |
+
</thead>
|
| 98 |
+
<tbody>
|
| 99 |
+
{allFunds.length ? allFunds.map((f) => (
|
| 100 |
+
<tr key={f.id}>
|
| 101 |
+
<td className="id mono">{f.id}</td>
|
| 102 |
+
<td>{f.name}</td>
|
| 103 |
+
<td className="amt">{f.amount}</td>
|
| 104 |
+
</tr>
|
| 105 |
+
)) : (
|
| 106 |
+
<tr><td colSpan="3" style={{ textAlign: 'center', color: 'var(--ink-faint)', padding: 24 }}>데이터 없음</td></tr>
|
| 107 |
+
)}
|
| 108 |
+
</tbody>
|
| 109 |
+
</table>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div className="card data-table-card">
|
| 114 |
+
<div className="dtc-head">
|
| 115 |
+
<span className="ico">🏢</span> 간접투자대상 기업
|
| 116 |
+
<span className="count">{allTargets.length}건</span>
|
| 117 |
+
</div>
|
| 118 |
+
{allTargets.length ? (
|
| 119 |
+
<div className="dtc-body">
|
| 120 |
+
<table className="data-table">
|
| 121 |
+
<thead><tr><th>기업명</th><th>상장</th><th>증권</th></tr></thead>
|
| 122 |
+
<tbody>
|
| 123 |
+
{allTargets.map((t, i) => (
|
| 124 |
+
<tr key={i}>
|
| 125 |
+
<td>{t.name}</td>
|
| 126 |
+
<td>{t.listed}</td>
|
| 127 |
+
<td>{t.cps}</td>
|
| 128 |
+
</tr>
|
| 129 |
+
))}
|
| 130 |
+
</tbody>
|
| 131 |
+
</table>
|
| 132 |
+
</div>
|
| 133 |
+
) : (
|
| 134 |
+
<div className="empty-box">
|
| 135 |
+
<span className="em-ico">📭</span>
|
| 136 |
+
아래 폼에서 등록하면 여기에 구조화되어 표시됩니다.
|
| 137 |
+
<div className="em-sub">기존 데모는 비고 텍스트로만 존재 — 구조화 X</div>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div className="card data-table-card">
|
| 143 |
+
<div className="dtc-head">
|
| 144 |
+
<span className="ico">🏛️</span> 운용사 GP
|
| 145 |
+
<span className="count">{allGps.length}건</span>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="dtc-body">
|
| 148 |
+
<table className="data-table">
|
| 149 |
+
<thead><tr><th>GP명</th><th>ID</th></tr></thead>
|
| 150 |
+
<tbody>
|
| 151 |
+
{allGps.length ? allGps.map((g) => (
|
| 152 |
+
<tr key={g.id}>
|
| 153 |
+
<td>{g.name}</td>
|
| 154 |
+
<td className="id mono">{g.id}</td>
|
| 155 |
+
</tr>
|
| 156 |
+
)) : (
|
| 157 |
+
<tr><td colSpan="2" style={{ textAlign: 'center', color: 'var(--ink-faint)', padding: 24 }}>GP 없음</td></tr>
|
| 158 |
+
)}
|
| 159 |
+
</tbody>
|
| 160 |
+
</table>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<hr className="divider" />
|
| 166 |
+
|
| 167 |
+
{/* 새 검토건 등록 */}
|
| 168 |
+
<div className="sec-head">
|
| 169 |
+
<h2>➕ 새 검토건 등록</h2>
|
| 170 |
+
<span className="sub">5개 섹션 · 자동 채번</span>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div className="card form-card">
|
| 174 |
+
{/* [1] 검토건 기본 */}
|
| 175 |
+
<div className="form-section">
|
| 176 |
+
<div className="fs-head">
|
| 177 |
+
<div className="fs-num">1</div>
|
| 178 |
+
<div className="fs-title">검토건 기본</div>
|
| 179 |
+
<div className="fs-hint">ID 자동 채번 (007~)</div>
|
| 180 |
+
</div>
|
| 181 |
+
<div className="fs-body">
|
| 182 |
+
<div className="field-grid fg-1">
|
| 183 |
+
<div className="field">
|
| 184 |
+
<label>라벨</label>
|
| 185 |
+
<input className="inp" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="예: newjeans 인수금융 검토" />
|
| 186 |
+
</div>
|
| 187 |
+
<div className="field">
|
| 188 |
+
<label>비고</label>
|
| 189 |
+
<input className="inp" value={note} onChange={(e) => setNote(e.target.value)} placeholder="검토건 개요·특이사항" />
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* [2] GP */}
|
| 196 |
+
<div className="form-section">
|
| 197 |
+
<div className="fs-head"><div className="fs-num">2</div><div className="fs-title">운용사 (GP)</div></div>
|
| 198 |
+
<div className="fs-body">
|
| 199 |
+
<div className="field-grid fg-2">
|
| 200 |
+
<div className="field">
|
| 201 |
+
<label>GP명</label>
|
| 202 |
+
<input className="inp" value={gpName} onChange={(e) => setGpName(e.target.value)} placeholder="예: hybe" />
|
| 203 |
+
</div>
|
| 204 |
+
<div className="field">
|
| 205 |
+
<label>비고</label>
|
| 206 |
+
<input className="inp" value={gpNote} onChange={(e) => setGpNote(e.target.value)} placeholder="GP 관련 메모" />
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
{/* [3] 펀드 */}
|
| 213 |
+
<div className="form-section">
|
| 214 |
+
<div className="fs-head"><div className="fs-num">3</div><div className="fs-title">출자대상 펀드</div></div>
|
| 215 |
+
<div className="fs-body">
|
| 216 |
+
<div className="field-grid fg-2">
|
| 217 |
+
<div className="field">
|
| 218 |
+
<label>펀드명</label>
|
| 219 |
+
<input className="inp" value={fundName} onChange={(e) => setFundName(e.target.value)} placeholder="예: newjeans 1호" />
|
| 220 |
+
</div>
|
| 221 |
+
<div className="field">
|
| 222 |
+
<label>펀드 형태</label>
|
| 223 |
+
<select className="sel" value={fundProduct} onChange={(e) => setFundProduct(e.target.value)}>
|
| 224 |
+
{PRODUCT_OPTIONS.map((o) => <option key={o}>{o}</option>)}
|
| 225 |
+
</select>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{/* [4] 피투자 */}
|
| 232 |
+
<div className="form-section">
|
| 233 |
+
<div className="fs-head">
|
| 234 |
+
<div className="fs-num">4</div>
|
| 235 |
+
<div className="fs-title">피투자 기업</div>
|
| 236 |
+
<div className="fs-hint">{invest.length}개 행</div>
|
| 237 |
+
</div>
|
| 238 |
+
<div className="fs-body">
|
| 239 |
+
{invest.map((row, i) => (
|
| 240 |
+
<div className="dyn-row invest" key={i}>
|
| 241 |
+
<div className="dyn-idx">#{i + 1}</div>
|
| 242 |
+
<div className="field">
|
| 243 |
+
<label>기업명</label>
|
| 244 |
+
<input className="inp" value={row.name} onChange={(e) => setInv(i, 'name', e.target.value)} placeholder="기업명" />
|
| 245 |
+
</div>
|
| 246 |
+
<div className="field">
|
| 247 |
+
<label>상장 여부</label>
|
| 248 |
+
<select className="sel" value={row.listed} onChange={(e) => setInv(i, 'listed', e.target.value)}>
|
| 249 |
+
<option>상장</option><option>비상장</option>
|
| 250 |
+
</select>
|
| 251 |
+
</div>
|
| 252 |
+
<div className="field">
|
| 253 |
+
<label>증권 종류</label>
|
| 254 |
+
<select className="sel" value={row.cps} onChange={(e) => setInv(i, 'cps', e.target.value)}>
|
| 255 |
+
{SEC_OPTIONS.map((o) => <option key={o}>{o}</option>)}
|
| 256 |
+
</select>
|
| 257 |
+
</div>
|
| 258 |
+
<div className="field">
|
| 259 |
+
<label>비고</label>
|
| 260 |
+
<input className="inp" value={row.note} onChange={(e) => setInv(i, 'note', e.target.value)} placeholder="비고" />
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
))}
|
| 264 |
+
<div className="dyn-actions">
|
| 265 |
+
<button className="btn-ghost" onClick={addInvest}>+ 행 추가</button>
|
| 266 |
+
<button className="btn-ghost danger" onClick={rmInvest}>- 행 제거</button>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
{/* [5] 브랜치 */}
|
| 272 |
+
<div className="form-section">
|
| 273 |
+
<div className="fs-head">
|
| 274 |
+
<div className="fs-num">5</div>
|
| 275 |
+
<div className="fs-title">브랜치 (분기)</div>
|
| 276 |
+
<div className="fs-hint">{branch.length}개 행 · 단계 자동 추적</div>
|
| 277 |
+
</div>
|
| 278 |
+
<div className="fs-body">
|
| 279 |
+
{branch.map((row, i) => (
|
| 280 |
+
<div className="dyn-row branch" key={i}>
|
| 281 |
+
<div className="dyn-idx">#{i + 1}</div>
|
| 282 |
+
<div className="field">
|
| 283 |
+
<label>유형</label>
|
| 284 |
+
<select className="sel" value={row.type} onChange={(e) => setBr(i, 'type', e.target.value)}>
|
| 285 |
+
{PRODUCT_OPTIONS.map((o) => <option key={o}>{o}</option>)}
|
| 286 |
+
</select>
|
| 287 |
+
</div>
|
| 288 |
+
<div className="field">
|
| 289 |
+
<label>금액</label>
|
| 290 |
+
<input className="inp" value={row.amt} onChange={(e) => setBr(i, 'amt', e.target.value)} placeholder="예: 10억" />
|
| 291 |
+
</div>
|
| 292 |
+
<div className="field">
|
| 293 |
+
<label>단계</label>
|
| 294 |
+
<select className="sel" value={row.stage} onChange={(e) => setBr(i, 'stage', e.target.value)}>
|
| 295 |
+
{STAGE_OPTIONS.map((o) => <option key={o}>{o}</option>)}
|
| 296 |
+
</select>
|
| 297 |
+
</div>
|
| 298 |
+
<div className="field">
|
| 299 |
+
<label>상태</label>
|
| 300 |
+
<select className="sel" value={row.status} onChange={(e) => setBr(i, 'status', e.target.value)}>
|
| 301 |
+
{STATUS_OPTIONS.map((o) => <option key={o}>{o}</option>)}
|
| 302 |
+
</select>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
))}
|
| 306 |
+
<div className="dyn-actions">
|
| 307 |
+
<button className="btn-ghost" onClick={addBranch}>+ 행 추가</button>
|
| 308 |
+
<button className="btn-ghost danger" onClick={rmBranch}>- 행 제거</button>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<div className="form-submit">
|
| 314 |
+
<button className="btn-submit" onClick={handleRegister}>✅ 검토건 등록</button>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
);
|
| 319 |
+
}
|
web/src/ExplainTab.jsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
const DOWNLOADS = [
|
| 4 |
+
{
|
| 5 |
+
n: '①', title: 'PAPER', kind: 'PDF',
|
| 6 |
+
desc: '시스템 설계·실험 설계·통계 분석을 담은 연구 페이퍼.',
|
| 7 |
+
meta: '본문 + 부록 · 최신본',
|
| 8 |
+
href: '/api/download/paper',
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
n: '②', title: '노드 그래프', kind: 'PNG',
|
| 12 |
+
desc: '클래스·인스턴스 관계를 시각화한 온톨로지 노드 그래프.',
|
| 13 |
+
meta: 'v0.8 시점 스냅샷',
|
| 14 |
+
href: '/api/download/graph',
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
n: '③', title: '온톨로지', kind: 'TTL',
|
| 18 |
+
desc: 'Turtle 직렬화 온톨로지 정의 파일 (RDF/OWL).',
|
| 19 |
+
meta: 'v1.10 · 트리플 3,712개 · 64 클래스',
|
| 20 |
+
href: '/api/download/ttl',
|
| 21 |
+
},
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
const COLS = [
|
| 25 |
+
{
|
| 26 |
+
cls: 'h-purple', ico: '🎯', title: '구현 범위',
|
| 27 |
+
items: [
|
| 28 |
+
'<b>라우터</b> — 키워드 + LLM 의도파서 하이브리드',
|
| 29 |
+
'<b>온톨로지</b> — 64 클래스 / 트리플 3,712개',
|
| 30 |
+
'<b>SPARQL</b> 결정론적 조회 (Python)',
|
| 31 |
+
'<b>lookup 표 19종</b> (별표 3 포함)',
|
| 32 |
+
'<b>RAG</b> — 274 청크 인덱싱',
|
| 33 |
+
'<b>KoSimCSE</b> 의미검색 (축B)',
|
| 34 |
+
'<b>LLM 2종</b> — Sonnet · Gemma 4 e4b',
|
| 35 |
+
'데모 검토건 <b>3건</b> 적재',
|
| 36 |
+
],
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
cls: 'h-teal', ico: '🧭', title: '구현 방향',
|
| 40 |
+
items: [
|
| 41 |
+
'온톨로지가 <b>LLM 자유해석을 제약</b>',
|
| 42 |
+
'폐쇄망 + 4B 로컬 모델 정당성 입증',
|
| 43 |
+
'<b>Sonnet ≒ Gemma</b> (패러프레이즈 lenient 100% 동등)',
|
| 44 |
+
'하이브리드 라우팅 — 키워드 × 의미검색',
|
| 45 |
+
'다층 거래상대(GP·펀드·피투자) 구조화',
|
| 46 |
+
'분기(branch) 구조로 단계 추적',
|
| 47 |
+
'9단계 자동 채번 체계',
|
| 48 |
+
],
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
cls: 'h-green', ico: '🚀', title: '확장 가능성',
|
| 52 |
+
items: [
|
| 53 |
+
'인스턴스 ↔ 온톨로지 <b>분리</b> (Triplestore)',
|
| 54 |
+
'더 큰 폐쇄망 LLM (<b>9B / 27B</b>) 적용',
|
| 55 |
+
'검토건 데이터 확장',
|
| 56 |
+
'타 도메인 확장 (여신·심사 등)',
|
| 57 |
+
'메자닌·구조화 상품 정밀화',
|
| 58 |
+
'결재시스템 연동',
|
| 59 |
+
'few-shot 프롬프트 강화',
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
];
|
| 63 |
+
|
| 64 |
+
export default function ExplainTab() {
|
| 65 |
+
return (
|
| 66 |
+
<div>
|
| 67 |
+
<div className="sec-head">
|
| 68 |
+
<h2>📥 자료 다운로드</h2>
|
| 69 |
+
<span className="sub">발표·심사용 첨부 자료</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="grid-3">
|
| 72 |
+
{DOWNLOADS.map((d) => (
|
| 73 |
+
<div className="card dl-card" key={d.title}>
|
| 74 |
+
<div className="dl-head">
|
| 75 |
+
<div className="dl-num">{d.n}</div>
|
| 76 |
+
<div>
|
| 77 |
+
<div className="dl-title">{d.title}</div>
|
| 78 |
+
<div className="dl-kind mono">{d.kind}</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="dl-desc">{d.desc}</div>
|
| 82 |
+
<div className="dl-meta">{d.meta}</div>
|
| 83 |
+
<a className="dl-btn" href={d.href} download>
|
| 84 |
+
<span className="ico">↓</span> 다운로드
|
| 85 |
+
</a>
|
| 86 |
+
</div>
|
| 87 |
+
))}
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div className="sec-head spaced">
|
| 91 |
+
<h2>📝 시스템 설명</h2>
|
| 92 |
+
<span className="sub">구현 범위 · 방향 · 확장</span>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="grid-3">
|
| 95 |
+
{COLS.map((c) => (
|
| 96 |
+
<div className={'card info-card ' + c.cls} key={c.title}>
|
| 97 |
+
<div className="info-card-head">
|
| 98 |
+
<span className="ico">{c.ico}</span>
|
| 99 |
+
{c.title}
|
| 100 |
+
</div>
|
| 101 |
+
<div className="info-card-body">
|
| 102 |
+
<ul className="info-list">
|
| 103 |
+
{c.items.map((it, i) => (
|
| 104 |
+
<li key={i} dangerouslySetInnerHTML={{ __html: it }} />
|
| 105 |
+
))}
|
| 106 |
+
</ul>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
))}
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<hr className="divider" />
|
| 113 |
+
|
| 114 |
+
<div className="banner">
|
| 115 |
+
<span className="b-ico">ℹ️</span>
|
| 116 |
+
<div>
|
| 117 |
+
<b>운영 환경 안내</b> — 실제 운영은 폐쇄망 내 로컬 추론(M4 노트북 · 약 10초)으로
|
| 118 |
+
이루어집니다. 본 데모는 사용자 접근성을 위해 HF Spaces에서 동일 모델을 GPU로
|
| 119 |
+
호스팅하여 약 1초 내 응답합니다. <b>모델 · 로직 · 데이터는 운영 환경과 동일</b>합니다.
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|
web/src/TestTab.jsx
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const PRESETS = [
|
| 4 |
+
{ chip: '검토건 상태', q: 'ive 펀드 검토건은 지금 어디까지 갔어?' },
|
| 5 |
+
{ chip: '적합성 원칙', q: 'LP출자 검토 시 적합성 원칙은 어떻게 확인해?' },
|
| 6 |
+
{ chip: '위험가중치 조회', q: 'AAA 등급 중앙정부 익스포져의 위험가중치는 얼마야?' },
|
| 7 |
+
{ chip: '진행 중 전체', q: '지금 회사에서 검토 진행 중인 건들 좀 찾아서 설명해줘.' },
|
| 8 |
+
{ chip: '결재 순서 (구어체)', q: '사인 누구한테 받아야 해?' },
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
function Expander({ icon, title, defaultOpen, children }) {
|
| 12 |
+
const [open, setOpen] = useState(!!defaultOpen);
|
| 13 |
+
return (
|
| 14 |
+
<div className={'expander' + (open ? ' open' : '')}>
|
| 15 |
+
<button className="expander-head" onClick={() => setOpen(!open)}>
|
| 16 |
+
<span className="e-ico">{icon}</span>
|
| 17 |
+
{title}
|
| 18 |
+
<span className="chev">▼</span>
|
| 19 |
+
</button>
|
| 20 |
+
{open && <div className="expander-body">{children}</div>}
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function DatasetMini({ funds }) {
|
| 26 |
+
if (!funds || !funds.length) return <div>로딩 중...</div>;
|
| 27 |
+
return (
|
| 28 |
+
<table className="mini-table">
|
| 29 |
+
<thead>
|
| 30 |
+
<tr><th>ID</th><th>검토건</th><th>금액</th><th>단계</th></tr>
|
| 31 |
+
</thead>
|
| 32 |
+
<tbody>
|
| 33 |
+
{funds.map((f) => (
|
| 34 |
+
<tr key={f.id}>
|
| 35 |
+
<td className="mono">{f.id}</td>
|
| 36 |
+
<td>{f.fund}</td>
|
| 37 |
+
<td className="mono">{f.amount_eok}억</td>
|
| 38 |
+
<td>{f.stage}</td>
|
| 39 |
+
</tr>
|
| 40 |
+
))}
|
| 41 |
+
</tbody>
|
| 42 |
+
</table>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function AnswerCard({ kind, badgeCls, title, subtitle, content, route, time, hostNote, loading }) {
|
| 47 |
+
return (
|
| 48 |
+
<div className={'ans t-' + kind}>
|
| 49 |
+
<div className="ans-head">
|
| 50 |
+
<div className={'ans-badge ' + badgeCls}>
|
| 51 |
+
{kind === 'py' ? '🐍' : kind === 'son' ? '✨' : '🤖'}
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<div className="ans-title">{title}</div>
|
| 55 |
+
<div className="ans-kind">{subtitle}</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
<div className="ans-body">
|
| 59 |
+
{loading ? <p style={{ color: 'var(--ink-faint)' }}>응답 생성 중...</p> : content}
|
| 60 |
+
</div>
|
| 61 |
+
<div className="ans-foot">
|
| 62 |
+
<div className="route-cap">
|
| 63 |
+
↳ 라우트 <span className="mono">{route || '-'}</span>
|
| 64 |
+
</div>
|
| 65 |
+
<div className="foot-meta">
|
| 66 |
+
<span className="time-pill">
|
| 67 |
+
⚡ <span className="mono">{time ?? '-'}</span>
|
| 68 |
+
</span>
|
| 69 |
+
{hostNote && <span className="host-note">{hostNote}</span>}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export default function TestTab() {
|
| 77 |
+
const [route, setRoute] = useState('b'); // 'b' = axisB, 'a' = keyword
|
| 78 |
+
const [active, setActive] = useState(0);
|
| 79 |
+
const [query, setQuery] = useState(PRESETS[0].q);
|
| 80 |
+
const [answer, setAnswer] = useState(null);
|
| 81 |
+
const [loading, setLoading] = useState(false);
|
| 82 |
+
const [error, setError] = useState(null);
|
| 83 |
+
const [dataset, setDataset] = useState(null);
|
| 84 |
+
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
fetch('/api/dataset/summary')
|
| 87 |
+
.then((r) => r.json())
|
| 88 |
+
.then(setDataset)
|
| 89 |
+
.catch((e) => console.error('dataset load error', e));
|
| 90 |
+
}, []);
|
| 91 |
+
|
| 92 |
+
function runPreset(i) {
|
| 93 |
+
setActive(i);
|
| 94 |
+
setQuery(PRESETS[i].q);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function runSearch() {
|
| 98 |
+
setLoading(true);
|
| 99 |
+
setError(null);
|
| 100 |
+
try {
|
| 101 |
+
const r = await fetch('/api/ask', {
|
| 102 |
+
method: 'POST',
|
| 103 |
+
headers: { 'Content-Type': 'application/json' },
|
| 104 |
+
body: JSON.stringify({ question: query, mode: route === 'b' ? 'axisB' : 'keyword' }),
|
| 105 |
+
});
|
| 106 |
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 107 |
+
const data = await r.json();
|
| 108 |
+
setAnswer(data);
|
| 109 |
+
} catch (e) {
|
| 110 |
+
setError(String(e));
|
| 111 |
+
} finally {
|
| 112 |
+
setLoading(false);
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return (
|
| 117 |
+
<div>
|
| 118 |
+
{/* expander 2개 */}
|
| 119 |
+
<div className="expander-row">
|
| 120 |
+
<Expander icon="📋" title="질문 가능 범위">
|
| 121 |
+
<ul>
|
| 122 |
+
<li>검토건 <b>상태·단계·진행</b> 조회 (예: "ive 어디까지 됐어?")</li>
|
| 123 |
+
<li>적합성·위험가중치 등 <b>규정·원칙</b> 조회</li>
|
| 124 |
+
<li>진행 중 검토건 <b>목록·요약</b></li>
|
| 125 |
+
<li>결재 순서 등 <b>프로세스</b> 질의 (구어체 허용)</li>
|
| 126 |
+
<li>GP · 펀드 · 피투자 기업 <b>관계</b> 탐색</li>
|
| 127 |
+
</ul>
|
| 128 |
+
</Expander>
|
| 129 |
+
<Expander icon="📊" title="데이터셋 간략히 보기">
|
| 130 |
+
<DatasetMini funds={dataset?.funds} />
|
| 131 |
+
</Expander>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* 라우팅 모드 */}
|
| 135 |
+
<div className="route-block">
|
| 136 |
+
<div className="card route-card">
|
| 137 |
+
<div className="route-label">라우팅 모드</div>
|
| 138 |
+
<div
|
| 139 |
+
className={'radio-opt' + (route === 'b' ? ' sel' : '')}
|
| 140 |
+
onClick={() => setRoute('b')}
|
| 141 |
+
>
|
| 142 |
+
<div className="radio-dot" />
|
| 143 |
+
<div className="radio-main">
|
| 144 |
+
<div className="radio-title">
|
| 145 |
+
LLM 의도파서 + KoSimCSE <span className="tag-b">축 B</span>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="radio-sub">의도 JSON 파싱 + 의미검색 (권장)</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
<div
|
| 151 |
+
className={'radio-opt' + (route === 'a' ? ' sel' : '')}
|
| 152 |
+
onClick={() => setRoute('a')}
|
| 153 |
+
>
|
| 154 |
+
<div className="radio-dot" />
|
| 155 |
+
<div className="radio-main">
|
| 156 |
+
<div className="radio-title">
|
| 157 |
+
키워드 라우터 <span className="tag-base">베이스라인</span>
|
| 158 |
+
</div>
|
| 159 |
+
<div className="radio-sub">규칙 기반 키워드 매칭</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div className="route-explain">
|
| 165 |
+
<div className="re-head">
|
| 166 |
+
<span>🧠</span>{' '}
|
| 167 |
+
{route === 'b' ? '축 B — 의미 기반 라우팅' : '베이스라인 — 키워드 라우팅'}
|
| 168 |
+
</div>
|
| 169 |
+
<p>
|
| 170 |
+
{route === 'b'
|
| 171 |
+
? 'LLM이 질문 의도를 JSON으로 파싱하고 KoSimCSE 의미검색으로 라우팅합니다. 구어체·패러프레이즈에 강건합니다.'
|
| 172 |
+
: '사전 정의 키워드 규칙으로 라우팅합니다. 정형 질문엔 빠르지만 패러프레이즈에 취약합니다.'}
|
| 173 |
+
</p>
|
| 174 |
+
{route === 'b' && (
|
| 175 |
+
<div className="re-stats">
|
| 176 |
+
<div className="re-stat">
|
| 177 |
+
<span className="v">93%</span>
|
| 178 |
+
<span className="k">패러프레이즈 lenient (C1 axisB)</span>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="re-stat">
|
| 181 |
+
<span className="v">100%</span>
|
| 182 |
+
<span className="k">C3 패러프레이즈 lenient</span>
|
| 183 |
+
</div>
|
| 184 |
+
<div className="re-stat">
|
| 185 |
+
<span className="v">+44%p</span>
|
| 186 |
+
<span className="k">strict 향상 (vs 키워드)</span>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
{/* 프리셋 chip */}
|
| 194 |
+
<div className="preset-label">프리셋 질문</div>
|
| 195 |
+
<div className="chip-row">
|
| 196 |
+
{PRESETS.map((p, i) => (
|
| 197 |
+
<button
|
| 198 |
+
key={p.chip}
|
| 199 |
+
className={'chip' + (active === i ? ' active' : '')}
|
| 200 |
+
onClick={() => runPreset(i)}
|
| 201 |
+
>
|
| 202 |
+
{p.chip}
|
| 203 |
+
</button>
|
| 204 |
+
))}
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
{/* 질문 입력 */}
|
| 208 |
+
<div className="ask-row">
|
| 209 |
+
<input
|
| 210 |
+
className="ask-input"
|
| 211 |
+
value={query}
|
| 212 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 213 |
+
onKeyDown={(e) => e.key === 'Enter' && !loading && runSearch()}
|
| 214 |
+
placeholder="질문을 입력하세요 — 예: ive 검토건 지금 어디까지 진행됐어?"
|
| 215 |
+
/>
|
| 216 |
+
<button className="ask-btn" onClick={runSearch} disabled={loading}>
|
| 217 |
+
🔍 {loading ? '응답 중...' : '검색'}
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* 3-col 답변 */}
|
| 222 |
+
{error && (
|
| 223 |
+
<div className="banner" style={{ borderColor: 'oklch(0.7 0.13 25)', background: 'oklch(0.96 0.02 25)' }}>
|
| 224 |
+
<span className="b-ico">⚠️</span>
|
| 225 |
+
<div><b>오류</b> — {error}</div>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
|
| 229 |
+
<div className="answers">
|
| 230 |
+
{!answer && !loading && (
|
| 231 |
+
<div className="empty-ans">
|
| 232 |
+
프리셋을 누르거나 질문을 입력하고 <b>검색</b> 버튼을 누르세요. Python · Sonnet ·
|
| 233 |
+
Gemma 세 방식의 답변을 한 번에 비교합니다.
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
|
| 237 |
+
{(answer || loading) && (
|
| 238 |
+
<>
|
| 239 |
+
<AnswerCard
|
| 240 |
+
kind="py"
|
| 241 |
+
badgeCls="b-py"
|
| 242 |
+
title="Python"
|
| 243 |
+
subtitle="결정론적 · raw 컨텍스트"
|
| 244 |
+
content={
|
| 245 |
+
answer?.python && (
|
| 246 |
+
<div style={{ whiteSpace: 'pre-wrap' }}>{answer.python.answer}</div>
|
| 247 |
+
)
|
| 248 |
+
}
|
| 249 |
+
route={answer?.route}
|
| 250 |
+
time={answer ? '0.0s' : null}
|
| 251 |
+
loading={loading}
|
| 252 |
+
/>
|
| 253 |
+
<AnswerCard
|
| 254 |
+
kind="son"
|
| 255 |
+
badgeCls="b-son"
|
| 256 |
+
title="Sonnet"
|
| 257 |
+
subtitle="컨텍스트 다듬기"
|
| 258 |
+
content={
|
| 259 |
+
answer?.sonnet && (
|
| 260 |
+
<div style={{ whiteSpace: 'pre-wrap' }}>{answer.sonnet.answer}</div>
|
| 261 |
+
)
|
| 262 |
+
}
|
| 263 |
+
route={answer?.route}
|
| 264 |
+
time={answer ? `${answer.sonnet.elapsed_sec.toFixed(1)}s` : null}
|
| 265 |
+
loading={loading}
|
| 266 |
+
/>
|
| 267 |
+
<AnswerCard
|
| 268 |
+
kind="gem"
|
| 269 |
+
badgeCls="b-gem"
|
| 270 |
+
title="Gemma 4 e4b"
|
| 271 |
+
subtitle="컨텍스트 다듬기 · 로컬"
|
| 272 |
+
content={
|
| 273 |
+
answer?.gemma && (
|
| 274 |
+
<div style={{ whiteSpace: 'pre-wrap' }}>{answer.gemma.answer}</div>
|
| 275 |
+
)
|
| 276 |
+
}
|
| 277 |
+
route={answer?.route}
|
| 278 |
+
time={answer ? `${answer.gemma.elapsed_sec.toFixed(1)}s` : null}
|
| 279 |
+
hostNote="📌 HF GPU 호스팅"
|
| 280 |
+
loading={loading}
|
| 281 |
+
/>
|
| 282 |
+
</>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
);
|
| 287 |
+
}
|
web/src/main.jsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App.jsx';
|
| 4 |
+
import './styles.css';
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
web/src/styles.css
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
LP출자 온톨로지 LLM 프로토타입 — 디자인 시스템
|
| 3 |
+
크림 베이스 · IBK 퍼플 액센트(OKLCH) · Pretendard / JetBrains Mono
|
| 4 |
+
========================================================================= */
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
/* ---- 베이스 (크림, warm neutral) ---- */
|
| 8 |
+
--cream: #F8F7F2;
|
| 9 |
+
--cream-deep: #F2F0E9;
|
| 10 |
+
--surface: #FFFFFF;
|
| 11 |
+
--surface-2: #FCFBF7;
|
| 12 |
+
|
| 13 |
+
/* ---- 잉크 (warm near-black) ---- */
|
| 14 |
+
--ink: oklch(0.26 0.008 70);
|
| 15 |
+
--ink-2: oklch(0.42 0.010 70);
|
| 16 |
+
--ink-3: oklch(0.58 0.012 75);
|
| 17 |
+
--ink-faint: oklch(0.70 0.012 80);
|
| 18 |
+
|
| 19 |
+
/* ---- 보더 ---- */
|
| 20 |
+
--line: oklch(0.90 0.006 85);
|
| 21 |
+
--line-soft: oklch(0.93 0.005 85);
|
| 22 |
+
--line-strong: oklch(0.84 0.008 85);
|
| 23 |
+
|
| 24 |
+
/* ---- IBK 퍼플 (OKLCH, 부드러운 색조) ---- */
|
| 25 |
+
--purple: oklch(0.47 0.115 300);
|
| 26 |
+
--purple-deep: oklch(0.40 0.120 300);
|
| 27 |
+
--purple-soft: oklch(0.62 0.090 300);
|
| 28 |
+
--purple-tint: oklch(0.955 0.018 300);
|
| 29 |
+
--purple-tint2: oklch(0.92 0.030 300);
|
| 30 |
+
--purple-line: oklch(0.86 0.040 300);
|
| 31 |
+
|
| 32 |
+
/* ---- 보조 액센트 (동일 채도/명도, 색조만 변화) ---- */
|
| 33 |
+
--teal: oklch(0.50 0.090 200);
|
| 34 |
+
--teal-tint: oklch(0.955 0.020 200);
|
| 35 |
+
--teal-line: oklch(0.86 0.040 200);
|
| 36 |
+
--green: oklch(0.52 0.085 150);
|
| 37 |
+
--green-tint: oklch(0.955 0.022 150);
|
| 38 |
+
--green-line: oklch(0.86 0.045 150);
|
| 39 |
+
--amber: oklch(0.58 0.090 75);
|
| 40 |
+
--amber-tint: oklch(0.955 0.030 80);
|
| 41 |
+
|
| 42 |
+
/* ---- 그림자 ---- */
|
| 43 |
+
--shadow-sm: 0 1px 2px oklch(0.45 0.02 80 / 0.05), 0 1px 1px oklch(0.45 0.02 80 / 0.04);
|
| 44 |
+
--shadow: 0 1px 3px oklch(0.45 0.02 80 / 0.06), 0 4px 14px oklch(0.45 0.02 80 / 0.06);
|
| 45 |
+
--shadow-lg: 0 2px 6px oklch(0.45 0.02 80 / 0.07), 0 12px 32px oklch(0.45 0.02 80 / 0.09);
|
| 46 |
+
|
| 47 |
+
/* ---- 라운드 ---- */
|
| 48 |
+
--r-sm: 7px;
|
| 49 |
+
--r: 11px;
|
| 50 |
+
--r-lg: 16px;
|
| 51 |
+
|
| 52 |
+
--maxw: 1180px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
* { box-sizing: border-box; }
|
| 56 |
+
|
| 57 |
+
html, body {
|
| 58 |
+
margin: 0;
|
| 59 |
+
padding: 0;
|
| 60 |
+
background: var(--cream);
|
| 61 |
+
color: var(--ink);
|
| 62 |
+
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
| 63 |
+
font-size: 15px;
|
| 64 |
+
line-height: 1.55;
|
| 65 |
+
-webkit-font-smoothing: antialiased;
|
| 66 |
+
text-rendering: optimizeLegibility;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.mono {
|
| 70 |
+
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 71 |
+
font-feature-settings: "calt" 0;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
::selection { background: var(--purple-tint2); }
|
| 75 |
+
|
| 76 |
+
button { font-family: inherit; cursor: pointer; }
|
| 77 |
+
input, select, textarea { font-family: inherit; font-size: inherit; color: var(--ink); }
|
| 78 |
+
|
| 79 |
+
/* =========================================================================
|
| 80 |
+
레이아웃
|
| 81 |
+
========================================================================= */
|
| 82 |
+
.shell { min-height: 100vh; }
|
| 83 |
+
|
| 84 |
+
.wrap {
|
| 85 |
+
max-width: var(--maxw);
|
| 86 |
+
margin: 0 auto;
|
| 87 |
+
padding: 0 28px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* ---- 헤더 ---- */
|
| 91 |
+
.appbar {
|
| 92 |
+
background: linear-gradient(180deg, var(--surface), var(--surface-2));
|
| 93 |
+
border-bottom: 1px solid var(--line);
|
| 94 |
+
}
|
| 95 |
+
.appbar-inner {
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 18px;
|
| 99 |
+
padding: 20px 0 18px;
|
| 100 |
+
}
|
| 101 |
+
.brand-badge {
|
| 102 |
+
width: 52px; height: 52px;
|
| 103 |
+
border-radius: 13px;
|
| 104 |
+
flex: 0 0 auto;
|
| 105 |
+
overflow: hidden;
|
| 106 |
+
box-shadow: var(--shadow-sm);
|
| 107 |
+
border: 1px solid var(--line);
|
| 108 |
+
background: var(--surface);
|
| 109 |
+
}
|
| 110 |
+
.brand-badge image-slot { width: 100%; height: 100%; display: block; }
|
| 111 |
+
.brand-text h1 {
|
| 112 |
+
margin: 0;
|
| 113 |
+
font-size: 20px;
|
| 114 |
+
font-weight: 700;
|
| 115 |
+
letter-spacing: -0.015em;
|
| 116 |
+
color: var(--ink);
|
| 117 |
+
}
|
| 118 |
+
.brand-text p {
|
| 119 |
+
margin: 3px 0 0;
|
| 120 |
+
font-size: 13px;
|
| 121 |
+
color: var(--ink-3);
|
| 122 |
+
letter-spacing: -0.005em;
|
| 123 |
+
}
|
| 124 |
+
.brand-text .accent { color: var(--purple); font-weight: 600; }
|
| 125 |
+
|
| 126 |
+
.env-pill {
|
| 127 |
+
margin-left: auto;
|
| 128 |
+
display: inline-flex;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 7px;
|
| 131 |
+
padding: 7px 13px;
|
| 132 |
+
background: var(--purple-tint);
|
| 133 |
+
border: 1px solid var(--purple-line);
|
| 134 |
+
border-radius: 100px;
|
| 135 |
+
font-size: 12px;
|
| 136 |
+
font-weight: 600;
|
| 137 |
+
color: var(--purple-deep);
|
| 138 |
+
white-space: nowrap;
|
| 139 |
+
}
|
| 140 |
+
.env-pill .dot {
|
| 141 |
+
width: 7px; height: 7px; border-radius: 50%;
|
| 142 |
+
background: var(--purple);
|
| 143 |
+
box-shadow: 0 0 0 3px var(--purple-tint2);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* ---- 탭바 ---- */
|
| 147 |
+
.tabbar {
|
| 148 |
+
position: sticky;
|
| 149 |
+
top: 0;
|
| 150 |
+
z-index: 20;
|
| 151 |
+
background: oklch(0.985 0.004 85 / 0.9);
|
| 152 |
+
backdrop-filter: blur(10px);
|
| 153 |
+
border-bottom: 1px solid var(--line);
|
| 154 |
+
}
|
| 155 |
+
.tabbar-inner { display: flex; gap: 4px; }
|
| 156 |
+
.tab {
|
| 157 |
+
appearance: none;
|
| 158 |
+
background: none;
|
| 159 |
+
border: none;
|
| 160 |
+
padding: 15px 18px 13px;
|
| 161 |
+
font-size: 14.5px;
|
| 162 |
+
font-weight: 600;
|
| 163 |
+
letter-spacing: -0.01em;
|
| 164 |
+
color: var(--ink-3);
|
| 165 |
+
border-bottom: 2.5px solid transparent;
|
| 166 |
+
margin-bottom: -1px;
|
| 167 |
+
display: inline-flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
gap: 8px;
|
| 170 |
+
white-space: nowrap;
|
| 171 |
+
transition: color .15s, border-color .15s;
|
| 172 |
+
}
|
| 173 |
+
.tab:hover { color: var(--ink); }
|
| 174 |
+
.tab .tab-ico { font-size: 15px; opacity: .9; }
|
| 175 |
+
.tab.active { color: var(--purple-deep); border-bottom-color: var(--purple); }
|
| 176 |
+
|
| 177 |
+
main { padding: 30px 0 80px; }
|
| 178 |
+
|
| 179 |
+
/* ---- 섹션 타이틀 ---- */
|
| 180 |
+
.sec-head {
|
| 181 |
+
display: flex;
|
| 182 |
+
align-items: baseline;
|
| 183 |
+
gap: 10px;
|
| 184 |
+
margin: 6px 0 16px;
|
| 185 |
+
}
|
| 186 |
+
.sec-head h2 {
|
| 187 |
+
margin: 0;
|
| 188 |
+
font-size: 16px;
|
| 189 |
+
font-weight: 700;
|
| 190 |
+
letter-spacing: -0.01em;
|
| 191 |
+
color: var(--ink);
|
| 192 |
+
}
|
| 193 |
+
.sec-head .sub { font-size: 12.5px; color: var(--ink-faint); }
|
| 194 |
+
.sec-head.spaced { margin-top: 34px; }
|
| 195 |
+
|
| 196 |
+
.divider { height: 1px; background: var(--line-soft); margin: 30px 0; border: 0; }
|
| 197 |
+
|
| 198 |
+
/* =========================================================================
|
| 199 |
+
카드
|
| 200 |
+
========================================================================= */
|
| 201 |
+
.card {
|
| 202 |
+
background: var(--surface);
|
| 203 |
+
border: 1px solid var(--line);
|
| 204 |
+
border-radius: var(--r);
|
| 205 |
+
box-shadow: var(--shadow-sm);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
| 209 |
+
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
| 210 |
+
|
| 211 |
+
/* ---- 다운로드 카드 ---- */
|
| 212 |
+
.dl-card {
|
| 213 |
+
padding: 20px;
|
| 214 |
+
display: flex;
|
| 215 |
+
flex-direction: column;
|
| 216 |
+
gap: 4px;
|
| 217 |
+
transition: box-shadow .16s, transform .16s, border-color .16s;
|
| 218 |
+
}
|
| 219 |
+
.dl-card:hover { box-shadow: var(--shadow); transform: translateY(-1px); border-color: var(--purple-line); }
|
| 220 |
+
.dl-head { display: flex; align-items: center; gap: 11px; margin-bottom: 8px; }
|
| 221 |
+
.dl-num {
|
| 222 |
+
width: 26px; height: 26px;
|
| 223 |
+
border-radius: 8px;
|
| 224 |
+
display: grid; place-items: center;
|
| 225 |
+
background: var(--purple-tint);
|
| 226 |
+
color: var(--purple-deep);
|
| 227 |
+
font-size: 13px; font-weight: 700;
|
| 228 |
+
flex: 0 0 auto;
|
| 229 |
+
}
|
| 230 |
+
.dl-title { font-weight: 700; font-size: 15px; letter-spacing: -0.01em; }
|
| 231 |
+
.dl-kind {
|
| 232 |
+
font-size: 11px; font-weight: 600;
|
| 233 |
+
color: var(--ink-faint);
|
| 234 |
+
letter-spacing: 0.04em;
|
| 235 |
+
}
|
| 236 |
+
.dl-desc { font-size: 13px; color: var(--ink-2); line-height: 1.5; }
|
| 237 |
+
.dl-meta { font-size: 12px; color: var(--ink-3); }
|
| 238 |
+
.dl-btn {
|
| 239 |
+
margin-top: 14px;
|
| 240 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
| 241 |
+
padding: 9px 14px;
|
| 242 |
+
background: var(--surface);
|
| 243 |
+
border: 1px solid var(--line-strong);
|
| 244 |
+
border-radius: var(--r-sm);
|
| 245 |
+
font-size: 13px; font-weight: 600;
|
| 246 |
+
color: var(--ink);
|
| 247 |
+
transition: background .15s, border-color .15s, color .15s;
|
| 248 |
+
}
|
| 249 |
+
.dl-btn:hover { background: var(--purple); border-color: var(--purple); color: #fff; }
|
| 250 |
+
.dl-btn .ico { font-size: 13px; }
|
| 251 |
+
|
| 252 |
+
/* ---- 시스템 설명 카드 ---- */
|
| 253 |
+
.info-card { padding: 0; overflow: hidden; }
|
| 254 |
+
.info-card-head {
|
| 255 |
+
padding: 14px 18px;
|
| 256 |
+
border-bottom: 1px solid var(--line-soft);
|
| 257 |
+
display: flex; align-items: center; gap: 9px;
|
| 258 |
+
font-weight: 700; font-size: 14.5px; letter-spacing: -0.01em;
|
| 259 |
+
}
|
| 260 |
+
.info-card-head .ico {
|
| 261 |
+
width: 24px; height: 24px; border-radius: 7px;
|
| 262 |
+
display: grid; place-items: center; font-size: 13px;
|
| 263 |
+
flex: 0 0 auto;
|
| 264 |
+
}
|
| 265 |
+
.info-card-body { padding: 16px 18px 18px; }
|
| 266 |
+
.info-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
|
| 267 |
+
.info-list li {
|
| 268 |
+
position: relative;
|
| 269 |
+
padding-left: 16px;
|
| 270 |
+
font-size: 13.5px;
|
| 271 |
+
color: var(--ink-2);
|
| 272 |
+
line-height: 1.5;
|
| 273 |
+
}
|
| 274 |
+
.info-list li::before {
|
| 275 |
+
content: "";
|
| 276 |
+
position: absolute; left: 2px; top: 8px;
|
| 277 |
+
width: 5px; height: 5px; border-radius: 50%;
|
| 278 |
+
background: var(--purple-soft);
|
| 279 |
+
}
|
| 280 |
+
.info-list li b { color: var(--ink); font-weight: 600; }
|
| 281 |
+
|
| 282 |
+
/* tint variants for card heads */
|
| 283 |
+
.h-purple .ico { background: var(--purple-tint); color: var(--purple-deep); }
|
| 284 |
+
.h-teal .ico { background: var(--teal-tint); color: var(--teal); }
|
| 285 |
+
.h-green .ico { background: var(--green-tint); color: var(--green); }
|
| 286 |
+
.h-purple { border-top: 2.5px solid var(--purple); }
|
| 287 |
+
.h-teal { border-top: 2.5px solid var(--teal); }
|
| 288 |
+
.h-green { border-top: 2.5px solid var(--green); }
|
| 289 |
+
|
| 290 |
+
/* =========================================================================
|
| 291 |
+
배너 / info 박스
|
| 292 |
+
========================================================================= */
|
| 293 |
+
.banner {
|
| 294 |
+
display: flex;
|
| 295 |
+
gap: 13px;
|
| 296 |
+
padding: 15px 18px;
|
| 297 |
+
border-radius: var(--r);
|
| 298 |
+
font-size: 13.5px;
|
| 299 |
+
line-height: 1.6;
|
| 300 |
+
border: 1px solid var(--purple-line);
|
| 301 |
+
background: var(--purple-tint);
|
| 302 |
+
color: var(--ink-2);
|
| 303 |
+
}
|
| 304 |
+
.banner .b-ico {
|
| 305 |
+
flex: 0 0 auto;
|
| 306 |
+
font-size: 16px;
|
| 307 |
+
line-height: 1.4;
|
| 308 |
+
}
|
| 309 |
+
.banner b { color: var(--purple-deep); font-weight: 700; }
|
| 310 |
+
|
| 311 |
+
.banner.neutral { background: var(--surface-2); border-color: var(--line); }
|
| 312 |
+
.banner.neutral b { color: var(--ink); }
|
| 313 |
+
|
| 314 |
+
/* =========================================================================
|
| 315 |
+
테스트 탭
|
| 316 |
+
========================================================================= */
|
| 317 |
+
.expander-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 22px; }
|
| 318 |
+
|
| 319 |
+
.expander { border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); overflow: hidden; }
|
| 320 |
+
.expander-head {
|
| 321 |
+
width: 100%;
|
| 322 |
+
display: flex; align-items: center; gap: 9px;
|
| 323 |
+
padding: 13px 16px;
|
| 324 |
+
background: var(--surface-2);
|
| 325 |
+
border: none;
|
| 326 |
+
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
| 327 |
+
text-align: left;
|
| 328 |
+
}
|
| 329 |
+
.expander-head .chev { margin-left: auto; color: var(--ink-faint); transition: transform .2s; font-size: 12px; }
|
| 330 |
+
.expander.open .chev { transform: rotate(180deg); }
|
| 331 |
+
.expander-head .e-ico { font-size: 14px; }
|
| 332 |
+
.expander-body { padding: 14px 16px 16px; border-top: 1px solid var(--line-soft); font-size: 13px; color: var(--ink-2); }
|
| 333 |
+
.expander-body ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
|
| 334 |
+
.expander-body ul li { line-height: 1.5; }
|
| 335 |
+
|
| 336 |
+
/* 데이터셋 미니 표 */
|
| 337 |
+
.mini-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
| 338 |
+
.mini-table th, .mini-table td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line-soft); }
|
| 339 |
+
.mini-table th { color: var(--ink-faint); font-weight: 600; font-size: 11px; letter-spacing: .03em; text-transform: uppercase; }
|
| 340 |
+
.mini-table td { color: var(--ink-2); }
|
| 341 |
+
.mini-table td.mono { color: var(--ink-3); }
|
| 342 |
+
|
| 343 |
+
/* ---- 라우팅 모드 ---- */
|
| 344 |
+
.route-block { display: grid; grid-template-columns: 1fr 1.15fr; gap: 16px; margin-bottom: 24px; align-items: stretch; }
|
| 345 |
+
|
| 346 |
+
.route-card { padding: 16px 18px; }
|
| 347 |
+
.route-label { font-size: 11.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-faint); margin-bottom: 12px; }
|
| 348 |
+
.radio-opt {
|
| 349 |
+
display: flex; align-items: flex-start; gap: 11px;
|
| 350 |
+
padding: 12px 13px;
|
| 351 |
+
border: 1px solid var(--line);
|
| 352 |
+
border-radius: var(--r-sm);
|
| 353 |
+
cursor: pointer;
|
| 354 |
+
transition: border-color .15s, background .15s;
|
| 355 |
+
margin-bottom: 9px;
|
| 356 |
+
}
|
| 357 |
+
.radio-opt:last-child { margin-bottom: 0; }
|
| 358 |
+
.radio-opt:hover { border-color: var(--purple-line); }
|
| 359 |
+
.radio-opt.sel { border-color: var(--purple); background: var(--purple-tint); box-shadow: inset 0 0 0 1px var(--purple); }
|
| 360 |
+
.radio-dot {
|
| 361 |
+
width: 18px; height: 18px; border-radius: 50%;
|
| 362 |
+
border: 2px solid var(--line-strong);
|
| 363 |
+
flex: 0 0 auto; margin-top: 1px;
|
| 364 |
+
display: grid; place-items: center;
|
| 365 |
+
transition: border-color .15s;
|
| 366 |
+
}
|
| 367 |
+
.radio-opt.sel .radio-dot { border-color: var(--purple); }
|
| 368 |
+
.radio-opt.sel .radio-dot::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--purple); }
|
| 369 |
+
.radio-main { display: flex; flex-direction: column; gap: 2px; }
|
| 370 |
+
.radio-title { font-size: 14px; font-weight: 600; color: var(--ink); display: flex; align-items: center; gap: 7px; }
|
| 371 |
+
.radio-sub { font-size: 12px; color: var(--ink-3); }
|
| 372 |
+
.tag-b {
|
| 373 |
+
font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
|
| 374 |
+
padding: 2px 7px; border-radius: 5px;
|
| 375 |
+
background: var(--purple); color: #fff;
|
| 376 |
+
}
|
| 377 |
+
.tag-base {
|
| 378 |
+
font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
|
| 379 |
+
padding: 2px 7px; border-radius: 5px;
|
| 380 |
+
background: var(--cream-deep); color: var(--ink-3); border: 1px solid var(--line);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.route-explain {
|
| 384 |
+
padding: 16px 18px;
|
| 385 |
+
background: linear-gradient(150deg, var(--purple-tint), var(--surface));
|
| 386 |
+
border: 1px solid var(--purple-line);
|
| 387 |
+
border-radius: var(--r);
|
| 388 |
+
display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
| 389 |
+
}
|
| 390 |
+
.route-explain .re-head { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 14px; color: var(--purple-deep); }
|
| 391 |
+
.route-explain p { margin: 0; font-size: 13px; color: var(--ink-2); line-height: 1.6; }
|
| 392 |
+
.re-stats { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
|
| 393 |
+
.re-stat {
|
| 394 |
+
background: var(--surface); border: 1px solid var(--purple-line); border-radius: 8px;
|
| 395 |
+
padding: 7px 11px; display: flex; flex-direction: column; gap: 1px;
|
| 396 |
+
}
|
| 397 |
+
.re-stat .v { font-weight: 700; font-size: 15px; color: var(--purple-deep); font-family: "JetBrains Mono", monospace; }
|
| 398 |
+
.re-stat .k { font-size: 10.5px; color: var(--ink-3); }
|
| 399 |
+
|
| 400 |
+
/* ---- 프리셋 chip ---- */
|
| 401 |
+
.preset-label { font-size: 12.5px; font-weight: 600; color: var(--ink-faint); margin-bottom: 9px; }
|
| 402 |
+
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
|
| 403 |
+
.chip {
|
| 404 |
+
appearance: none;
|
| 405 |
+
padding: 8px 14px;
|
| 406 |
+
border: 1px solid var(--line-strong);
|
| 407 |
+
border-radius: 100px;
|
| 408 |
+
background: var(--surface);
|
| 409 |
+
font-size: 13px; font-weight: 500;
|
| 410 |
+
color: var(--ink-2);
|
| 411 |
+
white-space: nowrap;
|
| 412 |
+
transition: all .14s;
|
| 413 |
+
}
|
| 414 |
+
.chip:hover { border-color: var(--purple-soft); color: var(--purple-deep); background: var(--purple-tint); }
|
| 415 |
+
.chip.active { background: var(--purple); border-color: var(--purple); color: #fff; }
|
| 416 |
+
|
| 417 |
+
/* ---- 질문 입력 ---- */
|
| 418 |
+
.ask-row { display: flex; gap: 10px; margin-bottom: 26px; }
|
| 419 |
+
.ask-input {
|
| 420 |
+
flex: 1;
|
| 421 |
+
padding: 13px 16px;
|
| 422 |
+
border: 1px solid var(--line-strong);
|
| 423 |
+
border-radius: var(--r);
|
| 424 |
+
background: var(--surface);
|
| 425 |
+
font-size: 14.5px;
|
| 426 |
+
outline: none;
|
| 427 |
+
transition: border-color .15s, box-shadow .15s;
|
| 428 |
+
}
|
| 429 |
+
.ask-input::placeholder { color: var(--ink-faint); }
|
| 430 |
+
.ask-input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-tint2); }
|
| 431 |
+
.ask-btn {
|
| 432 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 433 |
+
padding: 0 26px;
|
| 434 |
+
background: var(--purple);
|
| 435 |
+
border: 1px solid var(--purple);
|
| 436 |
+
border-radius: var(--r);
|
| 437 |
+
color: #fff; font-size: 14.5px; font-weight: 600;
|
| 438 |
+
transition: background .15s;
|
| 439 |
+
white-space: nowrap;
|
| 440 |
+
}
|
| 441 |
+
.ask-btn:hover { background: var(--purple-deep); }
|
| 442 |
+
.ask-btn:active { transform: translateY(1px); }
|
| 443 |
+
|
| 444 |
+
/* ---- 답변 카드 3-col ---- */
|
| 445 |
+
.answers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
|
| 446 |
+
.ans {
|
| 447 |
+
background: var(--surface);
|
| 448 |
+
border: 1px solid var(--line);
|
| 449 |
+
border-radius: var(--r);
|
| 450 |
+
box-shadow: var(--shadow-sm);
|
| 451 |
+
overflow: hidden;
|
| 452 |
+
display: flex; flex-direction: column;
|
| 453 |
+
}
|
| 454 |
+
.ans-head {
|
| 455 |
+
display: flex; align-items: center; gap: 10px;
|
| 456 |
+
padding: 13px 16px;
|
| 457 |
+
border-bottom: 1px solid var(--line-soft);
|
| 458 |
+
}
|
| 459 |
+
.ans-badge {
|
| 460 |
+
width: 28px; height: 28px; border-radius: 8px;
|
| 461 |
+
display: grid; place-items: center; font-size: 15px;
|
| 462 |
+
flex: 0 0 auto;
|
| 463 |
+
}
|
| 464 |
+
.ans-title { font-weight: 700; font-size: 14.5px; letter-spacing: -0.01em; }
|
| 465 |
+
.ans-kind { font-size: 11px; color: var(--ink-faint); margin-top: 1px; }
|
| 466 |
+
.ans-body { padding: 16px; font-size: 13.5px; color: var(--ink-2); line-height: 1.62; flex: 1; }
|
| 467 |
+
.ans-body p { margin: 0 0 9px; }
|
| 468 |
+
.ans-body p:last-child { margin-bottom: 0; }
|
| 469 |
+
.ans-body h4 { margin: 0 0 9px; font-size: 14px; font-weight: 700; color: var(--ink); }
|
| 470 |
+
.ans-body ul { margin: 0; padding-left: 17px; display: flex; flex-direction: column; gap: 6px; }
|
| 471 |
+
.ans-body ul li { line-height: 1.5; }
|
| 472 |
+
.ans-body ul li b { color: var(--ink); font-weight: 600; }
|
| 473 |
+
.ans-body .ctx-line { display: flex; gap: 8px; padding: 3px 0; border-bottom: 1px dashed var(--line-soft); }
|
| 474 |
+
.ans-body .ctx-line:last-child { border-bottom: 0; }
|
| 475 |
+
.ans-body .ctx-k { color: var(--ink-faint); min-width: 64px; font-size: 12.5px; }
|
| 476 |
+
.ans-body .ctx-v { color: var(--ink); font-weight: 500; }
|
| 477 |
+
|
| 478 |
+
.ans-foot {
|
| 479 |
+
padding: 11px 16px;
|
| 480 |
+
border-top: 1px solid var(--line-soft);
|
| 481 |
+
background: var(--surface-2);
|
| 482 |
+
display: flex; flex-direction: column; gap: 7px;
|
| 483 |
+
}
|
| 484 |
+
.route-cap { font-size: 11.5px; color: var(--ink-3); display: flex; align-items: center; gap: 6px; }
|
| 485 |
+
.route-cap .mono { color: var(--purple-deep); font-size: 11px; }
|
| 486 |
+
.foot-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 487 |
+
.time-pill {
|
| 488 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 489 |
+
font-size: 11.5px; font-weight: 600;
|
| 490 |
+
padding: 3px 9px; border-radius: 100px;
|
| 491 |
+
background: var(--surface); border: 1px solid var(--line);
|
| 492 |
+
color: var(--ink-2);
|
| 493 |
+
}
|
| 494 |
+
.time-pill .mono { font-size: 11.5px; }
|
| 495 |
+
.host-note {
|
| 496 |
+
font-size: 11px; color: var(--amber); font-weight: 600;
|
| 497 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
/* badge tints */
|
| 501 |
+
.b-py { background: var(--green-tint); color: var(--green); }
|
| 502 |
+
.b-son { background: var(--purple-tint); color: var(--purple-deep); }
|
| 503 |
+
.b-gem { background: var(--teal-tint); color: var(--teal); }
|
| 504 |
+
.ans.t-py { border-top: 2.5px solid var(--green); }
|
| 505 |
+
.ans.t-son { border-top: 2.5px solid var(--purple); }
|
| 506 |
+
.ans.t-gem { border-top: 2.5px solid var(--teal); }
|
| 507 |
+
|
| 508 |
+
.empty-ans {
|
| 509 |
+
grid-column: 1 / -1;
|
| 510 |
+
border: 1.5px dashed var(--line-strong);
|
| 511 |
+
border-radius: var(--r);
|
| 512 |
+
padding: 38px;
|
| 513 |
+
text-align: center;
|
| 514 |
+
color: var(--ink-faint);
|
| 515 |
+
font-size: 13.5px;
|
| 516 |
+
background: var(--surface-2);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* =========================================================================
|
| 520 |
+
데이터 관리 탭
|
| 521 |
+
========================================================================= */
|
| 522 |
+
.data-table-card { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
| 523 |
+
.dtc-head {
|
| 524 |
+
padding: 13px 16px;
|
| 525 |
+
border-bottom: 1px solid var(--line-soft);
|
| 526 |
+
font-weight: 700; font-size: 14px; letter-spacing: -0.01em;
|
| 527 |
+
display: flex; align-items: center; gap: 9px;
|
| 528 |
+
background: var(--surface-2);
|
| 529 |
+
}
|
| 530 |
+
.dtc-head .ico { font-size: 14px; }
|
| 531 |
+
.dtc-head .count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--ink-faint); }
|
| 532 |
+
.dtc-body { padding: 4px 0; }
|
| 533 |
+
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 534 |
+
.data-table th, .data-table td { text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--line-soft); }
|
| 535 |
+
.data-table tr:last-child td { border-bottom: 0; }
|
| 536 |
+
.data-table th { font-size: 11px; color: var(--ink-faint); font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
|
| 537 |
+
.data-table td.id { color: var(--ink-3); }
|
| 538 |
+
.data-table td.amt { font-weight: 600; color: var(--purple-deep); font-family: "JetBrains Mono", monospace; text-align: right; }
|
| 539 |
+
.data-table .num { text-align: right; }
|
| 540 |
+
|
| 541 |
+
.empty-box {
|
| 542 |
+
margin: 16px; padding: 24px 18px;
|
| 543 |
+
border: 1.5px dashed var(--line-strong);
|
| 544 |
+
border-radius: var(--r-sm);
|
| 545 |
+
text-align: center;
|
| 546 |
+
color: var(--ink-3);
|
| 547 |
+
font-size: 13px; line-height: 1.6;
|
| 548 |
+
background: var(--surface-2);
|
| 549 |
+
}
|
| 550 |
+
.empty-box .em-ico { font-size: 22px; display: block; margin-bottom: 8px; opacity: .8; }
|
| 551 |
+
.empty-box .em-sub { font-size: 11.5px; color: var(--ink-faint); margin-top: 6px; }
|
| 552 |
+
|
| 553 |
+
/* ---- 등록 폼 ---- */
|
| 554 |
+
.form-card { padding: 0; overflow: hidden; }
|
| 555 |
+
.form-section { border-bottom: 1px solid var(--line-soft); }
|
| 556 |
+
.form-section:last-child { border-bottom: 0; }
|
| 557 |
+
.fs-head {
|
| 558 |
+
display: flex; align-items: center; gap: 11px;
|
| 559 |
+
padding: 15px 20px 13px;
|
| 560 |
+
}
|
| 561 |
+
.fs-num {
|
| 562 |
+
width: 24px; height: 24px; border-radius: 7px;
|
| 563 |
+
display: grid; place-items: center;
|
| 564 |
+
background: var(--purple); color: #fff;
|
| 565 |
+
font-size: 12px; font-weight: 700; flex: 0 0 auto;
|
| 566 |
+
}
|
| 567 |
+
.fs-title { font-weight: 700; font-size: 14.5px; letter-spacing: -0.01em; }
|
| 568 |
+
.fs-hint { font-size: 12px; color: var(--ink-faint); margin-left: auto; }
|
| 569 |
+
.fs-body { padding: 2px 20px 18px; }
|
| 570 |
+
|
| 571 |
+
.field-grid { display: grid; gap: 12px; }
|
| 572 |
+
.fg-1 { grid-template-columns: 1fr; }
|
| 573 |
+
.fg-2 { grid-template-columns: 1fr 1fr; }
|
| 574 |
+
.fg-mix { grid-template-columns: 1fr 1fr; }
|
| 575 |
+
|
| 576 |
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
| 577 |
+
.field label { font-size: 12px; font-weight: 600; color: var(--ink-3); }
|
| 578 |
+
.inp, .sel {
|
| 579 |
+
padding: 10px 12px;
|
| 580 |
+
border: 1px solid var(--line-strong);
|
| 581 |
+
border-radius: var(--r-sm);
|
| 582 |
+
background: var(--surface);
|
| 583 |
+
font-size: 13.5px;
|
| 584 |
+
outline: none;
|
| 585 |
+
transition: border-color .15s, box-shadow .15s;
|
| 586 |
+
}
|
| 587 |
+
.inp:focus, .sel:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-tint2); }
|
| 588 |
+
.sel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%238a8580' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 11px center; padding-right: 30px; appearance: none; cursor: pointer; }
|
| 589 |
+
|
| 590 |
+
/* 동적 행 */
|
| 591 |
+
.dyn-row {
|
| 592 |
+
display: grid;
|
| 593 |
+
gap: 9px;
|
| 594 |
+
align-items: end;
|
| 595 |
+
padding: 11px;
|
| 596 |
+
border: 1px solid var(--line);
|
| 597 |
+
border-radius: var(--r-sm);
|
| 598 |
+
background: var(--surface-2);
|
| 599 |
+
margin-bottom: 9px;
|
| 600 |
+
}
|
| 601 |
+
.dyn-row.invest { grid-template-columns: 28px 1.4fr 0.9fr 0.9fr 1.4fr; }
|
| 602 |
+
.dyn-row.branch { grid-template-columns: 28px 1.2fr 0.9fr 1.1fr 1fr; }
|
| 603 |
+
.dyn-idx {
|
| 604 |
+
align-self: center;
|
| 605 |
+
width: 24px; height: 24px; border-radius: 6px;
|
| 606 |
+
display: grid; place-items: center;
|
| 607 |
+
background: var(--cream-deep); color: var(--ink-3);
|
| 608 |
+
font-size: 11.5px; font-weight: 700;
|
| 609 |
+
font-family: "JetBrains Mono", monospace;
|
| 610 |
+
}
|
| 611 |
+
.dyn-row .field label { font-size: 11px; }
|
| 612 |
+
.dyn-actions { display: flex; gap: 8px; margin-top: 4px; }
|
| 613 |
+
.btn-ghost {
|
| 614 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 615 |
+
padding: 7px 13px;
|
| 616 |
+
background: var(--surface);
|
| 617 |
+
border: 1px solid var(--line-strong);
|
| 618 |
+
border-radius: var(--r-sm);
|
| 619 |
+
font-size: 12.5px; font-weight: 600; color: var(--ink-2);
|
| 620 |
+
transition: all .14s;
|
| 621 |
+
}
|
| 622 |
+
.btn-ghost:hover { border-color: var(--purple-soft); color: var(--purple-deep); background: var(--purple-tint); }
|
| 623 |
+
.btn-ghost.danger:hover { border-color: oklch(0.6 0.13 25); color: oklch(0.5 0.15 25); background: oklch(0.96 0.02 25); }
|
| 624 |
+
|
| 625 |
+
.form-submit { padding: 20px; background: var(--surface-2); border-top: 1px solid var(--line-soft); }
|
| 626 |
+
.btn-submit {
|
| 627 |
+
width: 100%;
|
| 628 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
| 629 |
+
padding: 14px;
|
| 630 |
+
background: var(--purple);
|
| 631 |
+
border: 1px solid var(--purple);
|
| 632 |
+
border-radius: var(--r);
|
| 633 |
+
color: #fff; font-size: 15px; font-weight: 700;
|
| 634 |
+
transition: background .15s;
|
| 635 |
+
}
|
| 636 |
+
.btn-submit:hover { background: var(--purple-deep); }
|
| 637 |
+
.btn-submit:active { transform: translateY(1px); }
|
| 638 |
+
|
| 639 |
+
/* =========================================================================
|
| 640 |
+
반응형 — < 768px 세로 stack
|
| 641 |
+
========================================================================= */
|
| 642 |
+
@media (max-width: 980px) {
|
| 643 |
+
.grid-3, .answers { grid-template-columns: 1fr 1fr; }
|
| 644 |
+
.route-block { grid-template-columns: 1fr; }
|
| 645 |
+
}
|
| 646 |
+
@media (max-width: 768px) {
|
| 647 |
+
.wrap { padding: 0 16px; }
|
| 648 |
+
.appbar-inner { flex-wrap: wrap; gap: 14px; }
|
| 649 |
+
.env-pill { margin-left: 0; order: 3; width: 100%; justify-content: center; }
|
| 650 |
+
.tabbar-inner { overflow-x: auto; }
|
| 651 |
+
.tab { padding: 13px 13px 11px; font-size: 13.5px; white-space: nowrap; }
|
| 652 |
+
.grid-3, .grid-2, .answers, .expander-row { grid-template-columns: 1fr; gap: 12px; }
|
| 653 |
+
.ask-row { flex-direction: column; }
|
| 654 |
+
.ask-btn { padding: 12px; justify-content: center; }
|
| 655 |
+
.fg-2, .fg-mix { grid-template-columns: 1fr; }
|
| 656 |
+
.dyn-row.invest, .dyn-row.branch { grid-template-columns: 1fr; }
|
| 657 |
+
.dyn-idx { justify-self: start; }
|
| 658 |
+
.data-mgmt-grid { grid-template-columns: 1fr; }
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.data-mgmt-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; align-items: start; }
|
web/vite.config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5173,
|
| 8 |
+
proxy: {
|
| 9 |
+
// 개발 시 /api 호출을 FastAPI(8000)로 프록시
|
| 10 |
+
'/api': {
|
| 11 |
+
target: 'http://localhost:8000',
|
| 12 |
+
changeOrigin: true,
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
build: {
|
| 17 |
+
outDir: 'dist',
|
| 18 |
+
sourcemap: false,
|
| 19 |
+
},
|
| 20 |
+
});
|