SOY NV AI commited on
Commit
d5b7e8c
·
1 Parent(s): 9fa456d

feat: ?섎? 湲곕컲 chunking 援ы쁽 諛??뱀냼??愿€由??섏씠吏€ 遺꾨━

Browse files

- ?섎? 湲곕컲(Semantic Chunking) 援ы쁽: 臾몄옣怨?臾몃떒 寃쎄퀎瑜?怨좊젮??泥?궧
- 臾몃떒 ?⑥쐞濡?癒쇱? 遺꾪븷
- 臾몄옣 醫낃껐 湲고샇瑜?湲곗??쇰줈 臾몄옣 ?⑥쐞 遺꾪븷
- 臾몄옣 以묎컙?먯꽌 ?먮Ⅴ吏€ ?딄퀬 ?섎? ?⑥쐞濡?泥?겕 ?앹꽦
- 理쒖냼 200?? 理쒕? 1000?? ?ㅻ쾭??150???ㅼ젙

- ?뱀냼??愿€由??섏씠吏€ 遺꾨━
- /admin/webnovels ?쇱슦??異붽?
- admin_webnovels.html ?쒗뵆由??앹꽦
- admin.html?먯꽌 ?뚯씪 愿€由?湲곕뒫 ?쒓굅
- admin_messages.html???뱀냼??愿€由?留곹겕 異붽?

- 肄붾뱶 ?뺣━
- admin.html?먯꽌 ?뚯씪 ?낅줈??愿€??JavaScript 肄붾뱶 ?쒓굅
- ?ъ슜??愿€由??섏씠吏€???ъ슜??愿€由ъ뿉留?吏묒쨷?섎룄濡?媛쒖꽑

app/routes.py CHANGED
@@ -64,25 +64,127 @@ def ensure_upload_folder():
64
  traceback.print_exc()
65
  raise
66
 
