Spaces:
Running
Running
Upload 16 files
Browse files- .gitattributes +6 -0
- app.py +242 -0
- data/EUR_RAG/EUR_index.faiss +3 -0
- data/EUR_RAG/EUR_index.pkl +3 -0
- data/EUR_RAG/EUR_metadata_mapping.db +3 -0
- data/FMVSS_RAG/US_index.faiss +3 -0
- data/FMVSS_RAG/US_index.pkl +3 -0
- data/FMVSS_RAG/US_metadata_mapping.db +3 -0
- data/KMVSS_RAG/Kor_index.faiss +3 -0
- data/KMVSS_RAG/Kor_index.pkl +3 -0
- data/KMVSS_RAG/Kor_metadata_mapping.db +3 -0
- dockerfile +8 -0
- leximind_prompts.py +37 -0
- reg_embedding_system.py +527 -0
- requirements.txt +20 -0
- templates/chat.html +1243 -0
- templates/toss_dark.html +811 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/EUR_RAG/EUR_index.faiss filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/EUR_RAG/EUR_metadata_mapping.db filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/FMVSS_RAG/US_index.faiss filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
data/FMVSS_RAG/US_metadata_mapping.db filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
data/KMVSS_RAG/Kor_index.faiss filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
data/KMVSS_RAG/Kor_metadata_mapping.db filter=lfs diff=lfs merge=lfs -text
|
app.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
from flask import Flask, render_template, jsonify, request
|
| 3 |
+
from flask_socketio import SocketIO
|
| 4 |
+
import threading
|
| 5 |
+
import os
|
| 6 |
+
import sqlite3
|
| 7 |
+
import gc
|
| 8 |
+
import time
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
# --- 외부 모듈 임포트 ---
|
| 12 |
+
import reg_embedding_system
|
| 13 |
+
import leximind_prompts
|
| 14 |
+
|
| 15 |
+
# --- Together AI SDK ---
|
| 16 |
+
from together import Together
|
| 17 |
+
|
| 18 |
+
# --- Flask & SocketIO 설정 ---
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet')
|
| 21 |
+
|
| 22 |
+
# --- 전역 변수 ---
|
| 23 |
+
connected_clients = 0
|
| 24 |
+
search_document_number = 30
|
| 25 |
+
Filtered_search = False
|
| 26 |
+
filters = {"regulation_part": []}
|
| 27 |
+
|
| 28 |
+
# --- 경로 설정 ---
|
| 29 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 30 |
+
ResultFile_FolderAddress = os.path.join(current_dir, 'result.txt')
|
| 31 |
+
|
| 32 |
+
# --- RAG 데이터 경로 ---
|
| 33 |
+
region_paths = {
|
| 34 |
+
"국내": "/app/data/KMVSS_RAG",
|
| 35 |
+
"북미": "/app/data/FMVSS_RAG",
|
| 36 |
+
"유럽": "/app/data/EUR_RAG"
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# --- 프롬프트 ---
|
| 40 |
+
lexi_prompts = leximind_prompts.PromptLibrary()
|
| 41 |
+
|
| 42 |
+
# --- RAG 객체 ---
|
| 43 |
+
region_rag_objects = {}
|
| 44 |
+
|
| 45 |
+
# --- Together AI 클라이언트 (Hugging Face Secrets에서 API 키 가져오기) ---
|
| 46 |
+
TOGETHER_API_KEY = os.getenv("TOGETHER_API_KEY")
|
| 47 |
+
if not TOGETHER_API_KEY:
|
| 48 |
+
raise EnvironmentError("TOGETHER_API_KEY가 설정되지 않았습니다. Hugging Face Secrets에 추가하세요.")
|
| 49 |
+
|
| 50 |
+
client = Together(api_key=TOGETHER_API_KEY)
|
| 51 |
+
|
| 52 |
+
# --- RAG 로딩 ---
|
| 53 |
+
def load_rag_objects():
|
| 54 |
+
global region_rag_objects
|
| 55 |
+
for region, path in region_paths.items():
|
| 56 |
+
if not os.path.exists(path):
|
| 57 |
+
msg = f"[{region}] 경로 없음: {path}"
|
| 58 |
+
socketio.emit('message', {'message': msg})
|
| 59 |
+
print(msg)
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
socketio.emit('message', {'message': f"[{region}] RAG 로딩 중..."})
|
| 64 |
+
ensemble_retriever, vectorstore, sqlite_conn = reg_embedding_system.load_embedding_from_faiss(path)
|
| 65 |
+
sqlite_conn.close()
|
| 66 |
+
db_path = os.path.join(path, "metadata_mapping.db")
|
| 67 |
+
new_conn = sqlite3.connect(db_path, check_same_thread=False)
|
| 68 |
+
|
| 69 |
+
region_rag_objects[region] = {
|
| 70 |
+
"ensemble_retriever": ensemble_retriever,
|
| 71 |
+
"vectorstore": vectorstore,
|
| 72 |
+
"sqlite_conn": new_conn
|
| 73 |
+
}
|
| 74 |
+
socketio.emit('message', {'message': f"[{region}] 로딩 완료"})
|
| 75 |
+
print(f"[{region}] RAG 로딩 완료")
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
error_msg = f"[{region}] 로딩 실패: {str(e)}"
|
| 79 |
+
print(error_msg)
|
| 80 |
+
socketio.emit('message', {'message': error_msg})
|
| 81 |
+
|
| 82 |
+
socketio.emit('message', {'message': "Ready to Search"})
|
| 83 |
+
print("Ready to Search")
|
| 84 |
+
|
| 85 |
+
# --- 웹 ---
|
| 86 |
+
@app.route('/')
|
| 87 |
+
def index():
|
| 88 |
+
return render_template('chat.html')
|
| 89 |
+
|
| 90 |
+
# --- 메시지 ---
|
| 91 |
+
@app.route('/get_message', methods=['POST'])
|
| 92 |
+
def get_message():
|
| 93 |
+
global Filtered_search, filters
|
| 94 |
+
data = request.get_json()
|
| 95 |
+
query = data.get('query', '').strip()
|
| 96 |
+
regions = data.get('regions', [])
|
| 97 |
+
selected_regulations = data.get('selectedRegulations', [])
|
| 98 |
+
|
| 99 |
+
filters = {"regulation_part": []}
|
| 100 |
+
Filtered_search = bool(selected_regulations)
|
| 101 |
+
if selected_regulations:
|
| 102 |
+
for reg in selected_regulations:
|
| 103 |
+
title = reg.get('title', '')
|
| 104 |
+
if title:
|
| 105 |
+
filters["regulation_part"].append(title)
|
| 106 |
+
|
| 107 |
+
Rag_Results = search_DB_from_multiple_regions(query, regions, region_rag_objects)
|
| 108 |
+
AImessage = RegAI(query, Rag_Results, ResultFile_FolderAddress)
|
| 109 |
+
|
| 110 |
+
return jsonify(message=AImessage)
|
| 111 |
+
|
| 112 |
+
# --- 법규 리스트 ---
|
| 113 |
+
@app.route('/get_reg_list', methods=['POST'])
|
| 114 |
+
def get_reg_list():
|
| 115 |
+
data = request.get_json()
|
| 116 |
+
selected_regions = data.get('regions', []) or ["국내", "북미", "유럽"]
|
| 117 |
+
|
| 118 |
+
all_reg_list_part = []
|
| 119 |
+
for region in selected_regions:
|
| 120 |
+
rag = region_rag_objects.get(region)
|
| 121 |
+
if not rag: continue
|
| 122 |
+
try:
|
| 123 |
+
conn = rag["sqlite_conn"]
|
| 124 |
+
parts = reg_embedding_system.get_unique_metadata_values(conn, "regulation_part")
|
| 125 |
+
all_reg_list_part.extend(parts)
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"[{region}] 법규 로드 실패: {e}")
|
| 128 |
+
|
| 129 |
+
unique_parts = sorted(set(all_reg_list_part), key=reg_embedding_system.natural_sort_key)
|
| 130 |
+
return jsonify(reg_list_part="\n".join(unique_parts))
|
| 131 |
+
|
| 132 |
+
# --- SocketIO ---
|
| 133 |
+
@socketio.on('connect')
|
| 134 |
+
def handle_connect():
|
| 135 |
+
global connected_clients
|
| 136 |
+
connected_clients += 1
|
| 137 |
+
print(f"클라이언트 연결: {connected_clients}명")
|
| 138 |
+
|
| 139 |
+
@socketio.on('disconnect')
|
| 140 |
+
def handle_disconnect():
|
| 141 |
+
global connected_clients
|
| 142 |
+
connected_clients -= 1
|
| 143 |
+
print(f"연결 해제: {connected_clients}명")
|
| 144 |
+
if connected_clients <= 0:
|
| 145 |
+
cleanup_connections()
|
| 146 |
+
print("서버 종료")
|
| 147 |
+
os._exit(0)
|
| 148 |
+
|
| 149 |
+
def cleanup_connections():
|
| 150 |
+
for region, rag in region_rag_objects.items():
|
| 151 |
+
try:
|
| 152 |
+
rag["sqlite_conn"].close()
|
| 153 |
+
print(f"[{region}] DB 연결 종료")
|
| 154 |
+
except:
|
| 155 |
+
pass
|
| 156 |
+
|
| 157 |
+
# --- Together AI 분석 ---
|
| 158 |
+
def Gemma3_AI_analysis(query_txt, content_txt):
|
| 159 |
+
content_txt = "\n".join(doc.page_content for doc in content_txt) if isinstance(content_txt, list) else str(content_txt)
|
| 160 |
+
query_txt = str(query_txt)
|
| 161 |
+
prompt = lexi_prompts.use_prompt(lexi_prompts.AI_system_prompt, query_txt=query_txt, content_txt=content_txt)
|
| 162 |
+
|
| 163 |
+
response = client.chat.completions.create(
|
| 164 |
+
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
| 165 |
+
messages=[{"role": "user", "content": prompt}],
|
| 166 |
+
max_tokens=1024,
|
| 167 |
+
temperature=0.7
|
| 168 |
+
)
|
| 169 |
+
return response.choices[0].message.content
|
| 170 |
+
|
| 171 |
+
# --- Together AI 번역 ---
|
| 172 |
+
def Gemma3_AI_Translate(query_txt):
|
| 173 |
+
query_txt = str(query_txt)
|
| 174 |
+
prompt = lexi_prompts.use_prompt(lexi_prompts.query_translator, query_txt=query_txt)
|
| 175 |
+
|
| 176 |
+
response = client.chat.completions.create(
|
| 177 |
+
model="meta-llama/Llama-3.2-3B-Instruct-Turbo",
|
| 178 |
+
messages=[{"role": "user", "content": prompt}],
|
| 179 |
+
max_tokens=512,
|
| 180 |
+
temperature=0.3
|
| 181 |
+
)
|
| 182 |
+
return response.choices[0].message.content
|
| 183 |
+
|
| 184 |
+
# --- 검색 ---
|
| 185 |
+
def search_DB_from_multiple_regions(query, selected_regions, region_rag_objects):
|
| 186 |
+
selected_regions = selected_regions or list(region_rag_objects.keys())
|
| 187 |
+
query = Gemma3_AI_Translate(query)
|
| 188 |
+
print(f"번역된 쿼리: {query}")
|
| 189 |
+
|
| 190 |
+
combined_results = []
|
| 191 |
+
for region in selected_regions:
|
| 192 |
+
rag = region_rag_objects.get(region)
|
| 193 |
+
if not rag: continue
|
| 194 |
+
|
| 195 |
+
retriever = rag["ensemble_retriever"]
|
| 196 |
+
vectorstore = rag["vectorstore"]
|
| 197 |
+
sqlite_conn = rag["sqlite_conn"]
|
| 198 |
+
|
| 199 |
+
if Filtered_search:
|
| 200 |
+
results = reg_embedding_system.search_with_metadata_filter(
|
| 201 |
+
ensemble_retriever=retriever,
|
| 202 |
+
vectorstore=vectorstore,
|
| 203 |
+
query=query,
|
| 204 |
+
k=search_document_number,
|
| 205 |
+
metadata_filter=filters,
|
| 206 |
+
sqlite_conn=sqlite_conn
|
| 207 |
+
)
|
| 208 |
+
else:
|
| 209 |
+
results = reg_embedding_system.smart_search_vectorstore(
|
| 210 |
+
retriever=retriever,
|
| 211 |
+
query=query,
|
| 212 |
+
k=search_document_number,
|
| 213 |
+
vectorstore=vectorstore,
|
| 214 |
+
sqlite_conn=sqlite_conn,
|
| 215 |
+
enable_detailed_search=True
|
| 216 |
+
)
|
| 217 |
+
print(f"[{region}] 검색: {len(results)}건")
|
| 218 |
+
combined_results.extend(results)
|
| 219 |
+
|
| 220 |
+
return combined_results
|
| 221 |
+
|
| 222 |
+
# --- 최종 AI ---
|
| 223 |
+
def RegAI(query, Rag_Results, ResultFile_FolderAddress):
|
| 224 |
+
gc.collect()
|
| 225 |
+
AI_Result = "검색 결과가 없습니다." if not Rag_Results else Gemma3_AI_analysis(query, Rag_Results)
|
| 226 |
+
|
| 227 |
+
with open(ResultFile_FolderAddress, 'w', encoding='utf-8') as f:
|
| 228 |
+
print("검색된 문서:", file=f)
|
| 229 |
+
for i, doc in enumerate(Rag_Results):
|
| 230 |
+
print(f"문서 {i+1}: {doc.page_content[:200]}... (메타: {doc.metadata})", file=f)
|
| 231 |
+
print("\n답변:", file=f)
|
| 232 |
+
print(AI_Result, file=f)
|
| 233 |
+
|
| 234 |
+
return AI_Result
|
| 235 |
+
|
| 236 |
+
# --- 실행 ---
|
| 237 |
+
if __name__ == '__main__':
|
| 238 |
+
threading.Thread(target=load_rag_objects, daemon=True).start()
|
| 239 |
+
time.sleep(2)
|
| 240 |
+
socketio.emit('message', {'message': '데이터 로딩 시작...'})
|
| 241 |
+
|
| 242 |
+
socketio.run(app, host='0.0.0.0', port=7860, debug=False)
|
data/EUR_RAG/EUR_index.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:99a3b3da85ca0106907d631ee7fa643f4bec194b33890f4500ddaf94a4f4b1c8
|
| 3 |
+
size 55246893
|
data/EUR_RAG/EUR_index.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b7e12c0d80639e31b16e5b02dd407c10e0da6d1cb88e7bd7c0974edc5f9f0e35
|
| 3 |
+
size 15563867
|
data/EUR_RAG/EUR_metadata_mapping.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d97546a48ac470642e836e85ba7816dc811e4101fb983462e12e661761cf9668
|
| 3 |
+
size 14094336
|
data/FMVSS_RAG/US_index.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:549e23730d18b1278735d97688dd119a1710231f191ac907f05661e451c4e0d3
|
| 3 |
+
size 13553709
|
data/FMVSS_RAG/US_index.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e28e5ac5eeaa73e0fe554c78cde263e42631f70b387c9df0cdb46d737f746607
|
| 3 |
+
size 7182308
|
data/FMVSS_RAG/US_metadata_mapping.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8c3c9db3fbc0af365f9dc2c55c68bd516d6ae30cfab0fd18b5b36d934b441ba1
|
| 3 |
+
size 5029888
|
data/KMVSS_RAG/Kor_index.faiss
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:84d109f1b1b078ff106eb1b3a7c429583a34528c57422d6049b136854ada824f
|
| 3 |
+
size 3041325
|
data/KMVSS_RAG/Kor_index.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c3fedff0fa38752bcdf5d62bd244151a6cdb2691db781970f5cdad1ae7a5b1f
|
| 3 |
+
size 1490577
|
data/KMVSS_RAG/Kor_metadata_mapping.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eaeebd3d9f8a96cc37fb0984084283ca326d445ff561d499c9eafef922294dc9
|
| 3 |
+
size 704512
|
dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
RUN apt-get update && apt-get install -y gcc g++ && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY . .
|
| 7 |
+
EXPOSE 7860
|
| 8 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--worker-class", "eventlet", "--workers", "1", "app:socketio"]
|
leximind_prompts.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class PromptLibrary:
|
| 2 |
+
def __init__(self):
|
| 3 |
+
#RAG용 Document를 찾기 위해 질문 자체를 한번 변경하기 위한 프롬프트
|
| 4 |
+
#영문 법규를 번역하기 위해서는 질문도 영문인것이 성능상 좋기 때문에 일단 번역을 붙이는 정도로 초안 작성됨
|
| 5 |
+
#이 프롬프트를 고도화하기 위해서는 통상 쓰는 용어를 법규적인 용어로 바꿔주는 부분도 필요
|
| 6 |
+
#예를 들어 다음과 같이 바뀔수 있도록 프롬프트 작성 필요 예) 충돌 관련 규정 찾아줘 --> 충돌(crash,impact,rollover) 관련 규정 찾아줘
|
| 7 |
+
self.query_translator = """
|
| 8 |
+
다음 질문을 RAG 검색을 위해 가장 적합한 Query로 변경할텐데 다음에 유의해서 질문을 잘 반영하는 키워드만 뽑고 다른말은 하지마.
|
| 9 |
+
유의사항
|
| 10 |
+
1. 키워드는 한국어, 영어 둘다로 만들어줘
|
| 11 |
+
2. 충돌이라는 말이 나오면 키워드에는 한글로는 충돌, 충격이 포함되고 영어는 collision, crash, impact, rollover라는 말이 다 포함되어야 해
|
| 12 |
+
3. 연소성이라는 말이 나오면 '차실내장재의 내인화성' 이라는 한글 표현과 'Flammability of interior materials'라는 키워드도 넣어줘
|
| 13 |
+
4. 파워윈도우 또는 파워윈도우 반전기능이라는 말이 나오면 한글로는 안전기준34조, 창유리, 영어로는 571.118, UN regulation 21도 넣어줘
|
| 14 |
+
5. ESC와 관련한 질문이 있었을 때는, 한글로는 자동차안정성제어장치, 영어로는 UN Regulation 140, 571.126 Electronic stability control systems 키워드도 넣어줘
|
| 15 |
+
6. 내부돌기와 관련한 질문이 있었을 때는 UN regulation 21, Interior Fittings도 넣어줘
|
| 16 |
+
|
| 17 |
+
질문 : {query_txt}
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
#RAG용 Document(content_txt 변수가 해당)가 생성된 이후 사용자의 질문과 document 내용을 가지고 답을 생성하도록 하는 명령어
|
| 21 |
+
#질문의 유형에 따라서 답변에 꼭 포함되어야 할 것으로 생각되는 내용들이 Prompt에 잘 반영되어 있어야 함
|
| 22 |
+
self.AI_system_prompt = """
|
| 23 |
+
다음 질문에 대한 검색 결과가 아래와 같을때 질문에 대하여 아래 유의사항을 고려하여 검색 결과 내용을 정리해서 알려줘.
|
| 24 |
+
유의사항
|
| 25 |
+
1. 형식은 HTML 형식으로 결과를 만들것
|
| 26 |
+
2. 답은 질문자가 별도로 요구하지 않는 한 한국말로 할 것
|
| 27 |
+
2. 검색 결과에 있는 내용에 근거해서만 대답할 것. 검색 결과에 근거가 없는 경우는 검색 결과에 관련 근거가 없다고 알려줄것
|
| 28 |
+
3. 답변에 근거가 되는 조항은 아래에 근거로 표시할 것
|
| 29 |
+
4. 경고등 점등과 관련한 문서에서 Control에 대한 점등과 telltale에 대한 점등이 구분되어 있다면 구분해서 말할것. 별도의 구분이 없을 경우 따로 언급은 하지마
|
| 30 |
+
5. 비교하라는 요청이 있거나 그 밖에 표로 정리할 수 있는 경우는 표 형식으로 정리할 것
|
| 31 |
+
|
| 32 |
+
질문 : {query_txt}
|
| 33 |
+
검색 결과 : {content_txt}
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def use_prompt(self, prompt_text, **kwargs):
|
| 37 |
+
return prompt_text.format(**kwargs)
|
reg_embedding_system.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gc
|
| 2 |
+
import json
|
| 3 |
+
import sqlite3 # SQLite 모듈 추가
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional, Tuple, Any, Dict, List, Set
|
| 6 |
+
from collections import Counter # 빈도 계산을 위한 추가
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
import faiss
|
| 10 |
+
from langchain.retrievers import BM25Retriever, EnsembleRetriever
|
| 11 |
+
from langchain_core.documents import Document
|
| 12 |
+
from langchain_community.vectorstores import FAISS
|
| 13 |
+
from sentence_transformers import SentenceTransformer
|
| 14 |
+
|
| 15 |
+
# 런타임에 Embeddings 클래스를 찾기 위한 로직 (원본 코드 유지)
|
| 16 |
+
try:
|
| 17 |
+
from langchain_core.embeddings import Embeddings
|
| 18 |
+
except ImportError:
|
| 19 |
+
try:
|
| 20 |
+
from langchain.embeddings.base import Embeddings
|
| 21 |
+
except ImportError:
|
| 22 |
+
Embeddings = object
|
| 23 |
+
|
| 24 |
+
# --- SQLite 헬퍼 함수 ---
|
| 25 |
+
SQLITE_DB_NAME = "metadata_mapping.db"
|
| 26 |
+
|
| 27 |
+
# === IDSelector 클래스 정의 (파일 상단 또는 함수 외부에 위치) ===
|
| 28 |
+
# IDSelector 대신 IDSelectorBatch 사용
|
| 29 |
+
class MetadataIDSelector(faiss.IDSelectorBatch):
|
| 30 |
+
def __init__(self, allowed_ids: Set[int]):
|
| 31 |
+
# IDSelectorBatch는 allowed_ids 리스트를 직접 받음
|
| 32 |
+
super().__init__(list(allowed_ids)) # 리스트로 변환해서 전달
|
| 33 |
+
|
| 34 |
+
def get_db_connection(persist_directory: str) -> sqlite3.Connection:
|
| 35 |
+
"""FAISS 저장 경로를 기반으로 SQLite 연결을 설정하고 반환합니다."""
|
| 36 |
+
db_path = Path(persist_directory) / SQLITE_DB_NAME
|
| 37 |
+
conn = sqlite3.connect(db_path)
|
| 38 |
+
return conn
|
| 39 |
+
|
| 40 |
+
def _create_and_populate_sqlite_db(chunks: List[Document], persist_directory: str):
|
| 41 |
+
"""문서 청크를 기반으로 SQLite DB를 생성하고 채웁니다. (save_embedding_system 내부용)"""
|
| 42 |
+
conn = get_db_connection(persist_directory)
|
| 43 |
+
cursor = conn.cursor()
|
| 44 |
+
|
| 45 |
+
# 1. 테이블 생성
|
| 46 |
+
cursor.execute("""
|
| 47 |
+
CREATE TABLE IF NOT EXISTS documents (
|
| 48 |
+
faiss_id INTEGER PRIMARY KEY,
|
| 49 |
+
regulation_part TEXT,
|
| 50 |
+
regulation_section TEXT,
|
| 51 |
+
chapter_section TEXT,
|
| 52 |
+
jo TEXT,
|
| 53 |
+
json_metadata TEXT
|
| 54 |
+
)
|
| 55 |
+
""")
|
| 56 |
+
conn.commit()
|
| 57 |
+
|
| 58 |
+
# 2. 데이터 채우기: FAISS ID는 청크 인덱스 i와 동일하게 매핑
|
| 59 |
+
for i, doc in enumerate(chunks):
|
| 60 |
+
faiss_id = i
|
| 61 |
+
metadata_json = json.dumps(doc.metadata, ensure_ascii=False)
|
| 62 |
+
reg_part = doc.metadata.get('regulation_part')
|
| 63 |
+
reg_section = doc.metadata.get('regulation_section')
|
| 64 |
+
reg_chapter = doc.metadata.get('chapter_section')
|
| 65 |
+
reg_jo = doc.metadata.get('jo')
|
| 66 |
+
|
| 67 |
+
# 변수가 리스트인 경우, 쉼표로 구분된 문자열로 변환
|
| 68 |
+
if isinstance(reg_section, list):
|
| 69 |
+
reg_section = ', '.join(map(str, reg_section))
|
| 70 |
+
if isinstance(reg_part, list):
|
| 71 |
+
reg_part = ', '.join(map(str, reg_part))
|
| 72 |
+
if isinstance(reg_chapter, list):
|
| 73 |
+
reg_chapter = ', '.join(map(str, reg_chapter))
|
| 74 |
+
if isinstance(reg_jo, list):
|
| 75 |
+
reg_jo = ', '.join(map(str, reg_jo))
|
| 76 |
+
|
| 77 |
+
# 문서 메타데이터에 FAISS ID 추가 (검색 후 필터링 함수에서 활용)
|
| 78 |
+
doc.metadata['faiss_id'] = faiss_id
|
| 79 |
+
|
| 80 |
+
cursor.execute(
|
| 81 |
+
"INSERT OR REPLACE INTO documents (faiss_id, regulation_part, regulation_section, chapter_section, jo, json_metadata) VALUES (?, ?, ?, ?, ?, ?)",
|
| 82 |
+
(faiss_id, reg_part, reg_section, reg_chapter, reg_jo, metadata_json)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
conn.commit()
|
| 86 |
+
conn.close()
|
| 87 |
+
|
| 88 |
+
# --- LocalSentenceTransformerEmbeddings (변경 없음) ---
|
| 89 |
+
|
| 90 |
+
class LocalSentenceTransformerEmbeddings(Embeddings):
|
| 91 |
+
"""SentenceTransformer를 LangChain Embeddings 인터페이스로 래핑"""
|
| 92 |
+
|
| 93 |
+
def __init__(self, st_model, normalize_embeddings: bool = True, encode_batch_size: int = 32):
|
| 94 |
+
self.model = st_model
|
| 95 |
+
self.normalize = normalize_embeddings
|
| 96 |
+
self.encode_batch_size = encode_batch_size
|
| 97 |
+
|
| 98 |
+
def embed_documents(self, texts):
|
| 99 |
+
vecs = self.model.encode(
|
| 100 |
+
texts,
|
| 101 |
+
batch_size=self.encode_batch_size,
|
| 102 |
+
show_progress_bar=False,
|
| 103 |
+
normalize_embeddings=self.normalize,
|
| 104 |
+
convert_to_numpy=True,
|
| 105 |
+
)
|
| 106 |
+
return vecs.tolist()
|
| 107 |
+
|
| 108 |
+
def embed_query(self, text: str):
|
| 109 |
+
vec = self.model.encode(
|
| 110 |
+
[text],
|
| 111 |
+
batch_size=self.encode_batch_size,
|
| 112 |
+
show_progress_bar=False,
|
| 113 |
+
normalize_embeddings=self.normalize,
|
| 114 |
+
convert_to_numpy=True,
|
| 115 |
+
)[0]
|
| 116 |
+
return vec.tolist()
|
| 117 |
+
|
| 118 |
+
# --- save_embedding_system (SQLite 저장 로직 추가) ---
|
| 119 |
+
|
| 120 |
+
def save_embedding_system(
|
| 121 |
+
chunks,
|
| 122 |
+
persist_directory: str = r"D:/Project AI/RAG",
|
| 123 |
+
batch_size: int = 32,
|
| 124 |
+
device: str = 'cuda'
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
청크를 임베딩하여 FAISS 벡터스토어와 앙상블 리트리버를 생성하고,
|
| 128 |
+
SQLite DB에 메타데이터를 저장합니다.
|
| 129 |
+
"""
|
| 130 |
+
Path(persist_directory).mkdir(parents=True, exist_ok=True)
|
| 131 |
+
|
| 132 |
+
# 1) SQLite DB에 메타데이터 저장 및 청크에 faiss_id 추가
|
| 133 |
+
# 이 함수 내에서 chunks 리스트의 metadata에 'faiss_id'가 추가됩니다.
|
| 134 |
+
_create_and_populate_sqlite_db(chunks, persist_directory)
|
| 135 |
+
|
| 136 |
+
# 2) SentenceTransformer 로드 (원래 코드와 동일)
|
| 137 |
+
model = SentenceTransformer(
|
| 138 |
+
'nomic-ai/nomic-embed-text-v2-moe',
|
| 139 |
+
trust_remote_code=True,
|
| 140 |
+
device=device
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
embeddings = LocalSentenceTransformerEmbeddings(
|
| 144 |
+
st_model=model,
|
| 145 |
+
normalize_embeddings=True,
|
| 146 |
+
encode_batch_size=batch_size
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# 3) FAISS 벡터스토어 생성 (원래 코드와 동일)
|
| 150 |
+
vectorstore = None
|
| 151 |
+
for i in range(0, len(chunks), batch_size):
|
| 152 |
+
batch = chunks[i:i + batch_size]
|
| 153 |
+
if vectorstore is None:
|
| 154 |
+
vectorstore = FAISS.from_documents(documents=batch, embedding=embeddings)
|
| 155 |
+
else:
|
| 156 |
+
vectorstore.add_documents(documents=batch)
|
| 157 |
+
gc.collect()
|
| 158 |
+
|
| 159 |
+
# 4) BM25 + 벡터 앙상블 리트리버 생성 (원래 코드와 동일)
|
| 160 |
+
bm25_retriever = BM25Retriever.from_documents(chunks)
|
| 161 |
+
bm25_retriever.k = 5
|
| 162 |
+
|
| 163 |
+
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
|
| 164 |
+
|
| 165 |
+
ensemble_retriever = EnsembleRetriever(
|
| 166 |
+
retrievers=[vector_retriever, bm25_retriever],
|
| 167 |
+
weights=[0.6, 0.4]
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# 5) FAISS 인덱스 저장 (원래 코드와 동일)
|
| 171 |
+
vectorstore.save_local(persist_directory)
|
| 172 |
+
|
| 173 |
+
# 6) SQLite 연결
|
| 174 |
+
sqlite_conn = get_db_connection(persist_directory)
|
| 175 |
+
gc.collect()
|
| 176 |
+
|
| 177 |
+
return ensemble_retriever, vectorstore, sqlite_conn
|
| 178 |
+
|
| 179 |
+
# --- load_embedding_from_faiss (SQLite 연결 반환 추가) ---
|
| 180 |
+
|
| 181 |
+
def load_embedding_from_faiss(
|
| 182 |
+
persist_directory: str = r"D:/Project AI/RAG",
|
| 183 |
+
top_k: int = 10,
|
| 184 |
+
bm25_k: int = 10,
|
| 185 |
+
weights: Tuple[float, float] = (0.6, 0.4),
|
| 186 |
+
embeddings: Optional[Any] = None,
|
| 187 |
+
device: str = 'cpu'
|
| 188 |
+
) -> Tuple[Any, FAISS, sqlite3.Connection]: # 반환 값에 SQLite 연결 추가
|
| 189 |
+
"""
|
| 190 |
+
저장된 FAISS 인덱스와 SQLite 연결을 로드하여 앙상블 리트리버를 생성합니다.
|
| 191 |
+
"""
|
| 192 |
+
# 1) Embeddings 준비 (원래 코드와 동일)
|
| 193 |
+
if embeddings is None:
|
| 194 |
+
st_model = SentenceTransformer(
|
| 195 |
+
'nomic-ai/nomic-embed-text-v2-moe',
|
| 196 |
+
trust_remote_code=True,
|
| 197 |
+
device=device
|
| 198 |
+
)
|
| 199 |
+
embeddings = LocalSentenceTransformerEmbeddings(
|
| 200 |
+
st_model=st_model,
|
| 201 |
+
normalize_embeddings=True,
|
| 202 |
+
encode_batch_size=32
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# 2) FAISS 벡터스토어 로드 (원래 코드와 동일)
|
| 206 |
+
persist_dir = Path(persist_directory)
|
| 207 |
+
if not persist_dir.exists():
|
| 208 |
+
raise FileNotFoundError(f"FAISS 경로가 없습니다: {persist_dir}")
|
| 209 |
+
|
| 210 |
+
vectorstore = FAISS.load_local(
|
| 211 |
+
folder_path=str(persist_dir),
|
| 212 |
+
embeddings=embeddings,
|
| 213 |
+
allow_dangerous_deserialization=True
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# 3) BM25를 위한 문서 추출 (원래 코드와 동일)
|
| 217 |
+
docs = []
|
| 218 |
+
try:
|
| 219 |
+
# FAISS docstore에서 문서 추출
|
| 220 |
+
if hasattr(vectorstore, "docstore") and hasattr(vectorstore.docstore, "_dict"):
|
| 221 |
+
docs = list(vectorstore.docstore._dict.values())
|
| 222 |
+
except Exception as e:
|
| 223 |
+
print(f"[경고] 저장된 문서를 읽는 중 문제가 발생했습니다: {e}")
|
| 224 |
+
|
| 225 |
+
# 4) 앙상블 리트리버 구성 (원래 코드와 동일)
|
| 226 |
+
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": top_k})
|
| 227 |
+
|
| 228 |
+
if docs:
|
| 229 |
+
bm25_retriever = BM25Retriever.from_documents(docs)
|
| 230 |
+
bm25_retriever.k = bm25_k
|
| 231 |
+
ensemble_retriever = EnsembleRetriever(
|
| 232 |
+
retrievers=[vector_retriever, bm25_retriever],
|
| 233 |
+
weights=list(weights)
|
| 234 |
+
)
|
| 235 |
+
else:
|
| 236 |
+
print("[안내] 문서를 찾지 못해 BM25 없이 벡터 리트리버만 반환합니다.")
|
| 237 |
+
ensemble_retriever = vector_retriever
|
| 238 |
+
|
| 239 |
+
# 5) SQLite 연결
|
| 240 |
+
sqlite_conn = get_db_connection(persist_directory)
|
| 241 |
+
|
| 242 |
+
return ensemble_retriever, vectorstore, sqlite_conn # SQLite 연결 반환
|
| 243 |
+
|
| 244 |
+
# --- search_vectorstore (변경 없음) ---
|
| 245 |
+
|
| 246 |
+
def search_vectorstore(retriever, query, k=5):
|
| 247 |
+
"""리트리버를 사용해 쿼리와 관련된 문서를 검색합니다."""
|
| 248 |
+
results = retriever.invoke(query)
|
| 249 |
+
return results[:k]
|
| 250 |
+
|
| 251 |
+
# === search_with_metadata_filter (사전 필터링 버전) ===
|
| 252 |
+
def search_with_metadata_filter(
|
| 253 |
+
ensemble_retriever: EnsembleRetriever,
|
| 254 |
+
vectorstore: FAISS,
|
| 255 |
+
query: str,
|
| 256 |
+
k: int = 5,
|
| 257 |
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
| 258 |
+
sqlite_conn: Optional[sqlite3.Connection] = None,
|
| 259 |
+
exact_match: bool = True
|
| 260 |
+
) -> List[Document]:
|
| 261 |
+
"""
|
| 262 |
+
SQLite로 사전 필터링 → FAISS ID 추출 → IDSelector로 FAISS 검색 제한
|
| 263 |
+
→ BM25는 post-filtering (BM25는 IDSelector 미지원)
|
| 264 |
+
"""
|
| 265 |
+
vector_ret, bm25_ret = ensemble_retriever.retrievers
|
| 266 |
+
|
| 267 |
+
# === 1. SQLite에서 필터링된 FAISS ID 추출 ===
|
| 268 |
+
filtered_ids = None
|
| 269 |
+
if metadata_filter and sqlite_conn:
|
| 270 |
+
cursor = sqlite_conn.cursor()
|
| 271 |
+
where_clauses = []
|
| 272 |
+
params = []
|
| 273 |
+
|
| 274 |
+
for key, value in metadata_filter.items():
|
| 275 |
+
print(f"[key] {key}")
|
| 276 |
+
print(f"[value] {value}")
|
| 277 |
+
if isinstance(value, list):
|
| 278 |
+
# IN 쿼리: 리스트 값 지원
|
| 279 |
+
if not value:
|
| 280 |
+
continue # 빈 리스트면 무시
|
| 281 |
+
placeholders = ', '.join(['?'] * len(value))
|
| 282 |
+
where_clauses.append(f"{key} IN ({placeholders})")
|
| 283 |
+
params.extend(value)
|
| 284 |
+
else:
|
| 285 |
+
# 단일 값
|
| 286 |
+
where_clauses.append(f"{key} = ?")
|
| 287 |
+
params.append(value)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
if where_clauses:
|
| 291 |
+
where_sql = " OR ".join(where_clauses)
|
| 292 |
+
sql_query = f"SELECT faiss_id FROM documents WHERE {where_sql}"
|
| 293 |
+
|
| 294 |
+
try:
|
| 295 |
+
cursor.execute(sql_query, params)
|
| 296 |
+
filtered_ids = {row[0] for row in cursor.fetchall()}
|
| 297 |
+
print(f"[사전 필터링] {len(filtered_ids)}개 ID 획득 → FAISS 검색 제한")
|
| 298 |
+
except Exception as e:
|
| 299 |
+
print(f"[경고] SQLite 필터링 실패: {e}")
|
| 300 |
+
filtered_ids = None
|
| 301 |
+
else:
|
| 302 |
+
print("[안내] 필터 조건 없음 → 전체 검색")
|
| 303 |
+
else:
|
| 304 |
+
print("[안내] 필터 또는 DB 없음 → 전체 검색")
|
| 305 |
+
|
| 306 |
+
# === 2. FAISS 벡터 검색 (IDSelector 기반 사전 필터링) ===
|
| 307 |
+
if filtered_ids and len(filtered_ids) > 0:
|
| 308 |
+
# IDSelector 생성
|
| 309 |
+
selector = MetadataIDSelector(filtered_ids)
|
| 310 |
+
|
| 311 |
+
# FAISS 인덱스 추출
|
| 312 |
+
index: faiss.Index = vectorstore.index
|
| 313 |
+
if not hasattr(index, "search"):
|
| 314 |
+
raise ValueError("FAISS 인덱스가 검색을 지원하지 않습니다.")
|
| 315 |
+
|
| 316 |
+
# 쿼리 임베딩
|
| 317 |
+
query_embedding = np.array(vectorstore.embeddings.embed_query(query)).astype('float32')
|
| 318 |
+
query_embedding = query_embedding.reshape(1, -1)
|
| 319 |
+
|
| 320 |
+
# 검색 파라미터 설정
|
| 321 |
+
search_params = faiss.SearchParametersIVF(
|
| 322 |
+
sel=selector,
|
| 323 |
+
nprobe=50 # 필요시 조정 (성능 vs 재현율)
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# 여유 있게 k * 10개 후보 요청 (필터 후 부족 방지)
|
| 327 |
+
_k = max(k * 10, 100)
|
| 328 |
+
D, I = index.search(query_embedding, _k, params=search_params)
|
| 329 |
+
|
| 330 |
+
# 유효한 결과만 추출
|
| 331 |
+
valid_indices = [i for i in I[0] if i != -1]
|
| 332 |
+
vector_docs = []
|
| 333 |
+
for idx in valid_indices[:k]:
|
| 334 |
+
doc_id = vectorstore.index_to_docstore_id[idx]
|
| 335 |
+
doc = vectorstore.docstore.search(doc_id)
|
| 336 |
+
if isinstance(doc, Document):
|
| 337 |
+
vector_docs.append(doc)
|
| 338 |
+
|
| 339 |
+
print(f"[벡터 검색] {len(valid_indices)}개 후보 → {len(vector_docs)}개 유효")
|
| 340 |
+
else:
|
| 341 |
+
# 필터 없거나 실패 → 일반 검색 (기존 방식)
|
| 342 |
+
search_k = k * 5
|
| 343 |
+
vector_docs = vector_ret.invoke(query, config={"search_kwargs": {"k": search_k}})
|
| 344 |
+
print(f"[벡터 검색] 전체 검색 → {len(vector_docs)}개 후보")
|
| 345 |
+
|
| 346 |
+
# === 3. BM25 검색 (post-filtering, BM25는 IDSelector 미지원) ===
|
| 347 |
+
bm25_docs = []
|
| 348 |
+
if hasattr(bm25_ret, "invoke"):
|
| 349 |
+
search_k = k * 5
|
| 350 |
+
candidates = bm25_ret.invoke(query, config={"search_kwargs": {"k": search_k}})
|
| 351 |
+
if filtered_ids:
|
| 352 |
+
bm25_docs = [d for d in candidates if d.metadata.get('faiss_id') in filtered_ids]
|
| 353 |
+
else:
|
| 354 |
+
bm25_docs = candidates[:k]
|
| 355 |
+
print(f"[BM25 검색] {len(candidates)}개 후보 → {len(bm25_docs)}개 필터링 후")
|
| 356 |
+
|
| 357 |
+
# === 4. 병합 및 최종 k개 반환 ===
|
| 358 |
+
combined = {id(d): d for d in (vector_docs + bm25_docs)}.values()
|
| 359 |
+
final_results = list(combined)[:k]
|
| 360 |
+
|
| 361 |
+
print(f"[최종 결과] {len(final_results)}개 문서 반환")
|
| 362 |
+
return final_results
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def get_unique_metadata_values(
|
| 366 |
+
sqlite_conn: sqlite3.Connection,
|
| 367 |
+
key_name: str,
|
| 368 |
+
partial_match: Optional[str] = None
|
| 369 |
+
) -> List[str]:
|
| 370 |
+
"""
|
| 371 |
+
SQLite 'documents' 테이블에서 특정 컬럼(key_name)의 중복되지 않은
|
| 372 |
+
모든 고유 값 리스트를 반환합니다.
|
| 373 |
+
|
| 374 |
+
Args:
|
| 375 |
+
sqlite_conn: SQLite 데이터베이스 연결 객체.
|
| 376 |
+
key_name: 고유한 값을 가져올 컬럼 이름 (예: 'regulation_name', 'part_name').
|
| 377 |
+
partial_match: (선택 사항) 해당 문자열을 포함하는 값만 검색할 때 사용.
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
중복이 제거된 고유한 값들의 리스트.
|
| 381 |
+
"""
|
| 382 |
+
if not sqlite_conn:
|
| 383 |
+
print("[경고] SQLite 연결이 없어 고유 값 검색을 수행할 수 없습니다.")
|
| 384 |
+
return []
|
| 385 |
+
|
| 386 |
+
cursor = sqlite_conn.cursor()
|
| 387 |
+
|
| 388 |
+
# SQL 쿼리 구성
|
| 389 |
+
# 1. 컬럼 이름에 백틱(`)을 사용하여 안전성 확보
|
| 390 |
+
# 2. DISTINCT를 사용하여 중복 제거
|
| 391 |
+
|
| 392 |
+
sql_query = f"SELECT DISTINCT `{key_name}` FROM documents"
|
| 393 |
+
params = []
|
| 394 |
+
|
| 395 |
+
# 부분 문자열 검색 (LIKE) 조건 추가
|
| 396 |
+
if partial_match:
|
| 397 |
+
sql_query += f" WHERE `{key_name}` LIKE ?"
|
| 398 |
+
params.append(f"%{partial_match}%")
|
| 399 |
+
|
| 400 |
+
try:
|
| 401 |
+
cursor.execute(sql_query, params)
|
| 402 |
+
|
| 403 |
+
# 쿼리 결과에서 첫 번째 항목 (값)만 추출
|
| 404 |
+
unique_values = [row[0] for row in cursor.fetchall() if row[0] is not None]
|
| 405 |
+
|
| 406 |
+
return unique_values
|
| 407 |
+
|
| 408 |
+
except sqlite3.OperationalError as e:
|
| 409 |
+
# 컬럼 이름이 DB에 없을 때 발생하는 에러 처리
|
| 410 |
+
print(f"[에러] SQLite 쿼리 실행 실패 (컬럼 '{key_name}' 이름 오류 가능): {e}")
|
| 411 |
+
return []
|
| 412 |
+
except Exception as e:
|
| 413 |
+
print(f"[에러] 고유 값 검색 중 알 수 없는 오류 발생: {e}")
|
| 414 |
+
return []
|
| 415 |
+
|
| 416 |
+
def smart_search_vectorstore(
|
| 417 |
+
retriever,
|
| 418 |
+
query,
|
| 419 |
+
k=5,
|
| 420 |
+
vectorstore=None,
|
| 421 |
+
sqlite_conn=None,
|
| 422 |
+
enable_detailed_search=True
|
| 423 |
+
):
|
| 424 |
+
"""
|
| 425 |
+
리트리버를 사용해 쿼리와 관련된 문서를 검색하고,
|
| 426 |
+
선택적으로 가장 빈번한 regulation_part 카테고리에 대해 상세 검색을 수행합니다.
|
| 427 |
+
|
| 428 |
+
Args:
|
| 429 |
+
retriever: 기본 검색에 사용할 리트리버
|
| 430 |
+
query: 검색 쿼리
|
| 431 |
+
k: 반환할 문서 수
|
| 432 |
+
vectorstore: FAISS 벡터스토어 (상세 검색용)
|
| 433 |
+
sqlite_conn: SQLite 연결 (상세 검색용)
|
| 434 |
+
enable_detailed_search: 상세 검색 활성화 여부
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
검색된 문서 리스트 (기본 검색 + 상세 검색 결과)
|
| 438 |
+
"""
|
| 439 |
+
# 1. 기본 검색 수행
|
| 440 |
+
basic_results = retriever.invoke(query)
|
| 441 |
+
basic_results = basic_results[:k]
|
| 442 |
+
|
| 443 |
+
print(f"[기본 검색] {len(basic_results)}개 문서 검색 완료")
|
| 444 |
+
|
| 445 |
+
# 상세 검색이 비활성화되었거나 필요한 컴포넌트가 없으면 기본 결과만 반환
|
| 446 |
+
if not enable_detailed_search or not vectorstore or not sqlite_conn:
|
| 447 |
+
print("[안내] 상세 검색 비활성화 또는 컴포넌트 부족 → 기본 검색 결과만 반환")
|
| 448 |
+
return basic_results
|
| 449 |
+
|
| 450 |
+
# 2. regulation_part 메타데이터 빈도 분석
|
| 451 |
+
regulation_parts = []
|
| 452 |
+
for doc in basic_results:
|
| 453 |
+
reg_part = doc.metadata.get('regulation_part')
|
| 454 |
+
if reg_part:
|
| 455 |
+
# regulation_part가 리스트인 경우 모든 항목 추가
|
| 456 |
+
if isinstance(reg_part, list):
|
| 457 |
+
regulation_parts.extend(reg_part)
|
| 458 |
+
elif isinstance(reg_part, str):
|
| 459 |
+
# 쉼표로 구분된 문자열인 경우 분리
|
| 460 |
+
if ',' in reg_part:
|
| 461 |
+
regulation_parts.extend([part.strip() for part in reg_part.split(',')])
|
| 462 |
+
else:
|
| 463 |
+
regulation_parts.append(reg_part)
|
| 464 |
+
|
| 465 |
+
# 빈도 계산 및 상위 카테고리 추출
|
| 466 |
+
if not regulation_parts:
|
| 467 |
+
print("[안내] regulation_part 메타데이터 없음 → 기본 검색 결과만 반환")
|
| 468 |
+
return basic_results
|
| 469 |
+
|
| 470 |
+
counter = Counter(regulation_parts)
|
| 471 |
+
most_extracted_category = counter.most_common(2) # 상위 2개 카테고리
|
| 472 |
+
|
| 473 |
+
print(f"[빈도 분석] regulation_part 빈도: {dict(counter)}")
|
| 474 |
+
print(f"[상위 카테고리] {most_extracted_category}")
|
| 475 |
+
|
| 476 |
+
# 3. 상위 카테고리에 대한 상세 검색 수행
|
| 477 |
+
detailed_results = []
|
| 478 |
+
|
| 479 |
+
for rank, (category, count) in enumerate(most_extracted_category, 1):
|
| 480 |
+
print(f"[상세 검색 {rank}순위] '{category}' 카테고리 검색 시작 (빈도: {count})")
|
| 481 |
+
|
| 482 |
+
# metadata_filter 구성
|
| 483 |
+
metadata_filter = {'regulation_part': category}
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
# search_with_metadata_filter 호출
|
| 487 |
+
category_results = search_with_metadata_filter(
|
| 488 |
+
ensemble_retriever=retriever,
|
| 489 |
+
vectorstore=vectorstore,
|
| 490 |
+
query=query,
|
| 491 |
+
k=k,
|
| 492 |
+
metadata_filter=metadata_filter,
|
| 493 |
+
sqlite_conn=sqlite_conn
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
detailed_results.extend(category_results)
|
| 497 |
+
print(f"[상세 검색 {rank}순위] {len(category_results)}개 추가 문서 검색 완료")
|
| 498 |
+
|
| 499 |
+
except Exception as e:
|
| 500 |
+
print(f"[경고] 상세 검색 {rank}순위 실패 ({category}): {e}")
|
| 501 |
+
continue
|
| 502 |
+
|
| 503 |
+
# 4. 결과 병합 (중복 제거)
|
| 504 |
+
# Document 객체의 고유성을 위해 page_content와 metadata의 조합으로 중복 판단
|
| 505 |
+
seen = set()
|
| 506 |
+
final_results = []
|
| 507 |
+
|
| 508 |
+
# 기본 검색 결과 우선 추가
|
| 509 |
+
for doc in basic_results:
|
| 510 |
+
doc_signature = (doc.page_content, str(sorted(doc.metadata.items())))
|
| 511 |
+
if doc_signature not in seen:
|
| 512 |
+
seen.add(doc_signature)
|
| 513 |
+
final_results.append(doc)
|
| 514 |
+
|
| 515 |
+
# 상세 검색 결과 추가 (중복 제거)
|
| 516 |
+
for doc in detailed_results:
|
| 517 |
+
doc_signature = (doc.page_content, str(sorted(doc.metadata.items())))
|
| 518 |
+
if doc_signature not in seen:
|
| 519 |
+
seen.add(doc_signature)
|
| 520 |
+
final_results.append(doc)
|
| 521 |
+
|
| 522 |
+
# 최종 k개로 제한
|
| 523 |
+
final_results = final_results[:k]
|
| 524 |
+
|
| 525 |
+
print(f"[최종 결과] 기본 {len(basic_results)}개 + 상세 {len(detailed_results)}개 → 중복 제거 후 {len(final_results)}개 반환")
|
| 526 |
+
|
| 527 |
+
return final_results
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.0.3
|
| 2 |
+
Flask-SocketIO==5.3.6
|
| 3 |
+
eventlet==0.36.1
|
| 4 |
+
gunicorn==22.0.0
|
| 5 |
+
|
| 6 |
+
# Together AI
|
| 7 |
+
together==1.2.1
|
| 8 |
+
|
| 9 |
+
# LangChain & RAG
|
| 10 |
+
langchain==0.1.20
|
| 11 |
+
langchain-community==0.0.38
|
| 12 |
+
faiss-cpu==1.8.0
|
| 13 |
+
rank-bm25==0.2.2
|
| 14 |
+
|
| 15 |
+
# Embedding
|
| 16 |
+
sentence-transformers==3.0.1
|
| 17 |
+
transformers==4.40.0
|
| 18 |
+
torch==2.3.0
|
| 19 |
+
|
| 20 |
+
numpy==1.26.4
|
templates/chat.html
ADDED
|
@@ -0,0 +1,1243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>LexiMind - 자동차 인증 법규를 더 쉽게</title>
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 8 |
+
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
| 9 |
+
<style>
|
| 10 |
+
:root {
|
| 11 |
+
--primary-color: #0066FF;
|
| 12 |
+
--primary-hover: #4da8ff;
|
| 13 |
+
--bg-primary: #1a1a1a;
|
| 14 |
+
--bg-secondary: #2c2c2c;
|
| 15 |
+
--bg-tertiary: #333;
|
| 16 |
+
--bg-quaternary: #444;
|
| 17 |
+
--text-primary: #e0e0e0;
|
| 18 |
+
--text-secondary: #b0b0b0;
|
| 19 |
+
--text-muted: #888;
|
| 20 |
+
--border-color: #444;
|
| 21 |
+
--shadow: 0 2px 8px rgba(0,0,0,0.2);
|
| 22 |
+
--radius: 8px;
|
| 23 |
+
--success-color: #4caf50;
|
| 24 |
+
--warning-color: #ff9800;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
* { box-sizing: border-box; }
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
|
| 31 |
+
margin: 0; padding: 0;
|
| 32 |
+
background-color: var(--bg-primary);
|
| 33 |
+
color: var(--text-primary);
|
| 34 |
+
line-height: 1.6;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* ===== 헤더 ===== */
|
| 38 |
+
.header {
|
| 39 |
+
background: var(--bg-secondary);
|
| 40 |
+
padding: 16px 24px;
|
| 41 |
+
box-shadow: var(--shadow);
|
| 42 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 43 |
+
position: sticky; top: 0; z-index: 100;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.logo {
|
| 47 |
+
font-size: 28px; font-weight: 700; color: var(--primary-color); letter-spacing: -0.5px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.nav {
|
| 51 |
+
display: flex; list-style: none; gap: 32px; margin: 0; padding: 0;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.nav a {
|
| 55 |
+
text-decoration: none; color: var(--text-secondary); font-weight: 500;
|
| 56 |
+
padding: 8px 12px; border-radius: var(--radius); transition: all 0.2s ease;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.nav a:hover {
|
| 60 |
+
color: var(--primary-hover); background: rgba(77, 168, 255, 0.1);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* ===== 사이드바 컨트롤 ===== */
|
| 64 |
+
.sidebar-toggle {
|
| 65 |
+
position: fixed;
|
| 66 |
+
top: 100px;
|
| 67 |
+
left: 30px;
|
| 68 |
+
width: 40px;
|
| 69 |
+
height: 40px;
|
| 70 |
+
background: rgba(0, 102, 255, 0.7);
|
| 71 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 72 |
+
border-radius: 50%;
|
| 73 |
+
cursor: pointer;
|
| 74 |
+
z-index: 1001;
|
| 75 |
+
transition: all 0.3s ease;
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
backdrop-filter: blur(10px);
|
| 80 |
+
box-shadow: 0 8px 32px rgba(0, 102, 255, 0.3);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.sidebar-toggle:hover {
|
| 84 |
+
background: rgba(77, 168, 255, 0.8);
|
| 85 |
+
transform: scale(1.05);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.hamburger {
|
| 89 |
+
width: 15px;
|
| 90 |
+
height: 2px;
|
| 91 |
+
background: white;
|
| 92 |
+
position: relative;
|
| 93 |
+
transition: all 0.3s ease;
|
| 94 |
+
transform: translateX(-7px);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.hamburger::before,
|
| 98 |
+
.hamburger::after {
|
| 99 |
+
content: '';
|
| 100 |
+
position: absolute;
|
| 101 |
+
width: 15px;
|
| 102 |
+
height: 2px;
|
| 103 |
+
background: white;
|
| 104 |
+
transition: all 0.3s ease;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.hamburger::before { top: -6px; }
|
| 108 |
+
.hamburger::after { top: 6px; }
|
| 109 |
+
|
| 110 |
+
.sidebar-toggle.active .hamburger { background: transparent; }
|
| 111 |
+
.sidebar-toggle.active .hamburger::before { transform: rotate(45deg); top: 0; }
|
| 112 |
+
.sidebar-toggle.active .hamburger::after { transform: rotate(-45deg); top: 0; }
|
| 113 |
+
|
| 114 |
+
.new-page-btn {
|
| 115 |
+
position: fixed;
|
| 116 |
+
top: 100px;
|
| 117 |
+
left: 85px;
|
| 118 |
+
min-width: 120px;
|
| 119 |
+
height: 40px;
|
| 120 |
+
padding: 8px 16px;
|
| 121 |
+
background: rgba(255, 255, 255, 1);
|
| 122 |
+
border: 2px solid rgba(0, 102, 255, 0.2);
|
| 123 |
+
border-radius: 12px;
|
| 124 |
+
cursor: pointer;
|
| 125 |
+
z-index: 1001;
|
| 126 |
+
transition: all 0.3s ease;
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: center;
|
| 129 |
+
justify-content: center;
|
| 130 |
+
gap: 8px;
|
| 131 |
+
opacity: 0;
|
| 132 |
+
visibility: hidden;
|
| 133 |
+
transform: translateX(-20px);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.new-page-btn.show {
|
| 137 |
+
opacity: 1;
|
| 138 |
+
visibility: visible;
|
| 139 |
+
transform: translateX(0);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.new-page-btn:hover {
|
| 143 |
+
background: rgba(255, 255, 255, 0.8);
|
| 144 |
+
transform: translateY(-2px);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.plus-icon {
|
| 148 |
+
color: #0066FF;
|
| 149 |
+
font-size: 16px;
|
| 150 |
+
font-weight: 700;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* ===== 사이드바 ===== */
|
| 154 |
+
.sidebar {
|
| 155 |
+
position: fixed; top: 76px; left: -300px; width: 300px;
|
| 156 |
+
height: calc(100vh - 76px); background: #2c2c2c;
|
| 157 |
+
transition: left 0.3s ease; z-index: 1000; overflow-y: auto;
|
| 158 |
+
}
|
| 159 |
+
.sidebar.open { left: 0; }
|
| 160 |
+
|
| 161 |
+
.sidebar-content {
|
| 162 |
+
padding: 75px 30px 30px 30px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.sidebar-overlay {
|
| 166 |
+
position: fixed; top: 76px; left: 0; width: 100%;
|
| 167 |
+
height: calc(100vh - 76px); background: rgba(0, 0, 0, 0.5);
|
| 168 |
+
opacity: 0; visibility: hidden; transition: all 0.3s ease; z-index: 999;
|
| 169 |
+
}
|
| 170 |
+
.sidebar-overlay.active { opacity: 1; visibility: visible; }
|
| 171 |
+
|
| 172 |
+
.date-ranges {
|
| 173 |
+
display: flex;
|
| 174 |
+
flex-direction: column;
|
| 175 |
+
gap: 12px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.date-range-item {
|
| 179 |
+
display: block;
|
| 180 |
+
padding: 16px;
|
| 181 |
+
background: var(--bg-tertiary);
|
| 182 |
+
border: 2px solid transparent;
|
| 183 |
+
border-radius: var(--radius);
|
| 184 |
+
text-decoration: none;
|
| 185 |
+
color: var(--text-primary);
|
| 186 |
+
transition: all 0.3s ease;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.date-range-item:hover {
|
| 190 |
+
background: var(--bg-quaternary);
|
| 191 |
+
border-color: var(--primary-color);
|
| 192 |
+
transform: translateY(-2px);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.date-period {
|
| 196 |
+
display: block;
|
| 197 |
+
font-weight: 600;
|
| 198 |
+
font-size: 14px;
|
| 199 |
+
color: var(--primary-color);
|
| 200 |
+
margin-bottom: 4px;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.date-count {
|
| 204 |
+
display: block;
|
| 205 |
+
font-size: 12px;
|
| 206 |
+
color: var(--text-muted);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* ===== 메인 레이아웃 ===== */
|
| 210 |
+
.main {
|
| 211 |
+
display: flex;
|
| 212 |
+
height: calc(100vh - 76px);
|
| 213 |
+
max-width: 100%;
|
| 214 |
+
margin: 0;
|
| 215 |
+
padding: 0;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/* ===== 필터 패널 ===== */
|
| 219 |
+
.filter-panel {
|
| 220 |
+
width: 350px;
|
| 221 |
+
background: var(--bg-secondary);
|
| 222 |
+
border-right: 1px solid var(--border-color);
|
| 223 |
+
display: flex;
|
| 224 |
+
flex-direction: column;
|
| 225 |
+
overflow: hidden;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.filter-header {
|
| 229 |
+
padding: 100px 20px 20px 20px;
|
| 230 |
+
border-bottom: 1px solid var(--border-color);
|
| 231 |
+
background: var(--bg-tertiary);
|
| 232 |
+
}
|
| 233 |
+
.filter-title {
|
| 234 |
+
font-size: 20px;
|
| 235 |
+
font-weight: 700;
|
| 236 |
+
color: var(--text-primary);
|
| 237 |
+
margin: 0 0 16px 0;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/* 지역 선택 토글 */
|
| 241 |
+
.toggle-group { margin-bottom: 24px; }
|
| 242 |
+
.toggle-label {
|
| 243 |
+
display: block;
|
| 244 |
+
font-size: 14px;
|
| 245 |
+
font-weight: 600;
|
| 246 |
+
color: var(--text-secondary);
|
| 247 |
+
margin-bottom: 12px;
|
| 248 |
+
}
|
| 249 |
+
.toggle-options {
|
| 250 |
+
display: flex;
|
| 251 |
+
gap: 8px;
|
| 252 |
+
flex-wrap: wrap;
|
| 253 |
+
}
|
| 254 |
+
.toggle-btn {
|
| 255 |
+
padding: 8px 16px;
|
| 256 |
+
background: var(--bg-quaternary);
|
| 257 |
+
border: 2px solid transparent;
|
| 258 |
+
border-radius: 20px;
|
| 259 |
+
color: var(--text-secondary);
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
transition: all 0.2s ease;
|
| 262 |
+
font-size: 13px;
|
| 263 |
+
font-weight: 500;
|
| 264 |
+
}
|
| 265 |
+
.toggle-btn:hover {
|
| 266 |
+
background: #3a3a3a;
|
| 267 |
+
color: var(--text-primary);
|
| 268 |
+
}
|
| 269 |
+
.toggle-btn.active {
|
| 270 |
+
background: var(--primary-color);
|
| 271 |
+
border-color: var(--primary-hover);
|
| 272 |
+
color: white;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* 법규 목록 스타일 */
|
| 276 |
+
.regulation-list-container {
|
| 277 |
+
border-bottom: 1px solid var(--border-color);
|
| 278 |
+
padding: 24px;
|
| 279 |
+
max-height: 250px;
|
| 280 |
+
overflow-y: auto;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.regulation-header {
|
| 284 |
+
display: flex;
|
| 285 |
+
justify-content: space-between;
|
| 286 |
+
align-items: center;
|
| 287 |
+
margin-bottom: 12px;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.selected-count {
|
| 291 |
+
font-size: 12px;
|
| 292 |
+
color: var(--primary-color);
|
| 293 |
+
background: rgba(77, 168, 255, 0.1);
|
| 294 |
+
padding: 4px 8px;
|
| 295 |
+
border-radius: 4px;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.regulation-item {
|
| 299 |
+
padding: 12px;
|
| 300 |
+
margin: 8px 0;
|
| 301 |
+
background: var(--bg-quaternary);
|
| 302 |
+
border-radius: var(--radius);
|
| 303 |
+
cursor: pointer;
|
| 304 |
+
transition: all 0.2s ease;
|
| 305 |
+
border: 2px solid transparent;
|
| 306 |
+
user-select: none;
|
| 307 |
+
}
|
| 308 |
+
.regulation-item:hover {
|
| 309 |
+
background: #3a3a3a;
|
| 310 |
+
border-color: var(--primary-color);
|
| 311 |
+
}
|
| 312 |
+
.regulation-item.selected {
|
| 313 |
+
background: var(--primary-color);
|
| 314 |
+
color: white;
|
| 315 |
+
border-color: var(--primary-hover);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/* ===== 상세 법규 리스트 ===== */
|
| 319 |
+
.regulation-details-container {
|
| 320 |
+
flex: 1;
|
| 321 |
+
display: flex;
|
| 322 |
+
flex-direction: column;
|
| 323 |
+
overflow: hidden;
|
| 324 |
+
}
|
| 325 |
+
.regulation-details-header {
|
| 326 |
+
display: flex;
|
| 327 |
+
justify-content: space-between;
|
| 328 |
+
align-items: center;
|
| 329 |
+
padding: 16px 24px;
|
| 330 |
+
border-bottom: 1px solid var(--border-color);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.btn {
|
| 334 |
+
padding: 8px 16px;
|
| 335 |
+
border: none;
|
| 336 |
+
border-radius: var(--radius);
|
| 337 |
+
background: var(--primary-color);
|
| 338 |
+
color: white;
|
| 339 |
+
cursor: pointer;
|
| 340 |
+
font-size: 13px;
|
| 341 |
+
font-weight: 600;
|
| 342 |
+
transition: all 0.2s ease;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.btn:hover {
|
| 346 |
+
background: var(--primary-hover);
|
| 347 |
+
transform: translateY(-1px);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.btn-small {
|
| 351 |
+
padding: 6px 12px;
|
| 352 |
+
font-size: 12px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.btn-load {
|
| 356 |
+
background: var(--success-color);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.btn-load:hover {
|
| 360 |
+
background: #45a049;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.btn-secondary {
|
| 364 |
+
background: var(--bg-quaternary);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.btn-secondary:hover {
|
| 368 |
+
background: #555;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.regulation-search {
|
| 372 |
+
padding: 12px 24px;
|
| 373 |
+
background: var(--bg-tertiary);
|
| 374 |
+
border-bottom: 1px solid var(--border-color);
|
| 375 |
+
}
|
| 376 |
+
.regulation-search-input {
|
| 377 |
+
width: 100%;
|
| 378 |
+
padding: 8px 12px;
|
| 379 |
+
border: 1px solid var(--border-color);
|
| 380 |
+
border-radius: var(--radius);
|
| 381 |
+
background: var(--bg-quaternary);
|
| 382 |
+
color: var(--text-primary);
|
| 383 |
+
font-size: 13px;
|
| 384 |
+
transition: border-color 0.2s ease;
|
| 385 |
+
}
|
| 386 |
+
.regulation-search-input:focus {
|
| 387 |
+
outline: none;
|
| 388 |
+
border-color: var(--primary-color);
|
| 389 |
+
}
|
| 390 |
+
.regulation-search-input::placeholder {
|
| 391 |
+
color: var(--text-muted);
|
| 392 |
+
}
|
| 393 |
+
.regulation-details-list {
|
| 394 |
+
flex: 1;
|
| 395 |
+
overflow-y: auto;
|
| 396 |
+
padding: 12px 24px;
|
| 397 |
+
}
|
| 398 |
+
.regulation-detail-item {
|
| 399 |
+
padding: 10px 12px;
|
| 400 |
+
margin: 6px 0;
|
| 401 |
+
background: var(--bg-quaternary);
|
| 402 |
+
border-radius: var(--radius);
|
| 403 |
+
cursor: pointer;
|
| 404 |
+
transition: all 0.2s ease;
|
| 405 |
+
border: 2px solid transparent;
|
| 406 |
+
user-select: none;
|
| 407 |
+
font-size: 13px;
|
| 408 |
+
line-height: 1.4;
|
| 409 |
+
}
|
| 410 |
+
.regulation-detail-item:hover {
|
| 411 |
+
background: #3a3a3a;
|
| 412 |
+
border-color: var(--primary-color);
|
| 413 |
+
}
|
| 414 |
+
.regulation-detail-item.selected {
|
| 415 |
+
background: var(--primary-color);
|
| 416 |
+
color: white;
|
| 417 |
+
border-color: var(--primary-hover);
|
| 418 |
+
}
|
| 419 |
+
.regulation-detail-item.hidden {
|
| 420 |
+
display: none;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.action-buttons {
|
| 424 |
+
display: flex;
|
| 425 |
+
gap: 12px;
|
| 426 |
+
padding: 16px 24px;
|
| 427 |
+
border-top: 1px solid var(--border-color);
|
| 428 |
+
background: var(--bg-secondary);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/* ===== 채팅 패널 ===== */
|
| 432 |
+
.chat-panel {
|
| 433 |
+
flex: 1;
|
| 434 |
+
display: flex;
|
| 435 |
+
flex-direction: column;
|
| 436 |
+
background: var(--bg-primary);
|
| 437 |
+
}
|
| 438 |
+
.chat-header {
|
| 439 |
+
padding: 20px 24px;
|
| 440 |
+
background: var(--bg-secondary);
|
| 441 |
+
border-bottom: 1px solid var(--border-color);
|
| 442 |
+
}
|
| 443 |
+
.chat-title {
|
| 444 |
+
font-size: 18px;
|
| 445 |
+
font-weight: 600;
|
| 446 |
+
color: var(--text-primary);
|
| 447 |
+
margin: 0;
|
| 448 |
+
display: flex;
|
| 449 |
+
align-items: baseline;
|
| 450 |
+
gap: 10px;
|
| 451 |
+
}
|
| 452 |
+
.title-subtitle {
|
| 453 |
+
font-size: 14px;
|
| 454 |
+
font-weight: 400;
|
| 455 |
+
color: var(--text-muted);
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.status-text {
|
| 459 |
+
margin-top: 8px;
|
| 460 |
+
padding: 8px 12px;
|
| 461 |
+
background: var(--bg-tertiary);
|
| 462 |
+
border-radius: var(--radius);
|
| 463 |
+
font-size: 12px;
|
| 464 |
+
color: var(--primary-color);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.chat-messages {
|
| 468 |
+
flex: 1;
|
| 469 |
+
overflow-y: auto;
|
| 470 |
+
padding: 24px;
|
| 471 |
+
display: flex;
|
| 472 |
+
flex-direction: column;
|
| 473 |
+
gap: 16px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/* 메시지 버블 */
|
| 477 |
+
.message {
|
| 478 |
+
display: flex;
|
| 479 |
+
gap: 12px;
|
| 480 |
+
max-width: 80%;
|
| 481 |
+
animation: slideIn 0.3s ease;
|
| 482 |
+
}
|
| 483 |
+
@keyframes slideIn {
|
| 484 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 485 |
+
to { opacity: 1; transform: translateY(0); }
|
| 486 |
+
}
|
| 487 |
+
.message.user {
|
| 488 |
+
align-self: flex-end;
|
| 489 |
+
flex-direction: row-reverse;
|
| 490 |
+
}
|
| 491 |
+
.message.assistant {
|
| 492 |
+
align-self: flex-start;
|
| 493 |
+
}
|
| 494 |
+
.message-avatar {
|
| 495 |
+
width: 36px;
|
| 496 |
+
height: 36px;
|
| 497 |
+
border-radius: 50%;
|
| 498 |
+
background: var(--primary-color);
|
| 499 |
+
display: flex;
|
| 500 |
+
align-items: center;
|
| 501 |
+
justify-content: center;
|
| 502 |
+
color: white;
|
| 503 |
+
font-weight: 700;
|
| 504 |
+
font-size: 14px;
|
| 505 |
+
flex-shrink: 0;
|
| 506 |
+
}
|
| 507 |
+
.message.user .message-avatar {
|
| 508 |
+
background: var(--success-color);
|
| 509 |
+
}
|
| 510 |
+
.message-content {
|
| 511 |
+
background: var(--bg-secondary);
|
| 512 |
+
padding: 12px 16px;
|
| 513 |
+
border-radius: 16px;
|
| 514 |
+
color: var(--text-primary);
|
| 515 |
+
line-height: 1.5;
|
| 516 |
+
word-wrap: break-word;
|
| 517 |
+
}
|
| 518 |
+
.message.user .message-content {
|
| 519 |
+
background: var(--primary-color);
|
| 520 |
+
color: white;
|
| 521 |
+
}
|
| 522 |
+
.message-time {
|
| 523 |
+
font-size: 11px;
|
| 524 |
+
color: var(--text-muted);
|
| 525 |
+
margin-top: 4px;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
/* 메시지 콘텐츠 내 HTML 스타일링 */
|
| 529 |
+
.message-content h1,
|
| 530 |
+
.message-content h2 {
|
| 531 |
+
color: var(--primary-color);
|
| 532 |
+
margin: 12px 0 8px 0;
|
| 533 |
+
font-weight: 600;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.message-content h1 { font-size: 18px; }
|
| 537 |
+
.message-content h2 {
|
| 538 |
+
font-size: 16px;
|
| 539 |
+
border-bottom: 1px solid var(--border-color);
|
| 540 |
+
padding-bottom: 4px;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.message-content ul,
|
| 544 |
+
.message-content ol {
|
| 545 |
+
margin: 8px 0;
|
| 546 |
+
padding-left: 20px;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.message-content li {
|
| 550 |
+
margin: 4px 0;
|
| 551 |
+
line-height: 1.4;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.message-content strong {
|
| 555 |
+
color: var(--primary-color);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.message-content p {
|
| 559 |
+
margin: 8px 0;
|
| 560 |
+
line-height: 1.5;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.message.user .message-content h1,
|
| 564 |
+
.message.user .message-content h2,
|
| 565 |
+
.message.user .message-content strong {
|
| 566 |
+
color: white;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
/* 채팅 입력 영역 */
|
| 570 |
+
.chat-input-container {
|
| 571 |
+
padding: 20px 24px;
|
| 572 |
+
background: var(--bg-secondary);
|
| 573 |
+
border-top: 1px solid var(--border-color);
|
| 574 |
+
}
|
| 575 |
+
.chat-input-wrapper {
|
| 576 |
+
display: flex;
|
| 577 |
+
gap: 12px;
|
| 578 |
+
align-items: flex-end;
|
| 579 |
+
}
|
| 580 |
+
.chat-input {
|
| 581 |
+
flex: 1;
|
| 582 |
+
padding: 12px 16px;
|
| 583 |
+
background: var(--bg-tertiary);
|
| 584 |
+
border: 2px solid var(--border-color);
|
| 585 |
+
border-radius: 24px;
|
| 586 |
+
color: var(--text-primary);
|
| 587 |
+
font-size: 14px;
|
| 588 |
+
resize: none;
|
| 589 |
+
max-height: 120px;
|
| 590 |
+
min-height: 44px;
|
| 591 |
+
font-family: inherit;
|
| 592 |
+
transition: border-color 0.2s ease;
|
| 593 |
+
}
|
| 594 |
+
.chat-input:focus {
|
| 595 |
+
outline: none;
|
| 596 |
+
border-color: var(--primary-color);
|
| 597 |
+
}
|
| 598 |
+
.chat-input::placeholder {
|
| 599 |
+
color: var(--text-muted);
|
| 600 |
+
}
|
| 601 |
+
.send-btn {
|
| 602 |
+
width: 44px;
|
| 603 |
+
height: 44px;
|
| 604 |
+
border-radius: 50%;
|
| 605 |
+
background: var(--primary-color);
|
| 606 |
+
border: none;
|
| 607 |
+
color: white;
|
| 608 |
+
cursor: pointer;
|
| 609 |
+
display: flex;
|
| 610 |
+
align-items: center;
|
| 611 |
+
justify-content: center;
|
| 612 |
+
transition: all 0.2s ease;
|
| 613 |
+
flex-shrink: 0;
|
| 614 |
+
}
|
| 615 |
+
.send-btn:hover {
|
| 616 |
+
background: var(--primary-hover);
|
| 617 |
+
transform: scale(1.05);
|
| 618 |
+
}
|
| 619 |
+
.send-btn:active {
|
| 620 |
+
transform: scale(0.95);
|
| 621 |
+
}
|
| 622 |
+
.send-btn:disabled {
|
| 623 |
+
background: var(--bg-quaternary);
|
| 624 |
+
cursor: not-allowed;
|
| 625 |
+
transform: none;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
/* 로딩 인디케이터 */
|
| 629 |
+
.typing-indicator {
|
| 630 |
+
display: flex;
|
| 631 |
+
gap: 4px;
|
| 632 |
+
padding: 12px 16px;
|
| 633 |
+
background: var(--bg-secondary);
|
| 634 |
+
border-radius: 16px;
|
| 635 |
+
width: fit-content;
|
| 636 |
+
}
|
| 637 |
+
.typing-dot {
|
| 638 |
+
width: 8px;
|
| 639 |
+
height: 8px;
|
| 640 |
+
border-radius: 50%;
|
| 641 |
+
background: var(--text-muted);
|
| 642 |
+
animation: typing 1.4s infinite;
|
| 643 |
+
}
|
| 644 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 645 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 646 |
+
|
| 647 |
+
@keyframes typing {
|
| 648 |
+
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
| 649 |
+
30% { opacity: 1; transform: translateY(-10px); }
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
/* 스크롤바 스타일 */
|
| 653 |
+
.chat-messages::-webkit-scrollbar,
|
| 654 |
+
.filter-panel::-webkit-scrollbar,
|
| 655 |
+
.regulation-list-container::-webkit-scrollbar,
|
| 656 |
+
.regulation-details-list::-webkit-scrollbar {
|
| 657 |
+
width: 8px;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.chat-messages::-webkit-scrollbar-track,
|
| 661 |
+
.filter-panel::-webkit-scrollbar-track,
|
| 662 |
+
.regulation-list-container::-webkit-scrollbar-track,
|
| 663 |
+
.regulation-details-list::-webkit-scrollbar-track {
|
| 664 |
+
background: var(--bg-tertiary);
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.chat-messages::-webkit-scrollbar-thumb,
|
| 668 |
+
.filter-panel::-webkit-scrollbar-thumb,
|
| 669 |
+
.regulation-list-container::-webkit-scrollbar-thumb,
|
| 670 |
+
.regulation-details-list::-webkit-scrollbar-thumb {
|
| 671 |
+
background: var(--bg-quaternary);
|
| 672 |
+
border-radius: 4px;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.chat-messages::-webkit-scrollbar-thumb:hover,
|
| 676 |
+
.filter-panel::-webkit-scrollbar-thumb:hover,
|
| 677 |
+
.regulation-list-container::-webkit-scrollbar-thumb:hover,
|
| 678 |
+
.regulation-details-list::-webkit-scrollbar-thumb:hover {
|
| 679 |
+
background: #555;
|
| 680 |
+
}
|
| 681 |
+
</style>
|
| 682 |
+
</head>
|
| 683 |
+
|
| 684 |
+
<body>
|
| 685 |
+
<header class="header">
|
| 686 |
+
<div class="logo">LexiMind</div>
|
| 687 |
+
<nav>
|
| 688 |
+
<ul class="nav">
|
| 689 |
+
<li><a href="#">법규 검색</a></li>
|
| 690 |
+
<li><a href="#">체크리스트 생성</a></li>
|
| 691 |
+
<li><a href="#">유권해석</a></li>
|
| 692 |
+
<li><a href="#">사용문의</a></li>
|
| 693 |
+
</ul>
|
| 694 |
+
</nav>
|
| 695 |
+
</header>
|
| 696 |
+
|
| 697 |
+
<!-- 햄버거 버튼 -->
|
| 698 |
+
<button class="sidebar-toggle" id="sidebarToggle">
|
| 699 |
+
<span class="hamburger"></span>
|
| 700 |
+
</button>
|
| 701 |
+
|
| 702 |
+
<!-- 새 페이지 버튼 -->
|
| 703 |
+
<button class="new-page-btn" id="newPageBtn">
|
| 704 |
+
<span class="plus-icon">+ 새 대화 생성</span>
|
| 705 |
+
</button>
|
| 706 |
+
|
| 707 |
+
<!-- 사이드바 오버레이 -->
|
| 708 |
+
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
| 709 |
+
|
| 710 |
+
<!-- 사이드바 -->
|
| 711 |
+
<nav class="sidebar" id="sidebar">
|
| 712 |
+
<div class="sidebar-content">
|
| 713 |
+
<h3>과거 이력</h3>
|
| 714 |
+
<div class="date-ranges">
|
| 715 |
+
<a href="/history/202511" class="date-range-item">
|
| 716 |
+
<span class="date-period">2025.11.01 ~ 2025.11.30</span>
|
| 717 |
+
<span class="date-count">검색 15건</span>
|
| 718 |
+
</a>
|
| 719 |
+
<a href="/history/202510" class="date-range-item">
|
| 720 |
+
<span class="date-period">2025.10.01 ~ 2025.10.31</span>
|
| 721 |
+
<span class="date-count">검색 8건</span>
|
| 722 |
+
</a>
|
| 723 |
+
<a href="/history/202509" class="date-range-item">
|
| 724 |
+
<span class="date-period">2025.09.01 ~ 2025.09.30</span>
|
| 725 |
+
<span class="date-count">검색 12건</span>
|
| 726 |
+
</a>
|
| 727 |
+
</div>
|
| 728 |
+
</div>
|
| 729 |
+
</nav>
|
| 730 |
+
|
| 731 |
+
<main class="main">
|
| 732 |
+
<!-- 필터 패널 -->
|
| 733 |
+
<aside class="filter-panel">
|
| 734 |
+
<div class="filter-header">
|
| 735 |
+
<h2 class="filter-title">검색 필터</h2>
|
| 736 |
+
<div class="toggle-group">
|
| 737 |
+
<span class="toggle-label">지역</span>
|
| 738 |
+
<div class="toggle-options">
|
| 739 |
+
<button class="toggle-btn region-toggle" data-value="국내">국내</button>
|
| 740 |
+
<button class="toggle-btn region-toggle" data-value="북미">북미</button>
|
| 741 |
+
<button class="toggle-btn region-toggle" data-value="유럽">유럽</button>
|
| 742 |
+
<button class="toggle-btn region-toggle active" data-value="전체">전체</button>
|
| 743 |
+
</div>
|
| 744 |
+
</div>
|
| 745 |
+
</div>
|
| 746 |
+
|
| 747 |
+
<!-- 상세 법규 리스트 -->
|
| 748 |
+
<div class="regulation-details-container">
|
| 749 |
+
<div class="regulation-details-header">
|
| 750 |
+
<span class="toggle-label">상세 법규 리스트</span>
|
| 751 |
+
<button class="btn btn-load btn-small" onclick="loadDetailedRegulationList()">리스트 불러오기</button>
|
| 752 |
+
</div>
|
| 753 |
+
<div class="regulation-search" id="regulationSearchContainer" style="display: none;">
|
| 754 |
+
<input type="text" id="regulationSearchInput" class="regulation-search-input" placeholder="법규 검색...">
|
| 755 |
+
</div>
|
| 756 |
+
<div class="regulation-details-list" id="regulationDetailsList">
|
| 757 |
+
<p style="color: var(--text-muted); padding: 20px; text-align: center; margin: 0;">
|
| 758 |
+
리스트 불러오기를 눌러 상세 법규를 확인하세요.
|
| 759 |
+
</p>
|
| 760 |
+
</div>
|
| 761 |
+
</div>
|
| 762 |
+
|
| 763 |
+
<div class="action-buttons">
|
| 764 |
+
<button class="btn btn-secondary" onclick="clearAllSelections()">선택 초기화</button>
|
| 765 |
+
</div>
|
| 766 |
+
</aside>
|
| 767 |
+
|
| 768 |
+
<!-- 채팅 패널 -->
|
| 769 |
+
<section class="chat-panel">
|
| 770 |
+
<div class="chat-header">
|
| 771 |
+
<h1 class="chat-title">자동차 인증 법규 검색
|
| 772 |
+
<span class="title-subtitle">  관련 법규 내용을 검색으로 편리하게 찾아보세요! ※ 모든 결과물은 AI에 의해 생성된 것으로 오류가 있을 수 있습니다</span>
|
| 773 |
+
</h1>
|
| 774 |
+
<div id="statusText" class="status-text">시스템 준비 중...</div>
|
| 775 |
+
</div>
|
| 776 |
+
|
| 777 |
+
<div class="chat-messages" id="chatMessages">
|
| 778 |
+
<div class="message assistant">
|
| 779 |
+
<div class="message-avatar">Lexi</div>
|
| 780 |
+
<div>
|
| 781 |
+
<div class="message-content">
|
| 782 |
+
안녕하세요! 자동차 인증 법규 검색 도우미입니다.<br>
|
| 783 |
+
궁금하신 법규 내용을 검색해보세요.<br><br>
|
| 784 |
+
(ex : ISA의 작동 요건을 알려주고, 표로 정리해줘)
|
| 785 |
+
</div>
|
| 786 |
+
<div class="message-time" id="welcomeTime"></div>
|
| 787 |
+
</div>
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
|
| 791 |
+
<!-- 채팅 입력 영역 -->
|
| 792 |
+
<div class="chat-input-container">
|
| 793 |
+
<div class="chat-input-wrapper">
|
| 794 |
+
<textarea
|
| 795 |
+
id="chatInput"
|
| 796 |
+
class="chat-input"
|
| 797 |
+
placeholder="검색하고 싶은 내용을 입력하세요..."
|
| 798 |
+
rows="1"
|
| 799 |
+
></textarea>
|
| 800 |
+
<button class="send-btn" id="sendBtn" title="전송">
|
| 801 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 802 |
+
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
|
| 803 |
+
</svg>
|
| 804 |
+
</button>
|
| 805 |
+
</div>
|
| 806 |
+
</div>
|
| 807 |
+
</section>
|
| 808 |
+
</main>
|
| 809 |
+
|
| 810 |
+
<script>
|
| 811 |
+
// ===== 전역 변수 =====
|
| 812 |
+
let selectedRegulations = [];
|
| 813 |
+
let selectedRegions = [];
|
| 814 |
+
let socket;
|
| 815 |
+
|
| 816 |
+
// ===== 초기화 =====
|
| 817 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 818 |
+
initializeSocket();
|
| 819 |
+
initializeSidebar();
|
| 820 |
+
initializeToggles();
|
| 821 |
+
initializeChat();
|
| 822 |
+
initializeRegulationList();
|
| 823 |
+
setWelcomeTime();
|
| 824 |
+
updateSelectedCount();
|
| 825 |
+
});
|
| 826 |
+
|
| 827 |
+
// 환영 메시지 시간 설정
|
| 828 |
+
function setWelcomeTime() {
|
| 829 |
+
const timeElement = document.getElementById('welcomeTime');
|
| 830 |
+
if (timeElement) {
|
| 831 |
+
const now = new Date();
|
| 832 |
+
timeElement.textContent = formatTime(now);
|
| 833 |
+
}
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
// 시간 포맷팅
|
| 837 |
+
function formatTime(date) {
|
| 838 |
+
const hours = date.getHours().toString().padStart(2, '0');
|
| 839 |
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
| 840 |
+
return `${hours}:${minutes}`;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
// ===== Socket.IO 초기화 =====
|
| 844 |
+
function initializeSocket() {
|
| 845 |
+
socket = io();
|
| 846 |
+
socket.on('message', function(data) {
|
| 847 |
+
document.getElementById('statusText').innerText = data.message;
|
| 848 |
+
});
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
// ===== 사이드바 초기화 =====
|
| 852 |
+
function initializeSidebar() {
|
| 853 |
+
const sidebarToggle = document.getElementById('sidebarToggle');
|
| 854 |
+
const sidebar = document.getElementById('sidebar');
|
| 855 |
+
const sidebarOverlay = document.getElementById('sidebarOverlay');
|
| 856 |
+
const newPageBtn = document.getElementById('newPageBtn');
|
| 857 |
+
|
| 858 |
+
sidebarToggle.addEventListener('click', function() {
|
| 859 |
+
const isOpen = sidebar.classList.contains('open');
|
| 860 |
+
if (isOpen) {
|
| 861 |
+
closeSidebar();
|
| 862 |
+
} else {
|
| 863 |
+
openSidebar();
|
| 864 |
+
}
|
| 865 |
+
});
|
| 866 |
+
|
| 867 |
+
sidebarOverlay.addEventListener('click', closeSidebar);
|
| 868 |
+
|
| 869 |
+
document.addEventListener('keydown', function(e) {
|
| 870 |
+
if (e.key === 'Escape') {
|
| 871 |
+
closeSidebar();
|
| 872 |
+
}
|
| 873 |
+
});
|
| 874 |
+
|
| 875 |
+
if (newPageBtn) {
|
| 876 |
+
newPageBtn.addEventListener('click', function() {
|
| 877 |
+
window.open('/', '_blank');
|
| 878 |
+
});
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
function openSidebar() {
|
| 883 |
+
const sidebar = document.getElementById('sidebar');
|
| 884 |
+
const sidebarToggle = document.getElementById('sidebarToggle');
|
| 885 |
+
const sidebarOverlay = document.getElementById('sidebarOverlay');
|
| 886 |
+
const newPageBtn = document.getElementById('newPageBtn');
|
| 887 |
+
|
| 888 |
+
sidebar.classList.add('open');
|
| 889 |
+
sidebarToggle.classList.add('active');
|
| 890 |
+
sidebarOverlay.classList.add('active');
|
| 891 |
+
if (newPageBtn) newPageBtn.classList.add('show');
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
function closeSidebar() {
|
| 895 |
+
const sidebar = document.getElementById('sidebar');
|
| 896 |
+
const sidebarToggle = document.getElementById('sidebarToggle');
|
| 897 |
+
const sidebarOverlay = document.getElementById('sidebarOverlay');
|
| 898 |
+
const newPageBtn = document.getElementById('newPageBtn');
|
| 899 |
+
|
| 900 |
+
sidebar.classList.remove('open');
|
| 901 |
+
sidebarToggle.classList.remove('active');
|
| 902 |
+
sidebarOverlay.classList.remove('active');
|
| 903 |
+
if (newPageBtn) newPageBtn.classList.remove('show');
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
// ===== 토글 버튼 초기화 =====
|
| 907 |
+
function initializeToggles() {
|
| 908 |
+
document.querySelectorAll('.region-toggle').forEach(btn => {
|
| 909 |
+
btn.addEventListener('click', function() {
|
| 910 |
+
handleRegionToggle(this);
|
| 911 |
+
});
|
| 912 |
+
});
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
function handleRegionToggle(clickedBtn) {
|
| 916 |
+
const value = clickedBtn.getAttribute('data-value');
|
| 917 |
+
const allButtons = document.querySelectorAll('.region-toggle');
|
| 918 |
+
|
| 919 |
+
if (value === '전체') {
|
| 920 |
+
// 전체 선택 - 다른 모든 버튼 비활성화
|
| 921 |
+
allButtons.forEach(btn => btn.classList.remove('active'));
|
| 922 |
+
clickedBtn.classList.add('active');
|
| 923 |
+
selectedRegions.length = 0;
|
| 924 |
+
} else {
|
| 925 |
+
// 개별 선택 - 전체 버튼 비활성화
|
| 926 |
+
const allBtn = document.querySelector('.region-toggle[data-value="전체"]');
|
| 927 |
+
if (allBtn) allBtn.classList.remove('active');
|
| 928 |
+
|
| 929 |
+
clickedBtn.classList.toggle('active');
|
| 930 |
+
|
| 931 |
+
if (clickedBtn.classList.contains('active')) {
|
| 932 |
+
if (!selectedRegions.includes(value)) {
|
| 933 |
+
selectedRegions.push(value);
|
| 934 |
+
}
|
| 935 |
+
} else {
|
| 936 |
+
selectedRegions = selectedRegions.filter(region => region !== value);
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
// 아무것도 선택되지 않았으면 전체 활성화
|
| 940 |
+
if (selectedRegions.length === 0 && allBtn) {
|
| 941 |
+
allBtn.classList.add('active');
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
console.log('선택된 지역:', selectedRegions);
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
function getSelectedRegions() {
|
| 949 |
+
return selectedRegions.length > 0 ? selectedRegions : [];
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
// ===== 법규 리스트 초기화 =====
|
| 953 |
+
function initializeRegulationList() {
|
| 954 |
+
const regulationListDiv = document.getElementById('regulationList');
|
| 955 |
+
regulationListDiv.addEventListener('click', function(e) {
|
| 956 |
+
const item = e.target.closest('.regulation-item');
|
| 957 |
+
if (item) {
|
| 958 |
+
toggleRegulationSelection(item);
|
| 959 |
+
}
|
| 960 |
+
});
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
function toggleRegulationSelection(element) {
|
| 964 |
+
const id = element.getAttribute('data-id');
|
| 965 |
+
if (selectedRegulations.includes(id)) {
|
| 966 |
+
selectedRegulations = selectedRegulations.filter(item => item !== id);
|
| 967 |
+
element.classList.remove('selected');
|
| 968 |
+
} else {
|
| 969 |
+
selectedRegulations.push(id);
|
| 970 |
+
element.classList.add('selected');
|
| 971 |
+
}
|
| 972 |
+
updateSelectedCount();
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
function updateSelectedCount() {
|
| 976 |
+
const countElement = document.getElementById('selectedCount');
|
| 977 |
+
if (countElement) {
|
| 978 |
+
countElement.textContent = `선택됨: ${selectedRegulations.length}`;
|
| 979 |
+
}
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
// ===== 채팅 초기화 =====
|
| 983 |
+
function initializeChat() {
|
| 984 |
+
const chatInput = document.getElementById('chatInput');
|
| 985 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 986 |
+
|
| 987 |
+
// 자동 높이 조절
|
| 988 |
+
chatInput.addEventListener('input', function() {
|
| 989 |
+
this.style.height = 'auto';
|
| 990 |
+
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
| 991 |
+
});
|
| 992 |
+
|
| 993 |
+
// Enter 키로 전송
|
| 994 |
+
chatInput.addEventListener('keydown', function(e) {
|
| 995 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 996 |
+
e.preventDefault();
|
| 997 |
+
sendMessage();
|
| 998 |
+
}
|
| 999 |
+
});
|
| 1000 |
+
|
| 1001 |
+
// 전송 버튼 클릭
|
| 1002 |
+
sendBtn.addEventListener('click', sendMessage);
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
// ===== 메시지 전송 =====
|
| 1006 |
+
function sendMessage() {
|
| 1007 |
+
const chatInput = document.getElementById('chatInput');
|
| 1008 |
+
const message = chatInput.value.trim();
|
| 1009 |
+
if (!message) return;
|
| 1010 |
+
|
| 1011 |
+
// 사용자 메시지 추가
|
| 1012 |
+
addMessage(message, 'user');
|
| 1013 |
+
|
| 1014 |
+
// 입력창 초기화
|
| 1015 |
+
chatInput.value = '';
|
| 1016 |
+
chatInput.style.height = 'auto';
|
| 1017 |
+
|
| 1018 |
+
// 로딩 표시
|
| 1019 |
+
showTypingIndicator();
|
| 1020 |
+
|
| 1021 |
+
// 서버에 요청
|
| 1022 |
+
const requestData = {
|
| 1023 |
+
query: message,
|
| 1024 |
+
regions: getSelectedRegions(),
|
| 1025 |
+
selectedRegulations: selectedRegulations.map(id => {
|
| 1026 |
+
const element = document.querySelector(`[data-id="${id}"]`);
|
| 1027 |
+
return {
|
| 1028 |
+
id: id,
|
| 1029 |
+
title: element ? element.getAttribute('data-title') || element.textContent.trim() : id
|
| 1030 |
+
};
|
| 1031 |
+
})
|
| 1032 |
+
};
|
| 1033 |
+
|
| 1034 |
+
fetch('/get_message', {
|
| 1035 |
+
method: 'POST',
|
| 1036 |
+
headers: {
|
| 1037 |
+
'Content-Type': 'application/json'
|
| 1038 |
+
},
|
| 1039 |
+
body: JSON.stringify(requestData)
|
| 1040 |
+
})
|
| 1041 |
+
.then(response => response.json())
|
| 1042 |
+
.then(data => {
|
| 1043 |
+
hideTypingIndicator();
|
| 1044 |
+
if (data.message) {
|
| 1045 |
+
addMessage(data.message, 'assistant');
|
| 1046 |
+
}
|
| 1047 |
+
})
|
| 1048 |
+
.catch(error => {
|
| 1049 |
+
console.error('검색 오류:', error);
|
| 1050 |
+
hideTypingIndicator();
|
| 1051 |
+
addMessage('죄송합니다. 검색 중 오류가 발생했습니다.', 'assistant');
|
| 1052 |
+
});
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
function addMessage(text, type) {
|
| 1056 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 1057 |
+
const messageDiv = document.createElement('div');
|
| 1058 |
+
messageDiv.className = `message ${type}`;
|
| 1059 |
+
|
| 1060 |
+
const avatar = document.createElement('div');
|
| 1061 |
+
avatar.className = 'message-avatar';
|
| 1062 |
+
avatar.textContent = type === 'user' ? 'Me' : 'Lexi';
|
| 1063 |
+
|
| 1064 |
+
const contentWrapper = document.createElement('div');
|
| 1065 |
+
const content = document.createElement('div');
|
| 1066 |
+
content.className = 'message-content';
|
| 1067 |
+
|
| 1068 |
+
if (type === 'assistant') {
|
| 1069 |
+
const decodedText = decodeHtmlEntities(text);
|
| 1070 |
+
content.innerHTML = decodedText;
|
| 1071 |
+
} else {
|
| 1072 |
+
content.textContent = text;
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
const time = document.createElement('div');
|
| 1076 |
+
time.className = 'message-time';
|
| 1077 |
+
time.textContent = formatTime(new Date());
|
| 1078 |
+
|
| 1079 |
+
contentWrapper.appendChild(content);
|
| 1080 |
+
contentWrapper.appendChild(time);
|
| 1081 |
+
messageDiv.appendChild(avatar);
|
| 1082 |
+
messageDiv.appendChild(contentWrapper);
|
| 1083 |
+
chatMessages.appendChild(messageDiv);
|
| 1084 |
+
|
| 1085 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
function decodeHtmlEntities(text) {
|
| 1089 |
+
const textarea = document.createElement('textarea');
|
| 1090 |
+
textarea.innerHTML = text;
|
| 1091 |
+
return textarea.value;
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
function showTypingIndicator() {
|
| 1095 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 1096 |
+
const indicator = document.createElement('div');
|
| 1097 |
+
indicator.className = 'message assistant';
|
| 1098 |
+
indicator.id = 'typingIndicator';
|
| 1099 |
+
|
| 1100 |
+
const avatar = document.createElement('div');
|
| 1101 |
+
avatar.className = 'message-avatar';
|
| 1102 |
+
avatar.textContent = 'Lexi';
|
| 1103 |
+
|
| 1104 |
+
const typing = document.createElement('div');
|
| 1105 |
+
typing.className = 'typing-indicator';
|
| 1106 |
+
typing.innerHTML = '<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>';
|
| 1107 |
+
|
| 1108 |
+
indicator.appendChild(avatar);
|
| 1109 |
+
indicator.appendChild(typing);
|
| 1110 |
+
chatMessages.appendChild(indicator);
|
| 1111 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
function hideTypingIndicator() {
|
| 1115 |
+
const indicator = document.getElementById('typingIndicator');
|
| 1116 |
+
if (indicator) {
|
| 1117 |
+
indicator.remove();
|
| 1118 |
+
}
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
// ===== 상세 법규 리스트 관련 함수 =====
|
| 1122 |
+
function loadDetailedRegulationList() {
|
| 1123 |
+
const selectedRegions = getSelectedRegions();
|
| 1124 |
+
const detailsListDiv = document.getElementById('regulationDetailsList');
|
| 1125 |
+
const searchContainer = document.getElementById('regulationSearchContainer');
|
| 1126 |
+
|
| 1127 |
+
detailsListDiv.innerHTML = '<p style="color: var(--text-muted); padding: 20px; text-align: center; margin: 0;">상세 법규 리스트 로딩 중...</p>';
|
| 1128 |
+
|
| 1129 |
+
fetch('/get_reg_list', {
|
| 1130 |
+
method: 'POST',
|
| 1131 |
+
headers: {
|
| 1132 |
+
'Content-Type': 'application/json'
|
| 1133 |
+
},
|
| 1134 |
+
body: JSON.stringify({ regions: selectedRegions })
|
| 1135 |
+
})
|
| 1136 |
+
.then(response => response.json())
|
| 1137 |
+
.then(data => {
|
| 1138 |
+
if (data.reg_list_part) {
|
| 1139 |
+
displayDetailedRegulationList(data.reg_list_part);
|
| 1140 |
+
searchContainer.style.display = 'block';
|
| 1141 |
+
initializeRegulationSearch();
|
| 1142 |
+
} else {
|
| 1143 |
+
detailsListDiv.innerHTML = '<p style="color: var(--text-muted); padding: 20px; text-align: center; margin: 0;">상세 법규 리스트를 불러올 수 없습니다.</p>';
|
| 1144 |
+
}
|
| 1145 |
+
})
|
| 1146 |
+
.catch(error => {
|
| 1147 |
+
console.error('상세 법규 리스트 로딩 오류:', error);
|
| 1148 |
+
detailsListDiv.innerHTML = '<p style="color: #ff6b6b; padding: 20px; text-align: center; margin: 0;">리스트 로딩 중 오류가 발생했습니다.</p>';
|
| 1149 |
+
});
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
function displayDetailedRegulationList(data) {
|
| 1153 |
+
const detailsListDiv = document.getElementById('regulationDetailsList');
|
| 1154 |
+
|
| 1155 |
+
let listHTML = '';
|
| 1156 |
+
|
| 1157 |
+
if (typeof data === 'string') {
|
| 1158 |
+
const lines = data.split('\n').filter(line => line.trim());
|
| 1159 |
+
lines.forEach((line, index) => {
|
| 1160 |
+
const regId = `detail_reg_${Date.now()}_${index}`;
|
| 1161 |
+
const isSelected = selectedRegulations.includes(regId);
|
| 1162 |
+
listHTML += `
|
| 1163 |
+
<div class="regulation-detail-item ${isSelected ? 'selected' : ''}"
|
| 1164 |
+
data-id="${regId}"
|
| 1165 |
+
data-title="${line.trim()}"
|
| 1166 |
+
data-search-text="${line.trim().toLowerCase()}">
|
| 1167 |
+
${line.trim()}
|
| 1168 |
+
</div>
|
| 1169 |
+
`;
|
| 1170 |
+
});
|
| 1171 |
+
} else if (Array.isArray(data)) {
|
| 1172 |
+
data.forEach((item, index) => {
|
| 1173 |
+
const regId = item.id || `detail_reg_${Date.now()}_${index}`;
|
| 1174 |
+
const title = item.title || item.name || item.toString();
|
| 1175 |
+
const isSelected = selectedRegulations.includes(regId);
|
| 1176 |
+
listHTML += `
|
| 1177 |
+
<div class="regulation-detail-item ${isSelected ? 'selected' : ''}"
|
| 1178 |
+
data-id="${regId}"
|
| 1179 |
+
data-title="${title}"
|
| 1180 |
+
data-search-text="${title.toLowerCase()}">
|
| 1181 |
+
${title}
|
| 1182 |
+
</div>
|
| 1183 |
+
`;
|
| 1184 |
+
});
|
| 1185 |
+
} else {
|
| 1186 |
+
listHTML = '<p style="color: var(--text-muted); padding: 20px; text-align: center; margin: 0;">상세 법규 데이터를 표시할 수 없습니다.</p>';
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
detailsListDiv.innerHTML = listHTML;
|
| 1190 |
+
updateSelectedCount();
|
| 1191 |
+
|
| 1192 |
+
// 상세 법규 리스트 클릭 이벤트 추가
|
| 1193 |
+
detailsListDiv.addEventListener('click', function(e) {
|
| 1194 |
+
const item = e.target.closest('.regulation-detail-item');
|
| 1195 |
+
if (item) {
|
| 1196 |
+
toggleRegulationSelection(item);
|
| 1197 |
+
}
|
| 1198 |
+
});
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
function initializeRegulationSearch() {
|
| 1202 |
+
const searchInput = document.getElementById('regulationSearchInput');
|
| 1203 |
+
|
| 1204 |
+
searchInput.addEventListener('input', function() {
|
| 1205 |
+
const searchTerm = this.value.toLowerCase().trim();
|
| 1206 |
+
filterRegulationList(searchTerm);
|
| 1207 |
+
});
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
function filterRegulationList(searchTerm) {
|
| 1211 |
+
const detailItems = document.querySelectorAll('.regulation-detail-item');
|
| 1212 |
+
|
| 1213 |
+
detailItems.forEach(item => {
|
| 1214 |
+
const searchText = item.getAttribute('data-search-text') || '';
|
| 1215 |
+
|
| 1216 |
+
if (searchTerm === '' || searchText.includes(searchTerm)) {
|
| 1217 |
+
item.classList.remove('hidden');
|
| 1218 |
+
} else {
|
| 1219 |
+
item.classList.add('hidden');
|
| 1220 |
+
}
|
| 1221 |
+
});
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
function clearAllSelections() {
|
| 1225 |
+
selectedRegulations = [];
|
| 1226 |
+
document.querySelectorAll('.regulation-item').forEach(item => {
|
| 1227 |
+
item.classList.remove('selected');
|
| 1228 |
+
});
|
| 1229 |
+
document.querySelectorAll('.regulation-detail-item').forEach(item => {
|
| 1230 |
+
item.classList.remove('selected');
|
| 1231 |
+
});
|
| 1232 |
+
updateSelectedCount();
|
| 1233 |
+
|
| 1234 |
+
// 검색창 초기화
|
| 1235 |
+
const searchInput = document.getElementById('regulationSearchInput');
|
| 1236 |
+
if (searchInput) {
|
| 1237 |
+
searchInput.value = '';
|
| 1238 |
+
filterRegulationList('');
|
| 1239 |
+
}
|
| 1240 |
+
}
|
| 1241 |
+
</script>
|
| 1242 |
+
</body>
|
| 1243 |
+
</html>
|
templates/toss_dark.html
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>트리니티2 - 자동차 인증 법규를 더 쉽게</title>
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 8 |
+
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
| 9 |
+
<style>
|
| 10 |
+
/* ... existing styles ... */
|
| 11 |
+
:root {
|
| 12 |
+
--primary-color: #4da8ff;
|
| 13 |
+
--primary-hover: #4d8cff;
|
| 14 |
+
--bg-primary: #1a1a1a;
|
| 15 |
+
--bg-secondary: #2c2c2c;
|
| 16 |
+
--bg-tertiary: #333;
|
| 17 |
+
--bg-quaternary: #444;
|
| 18 |
+
--text-primary: #e0e0e0;
|
| 19 |
+
--text-secondary: #b0b0b0;
|
| 20 |
+
--text-muted: #888;
|
| 21 |
+
--border-color: #444;
|
| 22 |
+
--shadow: 0 2px 8px rgba(0,0,0,0.2);
|
| 23 |
+
--radius: 8px;
|
| 24 |
+
--success-color: #4caf50;
|
| 25 |
+
--warning-color: #ff9800;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
* {
|
| 29 |
+
box-sizing: border-box;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
body {
|
| 33 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
|
| 34 |
+
margin: 0;
|
| 35 |
+
padding: 0;
|
| 36 |
+
background-color: var(--bg-primary);
|
| 37 |
+
color: var(--text-primary);
|
| 38 |
+
line-height: 1.6;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* ===== 헤더 ===== */
|
| 42 |
+
.header {
|
| 43 |
+
background: var(--bg-secondary);
|
| 44 |
+
padding: 16px 24px;
|
| 45 |
+
box-shadow: var(--shadow);
|
| 46 |
+
display: flex;
|
| 47 |
+
justify-content: space-between;
|
| 48 |
+
align-items: center;
|
| 49 |
+
position: sticky;
|
| 50 |
+
top: 0;
|
| 51 |
+
z-index: 100;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.logo {
|
| 55 |
+
font-size: 28px;
|
| 56 |
+
font-weight: 700;
|
| 57 |
+
color: var(--primary-color);
|
| 58 |
+
letter-spacing: -0.5px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.nav {
|
| 62 |
+
display: flex;
|
| 63 |
+
list-style: none;
|
| 64 |
+
gap: 32px;
|
| 65 |
+
margin: 0;
|
| 66 |
+
padding: 0;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.nav a {
|
| 70 |
+
text-decoration: none;
|
| 71 |
+
color: var(--text-secondary);
|
| 72 |
+
font-weight: 500;
|
| 73 |
+
padding: 8px 12px;
|
| 74 |
+
border-radius: var(--radius);
|
| 75 |
+
transition: all 0.2s ease;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.nav a:hover {
|
| 79 |
+
color: var(--primary-hover);
|
| 80 |
+
background: rgba(77, 168, 255, 0.1);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* ===== 메인 컨텐츠 ===== */
|
| 84 |
+
.main {
|
| 85 |
+
max-width: 1200px;
|
| 86 |
+
margin: 0 auto;
|
| 87 |
+
padding: 32px 24px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.hero {
|
| 91 |
+
text-align: center;
|
| 92 |
+
margin-bottom: 48px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.hero-title {
|
| 96 |
+
font-size: 42px;
|
| 97 |
+
font-weight: 700;
|
| 98 |
+
color: var(--text-primary);
|
| 99 |
+
margin-bottom: 12px;
|
| 100 |
+
letter-spacing: -1px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.hero-subtitle {
|
| 104 |
+
font-size: 20px;
|
| 105 |
+
color: var(--text-secondary);
|
| 106 |
+
margin: 0 0 24px 0;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.status-text {
|
| 110 |
+
padding: 12px 20px;
|
| 111 |
+
background: var(--bg-tertiary);
|
| 112 |
+
border-radius: var(--radius);
|
| 113 |
+
display: inline-block;
|
| 114 |
+
font-size: 14px;
|
| 115 |
+
color: var(--primary-color);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ===== 검색 섹션 ===== */
|
| 119 |
+
.search-section {
|
| 120 |
+
background: var(--bg-secondary);
|
| 121 |
+
border-radius: 16px;
|
| 122 |
+
padding: 32px;
|
| 123 |
+
box-shadow: var(--shadow);
|
| 124 |
+
margin-bottom: 32px;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.section-title {
|
| 128 |
+
font-size: 24px;
|
| 129 |
+
font-weight: 600;
|
| 130 |
+
margin-bottom: 24px;
|
| 131 |
+
color: var(--text-primary);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.checkbox-container {
|
| 135 |
+
display: flex;
|
| 136 |
+
gap: 24px;
|
| 137 |
+
flex-wrap: wrap;
|
| 138 |
+
margin-bottom: 24px;
|
| 139 |
+
align-items: center;
|
| 140 |
+
padding: 16px;
|
| 141 |
+
background: var(--bg-tertiary);
|
| 142 |
+
border-radius: var(--radius);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.checkbox-label {
|
| 146 |
+
display: flex;
|
| 147 |
+
align-items: center;
|
| 148 |
+
gap: 8px;
|
| 149 |
+
font-size: 14px;
|
| 150 |
+
color: var(--text-secondary);
|
| 151 |
+
cursor: pointer;
|
| 152 |
+
transition: color 0.2s ease;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.checkbox-label:hover {
|
| 156 |
+
color: var(--text-primary);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.checkbox-label input[type="checkbox"] {
|
| 160 |
+
accent-color: var(--primary-color);
|
| 161 |
+
transform: scale(1.1);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.search-controls {
|
| 165 |
+
display: flex;
|
| 166 |
+
gap: 12px;
|
| 167 |
+
margin-bottom: 24px;
|
| 168 |
+
flex-wrap: wrap;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.search-input {
|
| 172 |
+
padding: 12px 16px;
|
| 173 |
+
border: 2px solid var(--border-color);
|
| 174 |
+
border-radius: var(--radius);
|
| 175 |
+
font-size: 16px;
|
| 176 |
+
flex: 1;
|
| 177 |
+
min-width: 300px;
|
| 178 |
+
background: var(--bg-tertiary);
|
| 179 |
+
color: var(--text-primary);
|
| 180 |
+
transition: border-color 0.2s ease;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.search-input:focus {
|
| 184 |
+
outline: none;
|
| 185 |
+
border-color: var(--primary-color);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.search-input::placeholder {
|
| 189 |
+
color: var(--text-muted);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.btn {
|
| 193 |
+
padding: 12px 20px;
|
| 194 |
+
border: none;
|
| 195 |
+
border-radius: var(--radius);
|
| 196 |
+
background: var(--primary-color);
|
| 197 |
+
color: white;
|
| 198 |
+
cursor: pointer;
|
| 199 |
+
font-size: 14px;
|
| 200 |
+
font-weight: 600;
|
| 201 |
+
transition: all 0.2s ease;
|
| 202 |
+
white-space: nowrap;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.btn:hover {
|
| 206 |
+
background: var(--primary-hover);
|
| 207 |
+
transform: translateY(-1px);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.btn-secondary {
|
| 211 |
+
background: var(--bg-quaternary);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.btn-secondary:hover {
|
| 215 |
+
background: #555;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/* ===== 결과 컨테이너 ===== */
|
| 219 |
+
.results-container {
|
| 220 |
+
display: grid;
|
| 221 |
+
grid-template-columns: 1fr 2fr;
|
| 222 |
+
gap: 24px;
|
| 223 |
+
margin-top: 24px;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.result-panel {
|
| 227 |
+
padding: 24px;
|
| 228 |
+
border-radius: 12px;
|
| 229 |
+
min-height: 200px;
|
| 230 |
+
box-shadow: var(--shadow);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.regulation-list {
|
| 234 |
+
background: var(--bg-tertiary);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.search-results {
|
| 238 |
+
background: var(--bg-quaternary);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.panel-title {
|
| 242 |
+
font-size: 18px;
|
| 243 |
+
font-weight: 600;
|
| 244 |
+
margin-bottom: 16px;
|
| 245 |
+
color: var(--text-primary);
|
| 246 |
+
border-bottom: 2px solid var(--primary-color);
|
| 247 |
+
padding-bottom: 8px;
|
| 248 |
+
display: flex;
|
| 249 |
+
justify-content: space-between;
|
| 250 |
+
align-items: center;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.selected-count {
|
| 254 |
+
font-size: 14px;
|
| 255 |
+
color: var(--primary-color);
|
| 256 |
+
background: rgba(77, 168, 255, 0.1);
|
| 257 |
+
padding: 4px 8px;
|
| 258 |
+
border-radius: 4px;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.regulation-item {
|
| 262 |
+
padding: 12px;
|
| 263 |
+
margin: 8px 0;
|
| 264 |
+
background: var(--bg-quaternary);
|
| 265 |
+
border-radius: var(--radius);
|
| 266 |
+
cursor: pointer;
|
| 267 |
+
transition: all 0.2s ease;
|
| 268 |
+
border: 2px solid transparent;
|
| 269 |
+
user-select: none;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.regulation-item:hover {
|
| 273 |
+
background: #3a3a3a;
|
| 274 |
+
border-color: var(--primary-color);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.regulation-item.selected {
|
| 278 |
+
background: var(--primary-color);
|
| 279 |
+
color: white;
|
| 280 |
+
border-color: var(--primary-hover);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.regulation-item.selected:hover {
|
| 284 |
+
background: var(--primary-hover);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.region-table {
|
| 288 |
+
width: 100%;
|
| 289 |
+
border-collapse: separate;
|
| 290 |
+
border-spacing: 0;
|
| 291 |
+
margin-bottom: 16px;
|
| 292 |
+
border-radius: var(--radius);
|
| 293 |
+
overflow: hidden;
|
| 294 |
+
box-shadow: var(--shadow);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.region-table th,
|
| 298 |
+
.region-table td {
|
| 299 |
+
padding: 12px 16px;
|
| 300 |
+
border: 1px solid var(--border-color);
|
| 301 |
+
text-align: left;
|
| 302 |
+
background: var(--bg-secondary);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.region-table th {
|
| 306 |
+
background: var(--bg-quaternary);
|
| 307 |
+
font-weight: 600;
|
| 308 |
+
text-align: center;
|
| 309 |
+
color: var(--primary-color);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.action-buttons {
|
| 313 |
+
display: flex;
|
| 314 |
+
gap: 12px;
|
| 315 |
+
margin-top: 16px;
|
| 316 |
+
padding-top: 16px;
|
| 317 |
+
border-top: 1px solid var(--border-color);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.btn-submit {
|
| 321 |
+
background: var(--success-color);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.btn-submit:hover {
|
| 325 |
+
background: #45a049;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.btn-clear {
|
| 329 |
+
background: var(--warning-color);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.btn-clear:hover {
|
| 333 |
+
background: #f57c00;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* ===== 푸터 ===== */
|
| 337 |
+
.footer {
|
| 338 |
+
text-align: center;
|
| 339 |
+
padding: 48px 24px;
|
| 340 |
+
color: var(--text-secondary);
|
| 341 |
+
border-top: 1px solid var(--border-color);
|
| 342 |
+
margin-top: 48px;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
/* ===== 반응형 ===== */
|
| 346 |
+
@media (max-width: 768px) {
|
| 347 |
+
.header {
|
| 348 |
+
padding: 12px 16px;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.nav {
|
| 352 |
+
gap: 16px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.hero-title {
|
| 356 |
+
font-size: 28px;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.main {
|
| 360 |
+
padding: 16px;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.search-section {
|
| 364 |
+
padding: 20px;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.results-container {
|
| 368 |
+
grid-template-columns: 1fr;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.search-controls {
|
| 372 |
+
flex-direction: column;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.search-input {
|
| 376 |
+
min-width: auto;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.checkbox-container {
|
| 380 |
+
flex-direction: column;
|
| 381 |
+
align-items: flex-start;
|
| 382 |
+
gap: 12px;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.action-buttons {
|
| 386 |
+
flex-direction: column;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.search-results-content {
|
| 390 |
+
white-space: pre-wrap;
|
| 391 |
+
word-wrap: break-word;
|
| 392 |
+
line-height: 1.6;
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
@media (max-width: 480px) {
|
| 397 |
+
.checkbox-container {
|
| 398 |
+
padding: 12px;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.nav {
|
| 402 |
+
display: none;
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
</style>
|
| 406 |
+
</head>
|
| 407 |
+
|
| 408 |
+
<body>
|
| 409 |
+
<header class="header">
|
| 410 |
+
<div class="logo">트리니티</div>
|
| 411 |
+
<nav>
|
| 412 |
+
<ul class="nav">
|
| 413 |
+
<li><a href="#">법규 검색</a></li>
|
| 414 |
+
<li><a href="#">체크리스트 생성</a></li>
|
| 415 |
+
<li><a href="#">유권해석</a></li>
|
| 416 |
+
<li><a href="#">사용문의</a></li>
|
| 417 |
+
</ul>
|
| 418 |
+
</nav>
|
| 419 |
+
</header>
|
| 420 |
+
|
| 421 |
+
<main class="main">
|
| 422 |
+
<section class="hero">
|
| 423 |
+
<h1 class="hero-title">자동차 인증 법규를 더 쉽게</h1>
|
| 424 |
+
<p class="hero-subtitle">관련 법규를 검색으로 편리하게 찾아보세요</p>
|
| 425 |
+
<div id="statusText" class="status-text">시스템 준비 중...</div>
|
| 426 |
+
</section>
|
| 427 |
+
|
| 428 |
+
<section class="search-section">
|
| 429 |
+
<h2 class="section-title">법규 검색</h2>
|
| 430 |
+
|
| 431 |
+
<div class="checkbox-container">
|
| 432 |
+
<span class="checkbox-label"><strong>검색 범위:</strong></span>
|
| 433 |
+
<label class="checkbox-label">
|
| 434 |
+
<input type="checkbox" id="allRegions">
|
| 435 |
+
모두 선택
|
| 436 |
+
</label>
|
| 437 |
+
<label class="checkbox-label">
|
| 438 |
+
<input type="checkbox" class="region-checkbox" value="국내">
|
| 439 |
+
국내
|
| 440 |
+
</label>
|
| 441 |
+
<label class="checkbox-label">
|
| 442 |
+
<input type="checkbox" class="region-checkbox" value="북미">
|
| 443 |
+
북미
|
| 444 |
+
</label>
|
| 445 |
+
<label class="checkbox-label">
|
| 446 |
+
<input type="checkbox" class="region-checkbox" value="유럽">
|
| 447 |
+
유럽
|
| 448 |
+
</label>
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<div class="search-controls">
|
| 452 |
+
<input
|
| 453 |
+
type="text"
|
| 454 |
+
id="searchInput"
|
| 455 |
+
class="search-input"
|
| 456 |
+
placeholder="검색하고 싶은 내용을 입력하세요 (예: ISA 기능과 관련된 법규가 뭐야?)"
|
| 457 |
+
>
|
| 458 |
+
<button class="btn" onclick="showMessage()">검색</button>
|
| 459 |
+
<button class="btn btn-secondary" onclick="loadRegulationList()">법규 리스트</button>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
<div class="results-container">
|
| 463 |
+
<div class="result-panel regulation-list">
|
| 464 |
+
<div class="panel-title">
|
| 465 |
+
법규 리스트
|
| 466 |
+
<span id="selectedCount" class="selected-count">선택됨: 0</span>
|
| 467 |
+
</div>
|
| 468 |
+
<div id="regulationList">
|
| 469 |
+
<div class="regulation-item" data-id="reg1" data-title="국내 배출가스 기준">
|
| 470 |
+
법규 1 - 국내 배출가스 기준
|
| 471 |
+
</div>
|
| 472 |
+
<div class="regulation-item" data-id="reg2" data-title="북미 안전 기준">
|
| 473 |
+
법규 2 - 북미 안전 기준
|
| 474 |
+
</div>
|
| 475 |
+
<div class="regulation-item" data-id="reg3" data-title="유럽 환경 규정">
|
| 476 |
+
법규 3 - 유럽 환경 규정
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
<div class="action-buttons">
|
| 480 |
+
<button class="btn btn-submit" onclick="submitSelectedRegulations()">선택 항목 전송</button>
|
| 481 |
+
<button class="btn btn-clear" onclick="clearAllSelections()">선택 초기화</button>
|
| 482 |
+
</div>
|
| 483 |
+
</div>
|
| 484 |
+
|
| 485 |
+
<div class="result-panel search-results">
|
| 486 |
+
<div class="panel-title">검색 결과</div>
|
| 487 |
+
<div id="searchResults">검색 결과가 여기에 표시됩니다.</div>
|
| 488 |
+
</div>
|
| 489 |
+
</div>
|
| 490 |
+
</section>
|
| 491 |
+
</main>
|
| 492 |
+
|
| 493 |
+
<footer class="footer">
|
| 494 |
+
<p>© 2024 법규인증3팀 - 트리니티 시스템</p>
|
| 495 |
+
</footer>
|
| 496 |
+
|
| 497 |
+
<script>
|
| 498 |
+
// ===== 전역 변수 =====
|
| 499 |
+
let selectedRegulations = [];
|
| 500 |
+
let socket;
|
| 501 |
+
|
| 502 |
+
// ===== 초기화 =====
|
| 503 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 504 |
+
initializeSocket();
|
| 505 |
+
initializeEventListeners();
|
| 506 |
+
updateSelectedCount();
|
| 507 |
+
});
|
| 508 |
+
|
| 509 |
+
// ===== Socket.IO 초기화 =====
|
| 510 |
+
function initializeSocket() {
|
| 511 |
+
socket = io();
|
| 512 |
+
socket.on('message', function(data) {
|
| 513 |
+
document.getElementById('statusText').innerText = data.message;
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// ===== 이벤트 리스너 초기화 =====
|
| 518 |
+
function initializeEventListeners() {
|
| 519 |
+
// 전체 선택 체크박스
|
| 520 |
+
document.getElementById('allRegions').addEventListener('change', toggleAllRegions);
|
| 521 |
+
|
| 522 |
+
// 개별 체크박스들에 이벤트 리스너 추가
|
| 523 |
+
document.querySelectorAll('.region-checkbox').forEach(checkbox => {
|
| 524 |
+
checkbox.addEventListener('change', updateAllRegionsCheckbox);
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
+
// 검색 입력 엔터키
|
| 528 |
+
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
| 529 |
+
if (e.key === 'Enter') {
|
| 530 |
+
showMessage();
|
| 531 |
+
}
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
// 법규 리스트 이벤트 위임 (동적으로 추가되는 항목들도 클릭 가능)
|
| 535 |
+
document.getElementById('regulationList').addEventListener('click', function(e) {
|
| 536 |
+
const item = e.target.closest('.regulation-item');
|
| 537 |
+
if (item) {
|
| 538 |
+
toggleSelection(item);
|
| 539 |
+
}
|
| 540 |
+
});
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// ===== 지역 선택 관련 함수 =====
|
| 544 |
+
function toggleAllRegions() {
|
| 545 |
+
const allCheckbox = document.getElementById('allRegions');
|
| 546 |
+
const regionCheckboxes = document.querySelectorAll('.region-checkbox');
|
| 547 |
+
|
| 548 |
+
regionCheckboxes.forEach(checkbox => {
|
| 549 |
+
checkbox.checked = allCheckbox.checked;
|
| 550 |
+
});
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// 개별 체크박스 변경 시 "모두 선택" 체크박스 상태 업데이트
|
| 554 |
+
function updateAllRegionsCheckbox() {
|
| 555 |
+
const allCheckbox = document.getElementById('allRegions');
|
| 556 |
+
const regionCheckboxes = document.querySelectorAll('.region-checkbox');
|
| 557 |
+
const checkedCount = document.querySelectorAll('.region-checkbox:checked').length;
|
| 558 |
+
|
| 559 |
+
if (checkedCount === 0) {
|
| 560 |
+
// 아무것도 선택되지 않음
|
| 561 |
+
allCheckbox.checked = false;
|
| 562 |
+
allCheckbox.indeterminate = false;
|
| 563 |
+
} else if (checkedCount === regionCheckboxes.length) {
|
| 564 |
+
// 모두 선택됨
|
| 565 |
+
allCheckbox.checked = true;
|
| 566 |
+
allCheckbox.indeterminate = false;
|
| 567 |
+
} else {
|
| 568 |
+
// 일부만 선택됨
|
| 569 |
+
allCheckbox.checked = false;
|
| 570 |
+
allCheckbox.indeterminate = true;
|
| 571 |
+
}
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
function getSelectedRegions() {
|
| 575 |
+
const checkboxes = document.querySelectorAll('.region-checkbox:checked');
|
| 576 |
+
return Array.from(checkboxes).map(cb => cb.value);
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// ===== 검색 관련 함수 (기존 performSearch를 showMessage로 통합) =====
|
| 580 |
+
function showMessage() {
|
| 581 |
+
const query = document.getElementById('searchInput').value.trim();
|
| 582 |
+
const resultsDiv = document.getElementById('searchResults');
|
| 583 |
+
const selectedRegions = getSelectedRegions();
|
| 584 |
+
|
| 585 |
+
if (!query) {
|
| 586 |
+
resultsDiv.innerHTML = '<p style="color: var(--text-muted);">검색어를 입력해주세요.</p>';
|
| 587 |
+
return;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
// 로딩 상태 표시
|
| 591 |
+
resultsDiv.innerHTML = '<p>검색 중...</p>';
|
| 592 |
+
|
| 593 |
+
// 검색과 선택된 법규 정보를 함께 서버에 전송
|
| 594 |
+
const requestData = {
|
| 595 |
+
query: query,
|
| 596 |
+
regions: selectedRegions,
|
| 597 |
+
selectedRegulations: selectedRegulations.map(id => {
|
| 598 |
+
const element = document.querySelector(`[data-id="${id}"]`);
|
| 599 |
+
return {
|
| 600 |
+
id: id,
|
| 601 |
+
title: element ? element.getAttribute('data-title') || element.textContent.trim() : id
|
| 602 |
+
};
|
| 603 |
+
})
|
| 604 |
+
};
|
| 605 |
+
|
| 606 |
+
fetch('/get_message', {
|
| 607 |
+
method: 'POST',
|
| 608 |
+
headers: {
|
| 609 |
+
'Content-Type': 'application/json'
|
| 610 |
+
},
|
| 611 |
+
body: JSON.stringify(requestData)
|
| 612 |
+
})
|
| 613 |
+
.then(response => response.json())
|
| 614 |
+
.then(data => {
|
| 615 |
+
displaySearchResults(data, query, selectedRegions);
|
| 616 |
+
})
|
| 617 |
+
.catch(error => {
|
| 618 |
+
console.error('검색 오류:', error);
|
| 619 |
+
resultsDiv.innerHTML = '<p style="color: #ff6b6b;">검색 중 오류가 발생했습니다.</p>';
|
| 620 |
+
});
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function displaySearchResults(data, query, selectedRegions) {
|
| 624 |
+
const resultsDiv = document.getElementById('searchResults');
|
| 625 |
+
|
| 626 |
+
if (selectedRegions.length === 0) {
|
| 627 |
+
selectedRegions = ['국내', '북미', '유럽'];
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
let resultsHTML = `
|
| 631 |
+
<div style="margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: var(--radius);">
|
| 632 |
+
<p><strong>검색어:</strong> ${query}</p>
|
| 633 |
+
${selectedRegulations.length > 0 ? `<p><strong>선택된 법규:</strong> ${selectedRegulations.length}개</p>` : ''}
|
| 634 |
+
</div>
|
| 635 |
+
`;
|
| 636 |
+
|
| 637 |
+
if (data.message) {
|
| 638 |
+
resultsHTML += `<div class="search-results-content" style="margin: 16px 0; padding: 16px; background: var(--bg-tertiary); border-radius: var(--radius);">
|
| 639 |
+
${data.message}
|
| 640 |
+
</div>`;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
selectedRegions.forEach(region => {
|
| 644 |
+
const content = `${region} 관련 규정: ${query}에 대한 내용`;
|
| 645 |
+
const regId = `REG-${Math.random().toString(36).substr(2, 9).toUpperCase()}`;
|
| 646 |
+
const updateDate = new Date().toLocaleDateString('ko-KR');
|
| 647 |
+
|
| 648 |
+
resultsHTML += `
|
| 649 |
+
<table class="region-table">
|
| 650 |
+
<tr>
|
| 651 |
+
<th>${region}</th>
|
| 652 |
+
<td>
|
| 653 |
+
<ul style="margin: 0; padding-left: 20px;">
|
| 654 |
+
<li>법규 ID: ${regId}</li>
|
| 655 |
+
<li>내용: ${content}</li>
|
| 656 |
+
<li>업데이트: ${updateDate}</li>
|
| 657 |
+
</ul>
|
| 658 |
+
</td>
|
| 659 |
+
</tr>
|
| 660 |
+
</table>
|
| 661 |
+
`;
|
| 662 |
+
});
|
| 663 |
+
|
| 664 |
+
resultsDiv.innerHTML = resultsHTML;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
// ===== 법규 리스트 관련 함수 =====
|
| 668 |
+
function loadRegulationList() {
|
| 669 |
+
const selectedRegions = getSelectedRegions();
|
| 670 |
+
const listDiv = document.getElementById('regulationList');
|
| 671 |
+
|
| 672 |
+
listDiv.innerHTML = '<p>법규 리스트 로딩 중...</p>';
|
| 673 |
+
|
| 674 |
+
fetch('/get_reg_list', {
|
| 675 |
+
method: 'POST',
|
| 676 |
+
headers: {
|
| 677 |
+
'Content-Type': 'application/json'
|
| 678 |
+
},
|
| 679 |
+
body: JSON.stringify({ regions: selectedRegions })
|
| 680 |
+
})
|
| 681 |
+
.then(response => response.json())
|
| 682 |
+
.then(data => {
|
| 683 |
+
if (data.reg_list_part) {
|
| 684 |
+
displayRegulationList(data.reg_list_part);
|
| 685 |
+
} else {
|
| 686 |
+
listDiv.innerHTML = '<p style="color: var(--text-muted);">법규 리스트를 불러올 수 없습니다.</p>';
|
| 687 |
+
}
|
| 688 |
+
//if (data.reg_list_section) {
|
| 689 |
+
// displayRegulationList(data.reg_list_section);
|
| 690 |
+
//} else {
|
| 691 |
+
// listDiv.innerHTML = '<p style="color: var(--text-muted);">법규 리스트를 불러올 수 없습니다.</p>';
|
| 692 |
+
//}
|
| 693 |
+
})
|
| 694 |
+
.catch(error => {
|
| 695 |
+
console.error('법규 리스트 로딩 오류:', error);
|
| 696 |
+
listDiv.innerHTML = '<p style="color: #ff6b6b;">법규 리스트 로딩 중 오류가 발생했습니다.</p>';
|
| 697 |
+
});
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
function displayRegulationList(data) {
|
| 701 |
+
const listDiv = document.getElementById('regulationList');
|
| 702 |
+
|
| 703 |
+
// 서버 데이터를 파싱해서 클릭 가능한 항목들로 변환
|
| 704 |
+
// 실제 데이터 구조에 맞게 수정 필요
|
| 705 |
+
let listHTML = '';
|
| 706 |
+
|
| 707 |
+
if (typeof data === 'string') {
|
| 708 |
+
// 문자열 데이터를 줄 단위로 분할해서 처리
|
| 709 |
+
const lines = data.split('\n').filter(line => line.trim());
|
| 710 |
+
lines.forEach((line, index) => {
|
| 711 |
+
const regId = `reg_${Date.now()}_${index}`;
|
| 712 |
+
const isSelected = selectedRegulations.includes(regId);
|
| 713 |
+
listHTML += `
|
| 714 |
+
<div class="regulation-item ${isSelected ? 'selected' : ''}"
|
| 715 |
+
data-id="${regId}"
|
| 716 |
+
data-title="${line.trim()}">
|
| 717 |
+
${line.trim()}
|
| 718 |
+
</div>
|
| 719 |
+
`;
|
| 720 |
+
});
|
| 721 |
+
} else if (Array.isArray(data)) {
|
| 722 |
+
// 배열 데이터 처리
|
| 723 |
+
data.forEach((item, index) => {
|
| 724 |
+
const regId = item.id || `reg_${Date.now()}_${index}`;
|
| 725 |
+
const title = item.title || item.name || item.toString();
|
| 726 |
+
const isSelected = selectedRegulations.includes(regId);
|
| 727 |
+
listHTML += `
|
| 728 |
+
<div class="regulation-item ${isSelected ? 'selected' : ''}"
|
| 729 |
+
data-id="${regId}"
|
| 730 |
+
data-title="${title}">
|
| 731 |
+
${title}
|
| 732 |
+
</div>
|
| 733 |
+
`;
|
| 734 |
+
});
|
| 735 |
+
} else {
|
| 736 |
+
// 기본 처리
|
| 737 |
+
listHTML = '<p style="color: var(--text-muted);">법규 데이터를 표시할 수 없습니다.</p>';
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
listDiv.innerHTML = listHTML;
|
| 741 |
+
updateSelectedCount();
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
// ===== 법규 선택 관련 함수 =====
|
| 745 |
+
function toggleSelection(element) {
|
| 746 |
+
const id = element.getAttribute('data-id');
|
| 747 |
+
|
| 748 |
+
if (selectedRegulations.includes(id)) {
|
| 749 |
+
selectedRegulations = selectedRegulations.filter(item => item !== id);
|
| 750 |
+
element.classList.remove('selected');
|
| 751 |
+
} else {
|
| 752 |
+
selectedRegulations.push(id);
|
| 753 |
+
element.classList.add('selected');
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
updateSelectedCount();
|
| 757 |
+
console.log('선택된 법규:', selectedRegulations);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
function updateSelectedCount() {
|
| 761 |
+
const countElement = document.getElementById('selectedCount');
|
| 762 |
+
countElement.textContent = `선택됨: ${selectedRegulations.length}`;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
function clearAllSelections() {
|
| 766 |
+
selectedRegulations = [];
|
| 767 |
+
document.querySelectorAll('.regulation-item').forEach(item => {
|
| 768 |
+
item.classList.remove('selected');
|
| 769 |
+
});
|
| 770 |
+
updateSelectedCount();
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
// ===== 데이터 전송 함수 =====
|
| 774 |
+
function submitSelectedRegulations() {
|
| 775 |
+
if (selectedRegulations.length === 0) {
|
| 776 |
+
alert('선택된 법규가 없습니다.');
|
| 777 |
+
return;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
const messageData = {
|
| 781 |
+
selected: selectedRegulations.map(id => {
|
| 782 |
+
const element = document.querySelector(`[data-id="${id}"]`);
|
| 783 |
+
return {
|
| 784 |
+
id: id,
|
| 785 |
+
title: element ? element.getAttribute('data-title') || element.textContent.trim() : id
|
| 786 |
+
};
|
| 787 |
+
})
|
| 788 |
+
};
|
| 789 |
+
|
| 790 |
+
fetch('/submit', {
|
| 791 |
+
method: 'POST',
|
| 792 |
+
headers: {
|
| 793 |
+
'Content-Type': 'application/json'
|
| 794 |
+
},
|
| 795 |
+
body: JSON.stringify(messageData)
|
| 796 |
+
})
|
| 797 |
+
.then(response => {
|
| 798 |
+
if (response.ok) {
|
| 799 |
+
alert(`선택된 ${selectedRegulations.length}개 법규가 전송되었습니다!`);
|
| 800 |
+
} else {
|
| 801 |
+
alert('전송 실패');
|
| 802 |
+
}
|
| 803 |
+
})
|
| 804 |
+
.catch(error => {
|
| 805 |
+
console.error('전송 오류:', error);
|
| 806 |
+
alert('전송 중 오류가 발생했습니다.');
|
| 807 |
+
});
|
| 808 |
+
}
|
| 809 |
+
</script>
|
| 810 |
+
</body>
|
| 811 |
+
</html>
|