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 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
+ });