67
- def split_text_into_chunks(text, chunk_size=1000, overlap=150):
68
- """텍스트를 청크로 분할 ( 청크와 오버랩으로 문맥 유지)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  chunks = []
70
- start = 0
71
- while start < len(text):
72
- end = start + chunk_size
73
- chunk = text[start:end]
74
- chunks.append(chunk)
75
- start = end - overlap # 오버랩으로 문맥 유지
76
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  def create_chunks_for_file(file_id, content):
79
- """파일 내용을 청크로 분할하여 저장"""
80
  try:
81
  # 기존 청크 삭제
82
  DocumentChunk.query.filter_by(file_id=file_id).delete()
83
 
84
- # 텍스트를 청크로 분할 ( 청크로 많은 컨텍스트 제공)
85
- chunks = split_text_into_chunks(content, chunk_size=1000, overlap=150)
 
86
 
87
  # 각 청크를 데이터베이스에 저장
88
  for idx, chunk_content in enumerate(chunks):
@@ -98,6 +200,8 @@ def create_chunks_for_file(file_id, content):
98
  except Exception as e:
99
  db.session.rollback()
100
  print(f"청크 생성 오류: {str(e)}")
 
 
101
  return 0
102
 
103
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=25, min_score=1):
@@ -245,6 +349,12 @@ def admin_messages():
245
  """관리자 메시지 확인 페이지"""
246
  return render_template('admin_messages.html')
247
 
 
 
 
 
 
 
248
  @main_bp.route('/api/admin/users', methods=['GET'])
249
  @admin_required
250
  def get_users():
 
64
  traceback.print_exc()
65
  raise
66
 
67
+ def split_text_into_chunks(text, min_chunk_size=200, max_chunk_size=1000, overlap=150):
68
+ """의미 기반 텍스트 청킹 (문장과 문단 경계를 고려하여 분할)"""
69
+ if not text or len(text.strip()) == 0:
70
+ return []
71
+
72
+ # 1단계: 문단 단위로 분할 (빈 줄 기준)
73
+ paragraphs = re.split(r'\n\s*\n', text.strip())
74
+ paragraphs = [p.strip() for p in paragraphs if p.strip()]
75
+
76
+ if not paragraphs:
77
+ return []
78
+
79
+ # 2단계: 각 문단을 문장 단위로 분할
80
+ # 문장 종결 기호: . ! ? (한글과 영문 모두 지원)
81
+ # 구두점 뒤에 공백이나 줄바꿈이 오는 경우 문장 종료로 간주
82
+ sentence_pattern = r'([.!?]+)(?=\s+|$)'
83
+
84
+ all_sentences = []
85
+ for para in paragraphs:
86
+ # 문장 분리 (구두점 포함)
87
+ parts = re.split(sentence_pattern, para)
88
+ combined_sentences = []
89
+ current_sentence = ""
90
+
91
+ for i, part in enumerate(parts):
92
+ if part.strip():
93
+ if re.match(r'^[.!?]+$', part):
94
+ # 구두점인 경우 현재 문장에 추가하고 문장 완성
95
+ current_sentence += part
96
+ if current_sentence.strip():
97
+ combined_sentences.append(current_sentence.strip())
98
+ current_sentence = ""
99
+ else:
100
+ # 텍스트인 경우 현재 문장에 추가
101
+ current_sentence += part
102
+
103
+ # 마지막 문장 처리 (구두점이 없는 경우)
104
+ if current_sentence.strip():
105
+ combined_sentences.append(current_sentence.strip())
106
+
107
+ # 문장이 하나도 없는 경우 (구두점이 전혀 없는 문단)
108
+ if not combined_sentences and para.strip():
109
+ combined_sentences.append(para.strip())
110
+
111
+ all_sentences.extend(combined_sentences)
112
+
113
+ if not all_sentences:
114
+ # 문장 분리가 안 되는 경우 원본 텍스트를 그대로 반환
115
+ return [text] if text.strip() else []
116
+
117
+ # 3단계: 문장들을 모아서 의미 있는 청크 생성
118
  chunks = []
119
+ current_chunk = []
120
+ current_size = 0
121
+
122
+ for sentence in all_sentences:
123
+ sentence_size = len(sentence)
124
+
125
+ # 현재 청크에 문장 추가 시 최대 크기를 초과하는 경우
126
+ if current_size + sentence_size > max_chunk_size and current_chunk:
127
+ # 현재 청크 저장 (줄바꿈 유지)
128
+ chunk_text = '\n'.join(current_chunk)
129
+ if len(chunk_text.strip()) >= min_chunk_size:
130
+ chunks.append(chunk_text)
131
+ else:
132
+ # 최소 크기 미만이면 다음 청크와 병합 (오버랩 효과)
133
+ if chunks:
134
+ chunks[-1] = chunks[-1] + '\n' + chunk_text
135
+ else:
136
+ chunks.append(chunk_text)
137
+
138
+ # 오버랩을 위한 문장 유지 (마지막 몇 문장을 다음 청크에 포함)
139
+ overlap_sentences = []
140
+ overlap_size = 0
141
+ for s in reversed(current_chunk):
142
+ if overlap_size + len(s) <= overlap:
143
+ overlap_sentences.insert(0, s)
144
+ overlap_size += len(s) + 1 # 줄바꿈 포함
145
+ else:
146
+ break
147
+
148
+ current_chunk = overlap_sentences + [sentence]
149
+ current_size = overlap_size + sentence_size
150
+ else:
151
+ # 현재 청크에 문장 추가
152
+ current_chunk.append(sentence)
153
+ current_size += sentence_size + 1 # 줄바꿈 포함
154
+
155
+ # 마지막 청크 추가
156
+ if current_chunk:
157
+ chunk_text = '\n'.join(current_chunk)
158
+ if chunks and len(chunk_text.strip()) < min_chunk_size:
159
+ # 최소 크기 미만이면 이전 청크와 병합
160
+ chunks[-1] = chunks[-1] + '\n' + chunk_text
161
+ else:
162
+ chunks.append(chunk_text)
163
+
164
+ # 빈 청크 제거 및 최소 크기 미만 청크 처리
165
+ final_chunks = []
166
+ for chunk in chunks:
167
+ chunk = chunk.strip()
168
+ if chunk and len(chunk) >= min_chunk_size:
169
+ final_chunks.append(chunk)
170
+ elif chunk:
171
+ # 최소 크기 미만 청크는 이전 청크와 병합
172
+ if final_chunks:
173
+ final_chunks[-1] = final_chunks[-1] + '\n' + chunk
174
+ else:
175
+ final_chunks.append(chunk)
176
+
177
+ return final_chunks if final_chunks else [text] if text.strip() else []
178
 
179
  def create_chunks_for_file(file_id, content):
180
+ """파일 내용을 의미 기반 청크로 분할하여 저장"""
181
  try:
182
  # 기존 청크 삭제
183
  DocumentChunk.query.filter_by(file_id=file_id).delete()
184
 
185
+ # 의미 기반 청킹 (문장과 문단 경계를 고려하여 분할)
186
+ # min_chunk_size: 최소 200자, max_chunk_size: 최대 1000자, overlap: 150
187
+ chunks = split_text_into_chunks(content, min_chunk_size=200, max_chunk_size=1000, overlap=150)
188
 
189
  # 각 청크를 데이터베이스에 저장
190
  for idx, chunk_content in enumerate(chunks):
 
200
  except Exception as e:
201
  db.session.rollback()
202
  print(f"청크 생성 오류: {str(e)}")
203
+ import traceback
204
+ traceback.print_exc()
205
  return 0
206
 
207
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=25, min_score=1):
 
349
  """관리자 메시지 확인 페이지"""
350
  return render_template('admin_messages.html')
351
 
352
+ @main_bp.route('/admin/webnovels')
353
+ @admin_required
354
+ def admin_webnovels():
355
+ """웹소설 관리 페이지"""
356
+ return render_template('admin_webnovels.html')
357
+
358
  @main_bp.route('/api/admin/users', methods=['GET'])
359
  @admin_required
360
  def get_users():
templates/admin.html CHANGED
@@ -445,6 +445,7 @@
445
  </div>
446
  <div class="header-actions">
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
 
448
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
449
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
450
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
@@ -511,90 +512,6 @@
511
  </tbody>
512
  </table>
513
  </div>
514
-
515
- <!-- 파일 관리 섹션 -->
516
- <div class="card file-upload-section">
517
- <div class="card-header">
518
- <div class="card-title">웹소설 학습 파일 관리</div>
519
- </div>
520
-
521
- <!-- 모델 선택 -->
522
- <div style="margin-bottom: 16px;">
523
- <label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px;">AI 모델 선택</label>
524
- <select id="fileModelSelect" style="width: 100%; padding: 10px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
525
- <option value="">모델을 선택하세요...</option>
526
- </select>
527
- </div>
528
-
529
- <!-- 파일 업로드 -->
530
- <div class="file-upload-input-wrapper" id="fileUploadWrapper">
531
- <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
532
- <label class="file-upload-label">
533
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
534
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
535
- </svg>
536
- <span>파일을 선택하거나 드래그하세요</span>
537
- <small style="color: #5f6368;">(여러 파일 선택 가능)</small>
538
- </label>
539
- </div>
540
- <div class="file-upload-status" id="fileUploadStatus"></div>
541
- <div class="file-upload-progress" id="fileUploadProgress">
542
- <div id="progressItems"></div>
543
- </div>
544
-
545
- <!-- 업로드 규칙 안내 -->
546
- <div style="margin-top: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8;">
547
- <div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #202124;">
548
- 📋 파일 업로드 규칙
549
- </div>
550
- <div style="font-size: 13px; color: #5f6368; line-height: 1.8;">
551
- <div style="margin-bottom: 8px;">
552
- <strong>허용 파일 형식:</strong> .txt, .md, .pdf, .docx, .epub
553
- </div>
554
- <div style="margin-bottom: 8px;">
555
- <strong>파일 인코딩:</strong> UTF-8 또는 CP949 (한글 파일의 경우)
556
- </div>
557
- <div style="margin-bottom: 8px;">
558
- <strong>권장 사항:</strong>
559
- <ul style="margin: 8px 0 0 20px; padding: 0;">
560
- <li>텍스트 파일(.txt, .md)은 UTF-8 인코딩을 권장합니다</li>
561
- <li>파일명에 특수문자 사용을 자제해주세요</li>
562
- <li>업로드 전에 AI 모델을 먼저 선택해주세요</li>
563
- <li>여러 파일을 한 번에 업로드할 수 있습니다</li>
564
- </ul>
565
- </div>
566
- <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #dadce0; font-size: 12px; color: #80868b;">
567
- ⚠️ 업로드된 파일은 선택한 AI 모델과 연결되어 학습 데이터로 사용됩니다.
568
- </div>
569
- </div>
570
- </div>
571
-
572
- <!-- 모델별 통계 -->
573
- <div id="modelStats" style="margin-top: 20px; padding: 16px; background: #e8f0fe; border-radius: 8px; display: none;">
574
- <div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #1967d2;">📊 모델별 학습 파일 통계</div>
575
- <div id="modelStatsContent"></div>
576
- </div>
577
-
578
- <!-- 파일 목록 -->
579
- <div class="files-table">
580
- <table style="margin-top: 16px;">
581
- <thead>
582
- <tr>
583
- <th>파일명</th>
584
- <th>연결된 모델</th>
585
- <th>크기</th>
586
- <th>업로드일</th>
587
- <th>작업</th>
588
- </tr>
589
- </thead>
590
- <tbody id="filesTableBody">
591
- <tr>
592
- <td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">파일 목록을 불러오는 중...</td>
593
- </tr>
594
- </tbody>
595
- </table>
596
- </div>
597
- </div>
598
  </div>
599
 
600
  <!-- 사용자 생성/수정 모달 -->
@@ -751,369 +668,6 @@
751
  }
752
  });
753
 
754
- // 파일 업로드 관련
755
- const fileInput = document.getElementById('fileInput');
756
- const fileUploadWrapper = document.getElementById('fileUploadWrapper');
757
- const fileUploadStatus = document.getElementById('fileUploadStatus');
758
- const fileModelSelect = document.getElementById('fileModelSelect');
759
- const filesTableBody = document.getElementById('filesTableBody');
760
-
761
- // 모델 목록 로드
762
- async function loadModelsForFiles() {
763
- try {
764
- const response = await fetch('/api/ollama/models');
765
- const data = await response.json();
766
-
767
- fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
768
-
769
- if (data.models && data.models.length > 0) {
770
- data.models.forEach(model => {
771
- const option = document.createElement('option');
772
- option.value = model.name;
773
- option.textContent = model.name;
774
- fileModelSelect.appendChild(option);
775
- });
776
- }
777
- } catch (error) {
778
- console.error('모델 로드 오류:', error);
779
- }
780
- }
781
-
782
- // 파일 목록 로드
783
- async function loadFiles() {
784
- try {
785
- const response = await fetch('/api/files');
786
- const data = await response.json();
787
-
788
- filesTableBody.innerHTML = '';
789
-
790
- // 모델별 통계 표시
791
- if (data.model_stats && Object.keys(data.model_stats).length > 0) {
792
- const statsContainer = document.getElementById('modelStats');
793
- const statsContent = document.getElementById('modelStatsContent');
794
- statsContainer.style.display = 'block';
795
-
796
- let statsHtml = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">';
797
- for (const [model, stats] of Object.entries(data.model_stats)) {
798
- const totalSize = formatFileSize(stats.total_size);
799
- statsHtml += `
800
- <div style="background: white; padding: 12px; border-radius: 6px; border-left: 4px solid #1a73e8;">
801
- <div style="font-weight: 500; margin-bottom: 4px; color: #202124;">${escapeHtml(model || '미지정')}</div>
802
- <div style="font-size: 12px; color: #5f6368;">
803
- 파일: <strong>${stats.count}개</strong><br>
804
- 총 크기: <strong>${totalSize}</strong>
805
- </div>
806
- </div>
807
- `;
808
- }
809
- statsHtml += '</div>';
810
- statsContent.innerHTML = statsHtml;
811
- } else {
812
- document.getElementById('modelStats').style.display = 'none';
813
- }
814
-
815
- if (data.files && data.files.length > 0) {
816
- data.files.forEach(file => {
817
- const row = document.createElement('tr');
818
- const fileSize = formatFileSize(file.file_size);
819
- const uploadDate = new Date(file.uploaded_at).toLocaleString('ko-KR');
820
- const modelName = file.model_name || '<span style="color: #c5221f;">미지정</span>';
821
- const childFiles = file.child_files || [];
822
- const childCount = childFiles.length;
823
-
824
- // 원본 파일 행
825
- let fileNameDisplay = escapeHtml(file.original_filename);
826
- if (childCount > 0) {
827
- fileNameDisplay += ` <span style="color: #1a73e8; font-size: 12px;">(${childCount}개 회차)</span>`;
828
- }
829
-
830
- row.innerHTML = `
831
- <td>${fileNameDisplay}</td>
832
- <td>${modelName}</td>
833
- <td class="file-size">${fileSize}</td>
834
- <td>${uploadDate}</td>
835
- <td>
836
- <div class="file-actions">
837
- <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
838
- <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
839
- </div>
840
- </td>
841
- `;
842
- filesTableBody.appendChild(row);
843
-
844
- // 이어서 업로드된 파일들 표시
845
- childFiles.forEach((childFile, index) => {
846
- const childRow = document.createElement('tr');
847
- const childFileSize = formatFileSize(childFile.file_size);
848
- const childUploadDate = new Date(childFile.uploaded_at).toLocaleString('ko-KR');
849
-
850
- childRow.innerHTML = `
851
- <td style="padding-left: 32px; color: #5f6368;">
852
- <span style="margin-right: 8px;">└─</span>${escapeHtml(childFile.original_filename)}
853
- </td>
854
- <td>${modelName}</td>
855
- <td class="file-size">${childFileSize}</td>
856
- <td>${childUploadDate}</td>
857
- <td>
858
- <div class="file-actions">
859
- <button class="btn btn-secondary" onclick="deleteFile(${childFile.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
860
- </div>
861
- </td>
862
- `;
863
- filesTableBody.appendChild(childRow);
864
- });
865
- });
866
- } else {
867
- filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
868
- }
869
- } catch (error) {
870
- filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.</td></tr>';
871
- console.error('파일 목록 로드 오류:', error);
872
- }
873
- }
874
-
875
- // 파일 크기 포맷
876
- function formatFileSize(bytes) {
877
- if (bytes === 0) return '0 Bytes';
878
- const k = 1024;
879
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
880
- const i = Math.floor(Math.log(bytes) / Math.log(k));
881
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
882
- }
883
-
884
- // HTML 이스케이프
885
- function escapeHtml(text) {
886
- const div = document.createElement('div');
887
- div.textContent = text;
888
- return div.innerHTML;
889
- }
890
-
891
- // 파일 업로드 처리
892
- async function handleFileUpload(files) {
893
- if (!files || files.length === 0) return;
894
-
895
- const modelName = fileModelSelect.value;
896
- if (!modelName) {
897
- showAlert('먼저 AI 모델을 선택해주세요.', 'error');
898
- return;
899
- }
900
-
901
- // 업로드 중 UI 비활성화
902
- fileUploadWrapper.classList.add('disabled');
903
- fileModelSelect.disabled = true;
904
- fileInput.disabled = true;
905
-
906
- // 진행 상태 초기화
907
- const progressContainer = document.getElementById('fileUploadProgress');
908
- const progressItems = document.getElementById('progressItems');
909
- progressContainer.classList.add('active');
910
- progressItems.innerHTML = '';
911
-
912
- // 각 파일에 대한 진행 항목 생성
913
- const progressMap = new Map();
914
- files.forEach((file, index) => {
915
- const item = document.createElement('div');
916
- item.className = 'progress-item';
917
- item.id = `progress-item-${index}`;
918
- item.innerHTML = `
919
- <span class="progress-item-name">${escapeHtml(file.name)}</span>
920
- <span class="progress-item-status uploading" id="progress-status-${index}">
921
- <span class="spinner"></span>업로드 중...
922
- </span>
923
- `;
924
- progressItems.appendChild(item);
925
- progressMap.set(index, { file, item, status: 'uploading' });
926
- });
927
-
928
- fileUploadStatus.textContent = `총 ${files.length}개 파일 업로드 중...`;
929
- fileUploadStatus.className = 'file-upload-status progress';
930
-
931
- let successCount = 0;
932
- let failCount = 0;
933
- const errors = [];
934
-
935
- // 파일을 순차적으로 업로드
936
- for (let i = 0; i < files.length; i++) {
937
- const file = files[i];
938
- const formData = new FormData();
939
- formData.append('file', file);
940
- formData.append('model_name', modelName);
941
-
942
- // ���어서 업로드인 경우 parent_file_id 추가
943
- if (continueUploadFileId) {
944
- formData.append('parent_file_id', continueUploadFileId);
945
- }
946
-
947
- const statusElement = document.getElementById(`progress-status-${i}`);
948
- const itemElement = document.getElementById(`progress-item-${i}`);
949
-
950
- try {
951
- console.log(`[업로드 시작] 파일: ${file.name}, 크기: ${file.size} bytes, 모델: ${modelName}`);
952
-
953
- const response = await fetch('/api/upload', {
954
- method: 'POST',
955
- body: formData
956
- });
957
-
958
- console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
959
-
960
- let data;
961
- let responseText = '';
962
- try {
963
- responseText = await response.text();
964
- console.log(`[응답 본문] ${responseText.substring(0, 500)}`);
965
- data = JSON.parse(responseText);
966
- } catch (jsonError) {
967
- // JSON 파싱 실패 시 응답 텍스트 사용
968
- console.error(`[JSON 파싱 실패]`, jsonError, `응답 텍스트:`, responseText);
969
- throw new Error(`서버 응답 오류 (${response.status}): ${responseText.substring(0, 200)}`);
970
- }
971
-
972
- if (response.ok) {
973
- successCount++;
974
- const modelName = data.model_name || '알 수 없음';
975
- const chunkCount = data.chunk_count || 0;
976
- statusElement.className = 'progress-item-status success';
977
- statusElement.innerHTML = '✓ 완료';
978
- statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
979
- itemElement.style.opacity = '0.7';
980
- console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}, 청크: ${chunkCount}개`);
981
- } else {
982
- failCount++;
983
- const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
984
- statusElement.className = 'progress-item-status error';
985
- statusElement.innerHTML = '✗ 실패';
986
- statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
987
- statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
988
- errors.push(`${file.name}: ${errorMsg}`);
989
- console.error(`[업로드 실패] 파일: ${file.name}`, {
990
- status: response.status,
991
- statusText: response.statusText,
992
- error: errorMsg,
993
- data: data,
994
- responseText: responseText
995
- });
996
- }
997
- } catch (error) {
998
- failCount++;
999
- const errorMsg = error.message || '네트워크 오류';
1000
- statusElement.className = 'progress-item-status error';
1001
- statusElement.innerHTML = '✗ 실패';
1002
- statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
1003
- statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
1004
- errors.push(`${file.name}: ${errorMsg}`);
1005
- console.error(`[업로드 예외] 파일: ${file.name}`, error);
1006
- console.error(`[스택 트레이스]`, error.stack);
1007
- }
1008
-
1009
- // 진행 상태 업데이트
1010
- fileUploadStatus.textContent = `업로드 중... (${i + 1}/${files.length})`;
1011
- }
1012
-
1013
- // 업로드 완료 처리
1014
- fileUploadStatus.className = 'file-upload-status';
1015
- if (successCount > 0) {
1016
- fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
1017
- fileUploadStatus.className = 'file-upload-status success';
1018
- showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
1019
- loadFiles();
1020
- } else {
1021
- fileUploadStatus.textContent = '모든 파일 업로드 실패';
1022
- fileUploadStatus.className = 'file-upload-status error';
1023
- const errorDetails = errors.length > 0 ? '\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n... 외 ${errors.length - 3}개 오류` : '') : '';
1024
- showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
1025
- }
1026
-
1027
- // UI 활성화
1028
- fileUploadWrapper.classList.remove('disabled');
1029
- fileModelSelect.disabled = false;
1030
- fileInput.disabled = false;
1031
- fileInput.value = '';
1032
-
1033
- // 3초 후 진행 상태 숨기기
1034
- setTimeout(() => {
1035
- progressContainer.classList.remove('active');
1036
- fileUploadStatus.textContent = '';
1037
- }, 3000);
1038
- }
1039
-
1040
- // 파일 삭제
1041
- async function deleteFile(fileId) {
1042
- if (!confirm('이 파일을 삭제하시겠습니까?\n원본 파일인 경우 이어서 업로드한 모든 회차도 함께 삭제됩니다.')) {
1043
- return;
1044
- }
1045
-
1046
- try {
1047
- const response = await fetch(`/api/files/${fileId}`, {
1048
- method: 'DELETE'
1049
- });
1050
-
1051
- const data = await response.json();
1052
-
1053
- if (response.ok) {
1054
- if (data.deleted_count > 1) {
1055
- showAlert(`${data.message} (삭제된 파일: ${data.deleted_files.join(', ')})`, 'success');
1056
- } else {
1057
- showAlert(data.message, 'success');
1058
- }
1059
- loadFiles();
1060
- } else {
1061
- showAlert(data.error || '삭제 중 오류가 발생했습니다.', 'error');
1062
- }
1063
- } catch (error) {
1064
- showAlert(`오류: ${error.message}`, 'error');
1065
- }
1066
- }
1067
-
1068
- // 이어서 업로드
1069
- let continueUploadFileId = null;
1070
-
1071
- function continueUpload(fileId) {
1072
- continueUploadFileId = fileId;
1073
- // 모델 선택 확인
1074
- const modelName = fileModelSelect.value;
1075
- if (!modelName) {
1076
- showAlert('먼저 AI 모델을 선택해주세요.', 'error');
1077
- continueUploadFileId = null;
1078
- return;
1079
- }
1080
- fileInput.click(); // 파일 선택 다이얼로그 열기
1081
- }
1082
-
1083
- // 파일 입력 이벤트
1084
- fileInput.addEventListener('change', function(e) {
1085
- if (e.target.files.length > 0) {
1086
- handleFileUpload(Array.from(e.target.files));
1087
- }
1088
- // 이어서 업로드 모드 초기화
1089
- continueUploadFileId = null;
1090
- });
1091
-
1092
- // 드래그 앤 드롭
1093
- fileUploadWrapper.addEventListener('dragover', (e) => {
1094
- e.preventDefault();
1095
- fileUploadWrapper.classList.add('dragover');
1096
- });
1097
-
1098
- fileUploadWrapper.addEventListener('dragleave', () => {
1099
- fileUploadWrapper.classList.remove('dragover');
1100
- });
1101
-
1102
- fileUploadWrapper.addEventListener('drop', (e) => {
1103
- e.preventDefault();
1104
- fileUploadWrapper.classList.remove('dragover');
1105
-
1106
- const files = Array.from(e.dataTransfer.files);
1107
- if (files.length > 0) {
1108
- handleFileUpload(files);
1109
- }
1110
- });
1111
-
1112
- // 페이지 로드 시 초기화
1113
- window.addEventListener('load', () => {
1114
- loadModelsForFiles();
1115
- loadFiles();
1116
- });
1117
  </script>
1118
  </body>
1119
  </html>
 
445
  </div>
446
  <div class="header-actions">
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
449
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
450
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
451
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
 
512
  </tbody>
513
  </table>
514
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  </div>
516
 
517
  <!-- 사용자 생성/수정 모달 -->
 
668
  }
669
  });
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  </script>
672
  </body>
673
  </html>
templates/admin_messages.html CHANGED
@@ -349,6 +349,7 @@
349
  <div class="header-actions">
350
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
351
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
 
352
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
353
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
354
  </div>
 
349
  <div class="header-actions">
350
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
351
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
352
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
353
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
354
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
355
  </div>
templates/admin_webnovels.html ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>웹소설 관리 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .btn {
47
+ padding: 8px 16px;
48
+ border: none;
49
+ border-radius: 6px;
50
+ font-size: 14px;
51
+ font-weight: 500;
52
+ cursor: pointer;
53
+ transition: all 0.2s;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ }
57
+
58
+ .btn-primary {
59
+ background: #1a73e8;
60
+ color: white;
61
+ }
62
+
63
+ .btn-primary:hover {
64
+ background: #1557b0;
65
+ }
66
+
67
+ .btn-secondary {
68
+ background: #f1f3f4;
69
+ color: #202124;
70
+ }
71
+
72
+ .btn-secondary:hover {
73
+ background: #e8eaed;
74
+ }
75
+
76
+ .btn-danger {
77
+ background: #ea4335;
78
+ color: white;
79
+ }
80
+
81
+ .btn-danger:hover {
82
+ background: #c5221f;
83
+ }
84
+
85
+ .container {
86
+ max-width: 1200px;
87
+ margin: 0 auto;
88
+ padding: 24px;
89
+ }
90
+
91
+ .page-header {
92
+ margin-bottom: 24px;
93
+ }
94
+
95
+ .page-header h1 {
96
+ font-size: 28px;
97
+ font-weight: 600;
98
+ margin-bottom: 8px;
99
+ }
100
+
101
+ .page-header p {
102
+ color: #5f6368;
103
+ }
104
+
105
+ .card {
106
+ background: white;
107
+ border-radius: 8px;
108
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
109
+ padding: 24px;
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .card-header {
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: center;
117
+ margin-bottom: 20px;
118
+ }
119
+
120
+ .card-title {
121
+ font-size: 18px;
122
+ font-weight: 500;
123
+ }
124
+
125
+ table {
126
+ width: 100%;
127
+ border-collapse: collapse;
128
+ }
129
+
130
+ thead {
131
+ background: #f8f9fa;
132
+ }
133
+
134
+ th, td {
135
+ padding: 12px;
136
+ text-align: left;
137
+ border-bottom: 1px solid #e8eaed;
138
+ }
139
+
140
+ th {
141
+ font-weight: 500;
142
+ font-size: 14px;
143
+ color: #5f6368;
144
+ }
145
+
146
+ td {
147
+ font-size: 14px;
148
+ }
149
+
150
+ .alert {
151
+ padding: 12px 16px;
152
+ border-radius: 6px;
153
+ margin-bottom: 16px;
154
+ font-size: 14px;
155
+ }
156
+
157
+ .alert.error {
158
+ background: #fce8e6;
159
+ color: #c5221f;
160
+ }
161
+
162
+ .alert.success {
163
+ background: #e8f5e9;
164
+ color: #137333;
165
+ }
166
+
167
+ /* 파일 업로드 영역 */
168
+ .file-upload-section {
169
+ margin-top: 24px;
170
+ }
171
+
172
+ .file-upload-input-wrapper {
173
+ position: relative;
174
+ margin-bottom: 12px;
175
+ border: 2px dashed #dadce0;
176
+ border-radius: 8px;
177
+ padding: 20px;
178
+ text-align: center;
179
+ background: #f8f9fa;
180
+ cursor: pointer;
181
+ transition: all 0.2s;
182
+ }
183
+
184
+ .file-upload-input-wrapper:hover {
185
+ border-color: #1a73e8;
186
+ background: #e8f0fe;
187
+ }
188
+
189
+ .file-upload-input-wrapper.dragover {
190
+ border-color: #1a73e8;
191
+ background: #e8f0fe;
192
+ }
193
+
194
+ .file-upload-input-wrapper input[type="file"] {
195
+ position: absolute;
196
+ opacity: 0;
197
+ width: 100%;
198
+ height: 100%;
199
+ cursor: pointer;
200
+ }
201
+
202
+ .file-upload-label {
203
+ display: flex;
204
+ flex-direction: column;
205
+ align-items: center;
206
+ gap: 8px;
207
+ color: #5f6368;
208
+ font-size: 14px;
209
+ }
210
+
211
+ .file-upload-label svg {
212
+ width: 32px;
213
+ height: 32px;
214
+ }
215
+
216
+ .file-upload-status {
217
+ font-size: 12px;
218
+ margin-top: 8px;
219
+ min-height: 16px;
220
+ }
221
+
222
+ .file-upload-status.success {
223
+ color: #137333;
224
+ }
225
+
226
+ .file-upload-status.error {
227
+ color: #c5221f;
228
+ }
229
+
230
+ .file-upload-status.progress {
231
+ color: #1a73e8;
232
+ font-weight: 500;
233
+ }
234
+
235
+ .file-upload-progress {
236
+ margin-top: 12px;
237
+ padding: 12px;
238
+ background: #f8f9fa;
239
+ border-radius: 6px;
240
+ display: none;
241
+ }
242
+
243
+ .file-upload-progress.active {
244
+ display: block;
245
+ }
246
+
247
+ .progress-item {
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: space-between;
251
+ padding: 8px 0;
252
+ border-bottom: 1px solid #e8eaed;
253
+ }
254
+
255
+ .progress-item:last-child {
256
+ border-bottom: none;
257
+ }
258
+
259
+ .progress-item-name {
260
+ flex: 1;
261
+ font-size: 13px;
262
+ color: #202124;
263
+ overflow: hidden;
264
+ text-overflow: ellipsis;
265
+ white-space: nowrap;
266
+ margin-right: 12px;
267
+ }
268
+
269
+ .progress-item-status {
270
+ font-size: 12px;
271
+ font-weight: 500;
272
+ min-width: 80px;
273
+ text-align: right;
274
+ }
275
+
276
+ .progress-item-status.uploading {
277
+ color: #1a73e8;
278
+ }
279
+
280
+ .progress-item-status.success {
281
+ color: #137333;
282
+ }
283
+
284
+ .progress-item-status.error {
285
+ color: #c5221f;
286
+ }
287
+
288
+ .file-upload-input-wrapper.disabled {
289
+ opacity: 0.6;
290
+ pointer-events: none;
291
+ cursor: not-allowed;
292
+ }
293
+
294
+ .spinner {
295
+ display: inline-block;
296
+ width: 12px;
297
+ height: 12px;
298
+ border: 2px solid #e8eaed;
299
+ border-top-color: #1a73e8;
300
+ border-radius: 50%;
301
+ animation: spin 0.8s linear infinite;
302
+ margin-right: 6px;
303
+ vertical-align: middle;
304
+ }
305
+
306
+ @keyframes spin {
307
+ to { transform: rotate(360deg); }
308
+ }
309
+
310
+ .files-table {
311
+ margin-top: 16px;
312
+ }
313
+
314
+ .file-size {
315
+ color: #5f6368;
316
+ font-size: 12px;
317
+ }
318
+
319
+ .file-actions {
320
+ display: flex;
321
+ gap: 4px;
322
+ }
323
+ </style>
324
+ </head>
325
+ <body>
326
+ <div class="header">
327
+ <div class="header-title">
328
+ <span>📚</span>
329
+ <span>웹소설 관리</span>
330
+ </div>
331
+ <div class="header-actions">
332
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
333
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
334
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
335
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
336
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
337
+ </div>
338
+ </div>
339
+
340
+ <div class="container">
341
+ <div class="page-header">
342
+ <h1>웹소설 학습 파일 관리</h1>
343
+ <p>웹소설 학습 파일을 업로드하고 관리할 수 있습니다.</p>
344
+ </div>
345
+
346
+ <div id="alertContainer"></div>
347
+
348
+ <div class="card file-upload-section">
349
+ <div class="card-header">
350
+ <div class="card-title">파일 업로드</div>
351
+ </div>
352
+
353
+ <!-- 모델 선택 -->
354
+ <div style="margin-bottom: 16px;">
355
+ <label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px;">AI 모델 선택</label>
356
+ <select id="fileModelSelect" style="width: 100%; padding: 10px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
357
+ <option value="">모델을 선택하세요...</option>
358
+ </select>
359
+ </div>
360
+
361
+ <!-- 파일 업로드 -->
362
+ <div class="file-upload-input-wrapper" id="fileUploadWrapper">
363
+ <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
364
+ <label class="file-upload-label">
365
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
366
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
367
+ </svg>
368
+ <span>파일을 선택하거나 드래그하세요</span>
369
+ <small style="color: #5f6368;">(여러 파일 선택 가능)</small>
370
+ </label>
371
+ </div>
372
+ <div class="file-upload-status" id="fileUploadStatus"></div>
373
+ <div class="file-upload-progress" id="fileUploadProgress">
374
+ <div id="progressItems"></div>
375
+ </div>
376
+
377
+ <!-- 업로드 규칙 안내 -->
378
+ <div style="margin-top: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8;">
379
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #202124;">
380
+ 📋 파일 업로드 규칙
381
+ </div>
382
+ <div style="font-size: 13px; color: #5f6368; line-height: 1.8;">
383
+ <div style="margin-bottom: 8px;">
384
+ <strong>허용 파일 형식:</strong> .txt, .md, .pdf, .docx, .epub
385
+ </div>
386
+ <div style="margin-bottom: 8px;">
387
+ <strong>파일 인코딩:</strong> UTF-8 또는 CP949 (한글 파일의 경우)
388
+ </div>
389
+ <div style="margin-bottom: 8px;">
390
+ <strong>권장 사항:</strong>
391
+ <ul style="margin: 8px 0 0 20px; padding: 0;">
392
+ <li>텍스트 파일(.txt, .md)은 UTF-8 인코딩을 권장합니다</li>
393
+ <li>파일명에 특수문자 사용을 자제해주세요</li>
394
+ <li>업로드 전에 AI 모델을 먼저 선택해주세요</li>
395
+ <li>여러 파일을 한 번에 업로드할 수 있습니다</li>
396
+ </ul>
397
+ </div>
398
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #dadce0; font-size: 12px; color: #80868b;">
399
+ ⚠️ 업로드된 파일은 선택한 AI 모델과 연결되어 학습 데이터로 사용됩니다.
400
+ </div>
401
+ </div>
402
+ </div>
403
+
404
+ <!-- 모델별 통계 -->
405
+ <div id="modelStats" style="margin-top: 20px; padding: 16px; background: #e8f0fe; border-radius: 8px; display: none;">
406
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #1967d2;">📊 모델별 학습 파일 통계</div>
407
+ <div id="modelStatsContent"></div>
408
+ </div>
409
+
410
+ <!-- 파일 목록 -->
411
+ <div class="files-table">
412
+ <table style="margin-top: 16px;">
413
+ <thead>
414
+ <tr>
415
+ <th>파일명</th>
416
+ <th>연결된 모델</th>
417
+ <th>크기</th>
418
+ <th>업로드일</th>
419
+ <th>작업</th>
420
+ </tr>
421
+ </thead>
422
+ <tbody id="filesTableBody">
423
+ <tr>
424
+ <td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">파일 목록을 불러오는 중...</td>
425
+ </tr>
426
+ </tbody>
427
+ </table>
428
+ </div>
429
+ </div>
430
+ </div>
431
+
432
+ <script>
433
+ function showAlert(message, type = 'success') {
434
+ const container = document.getElementById('alertContainer');
435
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
436
+ setTimeout(() => {
437
+ container.innerHTML = '';
438
+ }, 5000);
439
+ }
440
+
441
+ // 파일 업로드 관련
442
+ const fileInput = document.getElementById('fileInput');
443
+ const fileUploadWrapper = document.getElementById('fileUploadWrapper');
444
+ const fileUploadStatus = document.getElementById('fileUploadStatus');
445
+ const fileModelSelect = document.getElementById('fileModelSelect');
446
+ const filesTableBody = document.getElementById('filesTableBody');
447
+
448
+ // 모델 목록 로드
449
+ async function loadModelsForFiles() {
450
+ try {
451
+ const response = await fetch('/api/ollama/models');
452
+ const data = await response.json();
453
+
454
+ fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
455
+
456
+ if (data.models && data.models.length > 0) {
457
+ data.models.forEach(model => {
458
+ const option = document.createElement('option');
459
+ option.value = model.name;
460
+ option.textContent = model.name;
461
+ fileModelSelect.appendChild(option);
462
+ });
463
+ }
464
+ } catch (error) {
465
+ console.error('모델 로드 오류:', error);
466
+ }
467
+ }
468
+
469
+ // 파일 목록 로드
470
+ async function loadFiles() {
471
+ try {
472
+ const response = await fetch('/api/files');
473
+ const data = await response.json();
474
+
475
+ filesTableBody.innerHTML = '';
476
+
477
+ // 모델별 통계 표시
478
+ if (data.model_stats && Object.keys(data.model_stats).length > 0) {
479
+ const statsContainer = document.getElementById('modelStats');
480
+ const statsContent = document.getElementById('modelStatsContent');
481
+ statsContainer.style.display = 'block';
482
+
483
+ let statsHtml = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">';
484
+ for (const [model, stats] of Object.entries(data.model_stats)) {
485
+ const totalSize = formatFileSize(stats.total_size);
486
+ statsHtml += `
487
+ <div style="background: white; padding: 12px; border-radius: 6px; border-left: 4px solid #1a73e8;">
488
+ <div style="font-weight: 500; margin-bottom: 4px; color: #202124;">${escapeHtml(model || '미지정')}</div>
489
+ <div style="font-size: 12px; color: #5f6368;">
490
+ 파일: <strong>${stats.count}개</strong><br>
491
+ 총 크기: <strong>${totalSize}</strong>
492
+ </div>
493
+ </div>
494
+ `;
495
+ }
496
+ statsHtml += '</div>';
497
+ statsContent.innerHTML = statsHtml;
498
+ } else {
499
+ document.getElementById('modelStats').style.display = 'none';
500
+ }
501
+
502
+ if (data.files && data.files.length > 0) {
503
+ data.files.forEach(file => {
504
+ const row = document.createElement('tr');
505
+ const fileSize = formatFileSize(file.file_size);
506
+ const uploadDate = new Date(file.uploaded_at).toLocaleString('ko-KR');
507
+ const modelName = file.model_name || '<span style="color: #c5221f;">미지정</span>';
508
+ const childFiles = file.child_files || [];
509
+ const childCount = childFiles.length;
510
+
511
+ // 원본 파일 행
512
+ let fileNameDisplay = escapeHtml(file.original_filename);
513
+ if (childCount > 0) {
514
+ fileNameDisplay += ` <span style="color: #1a73e8; font-size: 12px;">(${childCount}개 회차)</span>`;
515
+ }
516
+
517
+ row.innerHTML = `
518
+ <td>${fileNameDisplay}</td>
519
+ <td>${modelName}</td>
520
+ <td class="file-size">${fileSize}</td>
521
+ <td>${uploadDate}</td>
522
+ <td>
523
+ <div class="file-actions">
524
+ <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
525
+ <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
526
+ </div>
527
+ </td>
528
+ `;
529
+ filesTableBody.appendChild(row);
530
+
531
+ // 이어서 업로드된 파일들 표시
532
+ childFiles.forEach((childFile, index) => {
533
+ const childRow = document.createElement('tr');
534
+ const childFileSize = formatFileSize(childFile.file_size);
535
+ const childUploadDate = new Date(childFile.uploaded_at).toLocaleString('ko-KR');
536
+
537
+ childRow.innerHTML = `
538
+ <td style="padding-left: 32px; color: #5f6368;">
539
+ <span style="margin-right: 8px;">└─</span>${escapeHtml(childFile.original_filename)}
540
+ </td>
541
+ <td>${modelName}</td>
542
+ <td class="file-size">${childFileSize}</td>
543
+ <td>${childUploadDate}</td>
544
+ <td>
545
+ <div class="file-actions">
546
+ <button class="btn btn-secondary" onclick="deleteFile(${childFile.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
547
+ </div>
548
+ </td>
549
+ `;
550
+ filesTableBody.appendChild(childRow);
551
+ });
552
+ });
553
+ } else {
554
+ filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
555
+ }
556
+ } catch (error) {
557
+ filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.</td></tr>';
558
+ console.error('파일 목록 로드 오류:', error);
559
+ }
560
+ }
561
+
562
+ // 파일 크기 포맷
563
+ function formatFileSize(bytes) {
564
+ if (bytes === 0) return '0 Bytes';
565
+ const k = 1024;
566
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
567
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
568
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
569
+ }
570
+
571
+ // HTML 이스케이프
572
+ function escapeHtml(text) {
573
+ const div = document.createElement('div');
574
+ div.textContent = text;
575
+ return div.innerHTML;
576
+ }
577
+
578
+ // 파일 업로드 처리
579
+ async function handleFileUpload(files) {
580
+ if (!files || files.length === 0) return;
581
+
582
+ const modelName = fileModelSelect.value;
583
+ if (!modelName) {
584
+ showAlert('먼저 AI 모델을 선택해주세요.', 'error');
585
+ return;
586
+ }
587
+
588
+ // 업로드 중 UI 비활성화
589
+ fileUploadWrapper.classList.add('disabled');
590
+ fileModelSelect.disabled = true;
591
+ fileInput.disabled = true;
592
+
593
+ // 진행 상태 초기화
594
+ const progressContainer = document.getElementById('fileUploadProgress');
595
+ const progressItems = document.getElementById('progressItems');
596
+ progressContainer.classList.add('active');
597
+ progressItems.innerHTML = '';
598
+
599
+ // 각 파일에 대한 진행 항목 생성
600
+ const progressMap = new Map();
601
+ files.forEach((file, index) => {
602
+ const item = document.createElement('div');
603
+ item.className = 'progress-item';
604
+ item.id = `progress-item-${index}`;
605
+ item.innerHTML = `
606
+ <span class="progress-item-name">${escapeHtml(file.name)}</span>
607
+ <span class="progress-item-status uploading" id="progress-status-${index}">
608
+ <span class="spinner"></span>업로드 중...
609
+ </span>
610
+ `;
611
+ progressItems.appendChild(item);
612
+ progressMap.set(index, { file, item, status: 'uploading' });
613
+ });
614
+
615
+ fileUploadStatus.textContent = `총 ${files.length}개 파일 업로드 중...`;
616
+ fileUploadStatus.className = 'file-upload-status progress';
617
+
618
+ let successCount = 0;
619
+ let failCount = 0;
620
+ const errors = [];
621
+
622
+ // 파일을 순차적으로 업로드
623
+ for (let i = 0; i < files.length; i++) {
624
+ const file = files[i];
625
+ const formData = new FormData();
626
+ formData.append('file', file);
627
+ formData.append('model_name', modelName);
628
+
629
+ // 이어서 업로드인 경우 parent_file_id 추가
630
+ if (continueUploadFileId) {
631
+ formData.append('parent_file_id', continueUploadFileId);
632
+ }
633
+
634
+ const statusElement = document.getElementById(`progress-status-${i}`);
635
+ const itemElement = document.getElementById(`progress-item-${i}`);
636
+
637
+ try {
638
+ console.log(`[업로드 시작] 파일: ${file.name}, 크기: ${file.size} bytes, 모델: ${modelName}`);
639
+
640
+ const response = await fetch('/api/upload', {
641
+ method: 'POST',
642
+ body: formData
643
+ });
644
+
645
+ console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
646
+
647
+ let data;
648
+ let responseText = '';
649
+ try {
650
+ responseText = await response.text();
651
+ console.log(`[응답 본문] ${responseText.substring(0, 500)}`);
652
+ data = JSON.parse(responseText);
653
+ } catch (jsonError) {
654
+ // JSON 파싱 실패 시 응답 텍스트 사용
655
+ console.error(`[JSON 파싱 실패]`, jsonError, `응답 텍스트:`, responseText);
656
+ throw new Error(`서버 응답 오류 (${response.status}): ${responseText.substring(0, 200)}`);
657
+ }
658
+
659
+ if (response.ok) {
660
+ successCount++;
661
+ const modelName = data.model_name || '알 수 없음';
662
+ const chunkCount = data.chunk_count || 0;
663
+ statusElement.className = 'progress-item-status success';
664
+ statusElement.innerHTML = '✓ 완료';
665
+ statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
666
+ itemElement.style.opacity = '0.7';
667
+ console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}, 청크: ${chunkCount}개`);
668
+ } else {
669
+ failCount++;
670
+ const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
671
+ statusElement.className = 'progress-item-status error';
672
+ statusElement.innerHTML = '✗ 실패';
673
+ statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
674
+ statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
675
+ errors.push(`${file.name}: ${errorMsg}`);
676
+ console.error(`[업로드 실패] 파일: ${file.name}`, {
677
+ status: response.status,
678
+ statusText: response.statusText,
679
+ error: errorMsg,
680
+ data: data,
681
+ responseText: responseText
682
+ });
683
+ }
684
+ } catch (error) {
685
+ failCount++;
686
+ const errorMsg = error.message || '네트워크 오류';
687
+ statusElement.className = 'progress-item-status error';
688
+ statusElement.innerHTML = '✗ 실패';
689
+ statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
690
+ statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
691
+ errors.push(`${file.name}: ${errorMsg}`);
692
+ console.error(`[업로드 예외] 파일: ${file.name}`, error);
693
+ console.error(`[스택 트레이스]`, error.stack);
694
+ }
695
+
696
+ // 진행 상태 업데이트
697
+ fileUploadStatus.textContent = `업로드 중... (${i + 1}/${files.length})`;
698
+ }
699
+
700
+ // 업로드 완료 처리
701
+ fileUploadStatus.className = 'file-upload-status';
702
+ if (successCount > 0) {
703
+ fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
704
+ fileUploadStatus.className = 'file-upload-status success';
705
+ showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
706
+ loadFiles();
707
+ } else {
708
+ fileUploadStatus.textContent = '모든 파일 업로드 실패';
709
+ fileUploadStatus.className = 'file-upload-status error';
710
+ const errorDetails = errors.length > 0 ? '\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n... 외 ${errors.length - 3}개 오류` : '') : '';
711
+ showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
712
+ }
713
+
714
+ // UI 활성화
715
+ fileUploadWrapper.classList.remove('disabled');
716
+ fileModelSelect.disabled = false;
717
+ fileInput.disabled = false;
718
+ fileInput.value = '';
719
+
720
+ // 3초 후 진행 상태 숨기기
721
+ setTimeout(() => {
722
+ progressContainer.classList.remove('active');
723
+ fileUploadStatus.textContent = '';
724
+ }, 3000);
725
+ }
726
+
727
+ // 파일 삭제
728
+ async function deleteFile(fileId) {
729
+ if (!confirm('이 파일을 삭제하시겠습니까?\n원본 파일인 경우 이어서 업로드한 모든 회차도 함께 삭제됩니다.')) {
730
+ return;
731
+ }
732
+
733
+ try {
734
+ const response = await fetch(`/api/files/${fileId}`, {
735
+ method: 'DELETE'
736
+ });
737
+
738
+ const data = await response.json();
739
+
740
+ if (response.ok) {
741
+ if (data.deleted_count > 1) {
742
+ showAlert(`${data.message} (삭제된 파일: ${data.deleted_files.join(', ')})`, 'success');
743
+ } else {
744
+ showAlert(data.message, 'success');
745
+ }
746
+ loadFiles();
747
+ } else {
748
+ showAlert(data.error || '삭제 중 오류가 발생했습니다.', 'error');
749
+ }
750
+ } catch (error) {
751
+ showAlert(`오류: ${error.message}`, 'error');
752
+ }
753
+ }
754
+
755
+ // 이어서 업로드
756
+ let continueUploadFileId = null;
757
+
758
+ function continueUpload(fileId) {
759
+ continueUploadFileId = fileId;
760
+ // 모델 선택 확인
761
+ const modelName = fileModelSelect.value;
762
+ if (!modelName) {
763
+ showAlert('먼저 AI 모델을 선택해주세요.', 'error');
764
+ continueUploadFileId = null;
765
+ return;
766
+ }
767
+ fileInput.click(); // 파일 선택 다이얼로그 열기
768
+ }
769
+
770
+ // 파일 입력 이벤트
771
+ fileInput.addEventListener('change', function(e) {
772
+ if (e.target.files.length > 0) {
773
+ handleFileUpload(Array.from(e.target.files));
774
+ }
775
+ // 이어서 업로드 모드 초기화
776
+ continueUploadFileId = null;
777
+ });
778
+
779
+ // 드래그 앤 드롭
780
+ fileUploadWrapper.addEventListener('dragover', (e) => {
781
+ e.preventDefault();
782
+ fileUploadWrapper.classList.add('dragover');
783
+ });
784
+
785
+ fileUploadWrapper.addEventListener('dragleave', () => {
786
+ fileUploadWrapper.classList.remove('dragover');
787
+ });
788
+
789
+ fileUploadWrapper.addEventListener('drop', (e) => {
790
+ e.preventDefault();
791
+ fileUploadWrapper.classList.remove('dragover');
792
+
793
+ const files = Array.from(e.dataTransfer.files);
794
+ if (files.length > 0) {
795
+ handleFileUpload(files);
796
+ }
797
+ });
798
+
799
+ // 페이지 로드 시 초기화
800
+ window.addEventListener('load', () => {
801
+ loadModelsForFiles();
802
+ loadFiles();
803
+ });
804
+ </script>
805
+ </body>
806
+ </html>
807
+