scipious commited on
Commit
c514a40
·
verified ·
1 Parent(s): eaac0f6

Upload 16 files

Browse files
.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">&nbsp&nbsp관련 법규 내용을 검색으로 편리하게 찾아보세요! ※ 모든 결과물은 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>