ginipick commited on
Commit
8ed5fed
·
verified ·
1 Parent(s): 7a7453d

Upload 9 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ 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
+ core.cpython-312-x86_64-linux-gnu.so filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ zip nodejs npm && \
5
+ rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY core.cpython-312-x86_64-linux-gnu.so ./
13
+ COPY engine.dat app.py index.html sample_hanji.hwpx ./
14
+ COPY emergence_matrix_document.json ./
15
+ COPY hwpx_templates/ ./hwpx_templates/
16
+
17
+ # 검증
18
+ RUN python -c "from core import soma_pipeline, generate_hwpx, hwpx_to_html_preview; print('✅ core.so 검증 통과')"
19
+
20
+ EXPOSE 7860
21
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,14 +1,10 @@
1
  ---
2
- title: Hwp Agent
3
- emoji: 🐢
4
- colorFrom: blue
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.9.0
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- short_description: hwp-agent
12
  ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: HWP-Agent
3
+ emoji: 📄
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
 
 
 
9
  ---
10
+ # 한지(HANJI) · On-Premise HWP AI Agent
 
app.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # 한지(HANJI) · HWP AI Agent — Application Server
3
+ # core.py (또는 core.so) 에서 엔진 로드
4
+ # ============================================================
5
+ import os, re, json, tempfile, threading, zipfile
6
+ from pathlib import Path
7
+
8
+ import gradio as gr
9
+
10
+ # ── 핵심 엔진 로드 (core.py 또는 core.so) ──
11
+ from core import (
12
+ soma_pipeline,
13
+ generate_hwpx,
14
+ hwpx_to_html_preview,
15
+ process_uploaded_file,
16
+ normalize_text_for_title,
17
+ ohaeng_cards_html,
18
+ analyze_hwpx_styles,
19
+ page_guard_check,
20
+ doc_chat_respond,
21
+ _viewer_empty,
22
+ _viewer_empty_fullpage,
23
+ _install_hwpjs,
24
+ _is_binary_hwp,
25
+ _HWPJS_READY,
26
+ _HWP_CONVERT_GUIDE,
27
+ FIREWORKS_API_KEY,
28
+ FIREWORKS_MODEL,
29
+ FIREWORKS_URL,
30
+ DOC_CHAT_SYSTEM,
31
+ )
32
+ import requests # chat API에서 사용
33
+
34
+ # ============================================================
35
+ # ⑦ Gradio UI — v7 HANJI · Single-Page HWP Viewer Style
36
+ # ============================================================
37
+
38
+ # ── 기본 샘플 문서 (서비스 시작 시 뷰어에 표시) ──
39
+ _SAMPLE_HWPX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample_hanji.hwpx")
40
+ _SAMPLE_PREVIEW = ""
41
+ if os.path.exists(_SAMPLE_HWPX):
42
+ try:
43
+ _SAMPLE_PREVIEW = hwpx_to_html_preview(_SAMPLE_HWPX)
44
+ print(f"✅ 기본 샘플 미리보기 로드: sample_hanji.hwpx ({os.path.getsize(_SAMPLE_HWPX):,}B)")
45
+ except Exception as _se:
46
+ print(f"⚠️ 샘플 미리보기 실패: {_se}")
47
+ if not _SAMPLE_PREVIEW:
48
+ _SAMPLE_PREVIEW = _viewer_empty_fullpage("문서를 생성하거나 레퍼런스를 업로드하면 여기에 표시됩니다.")
49
+
50
+ SOMA_CUSTOM_CSS = """
51
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
52
+ .soma-topbar{display:flex;align-items:center;gap:10px;padding:8px 16px;
53
+ background:#fff;border:1px solid #e8ecf1;border-radius:12px;margin-bottom:8px;}
54
+ .soma-logo{font-weight:900;font-size:15px;color:#1e1b4b;}
55
+ .soma-logo em{font-style:normal;font-weight:400;font-size:11px;color:#475569;margin-left:2px;}
56
+ .soma-sep{width:1px;height:18px;background:#e8ecf1;}
57
+ .soma-desc{font-size:11px;color:#475569;font-weight:500;}
58
+ .soma-url{display:inline-flex;align-items:center;gap:4px;padding:3px 12px;border-radius:20px;
59
+ font-family:monospace;font-size:9.5px;font-weight:600;
60
+ background:linear-gradient(135deg,#4f46e5,#4338ca);color:#fff;text-decoration:none;
61
+ box-shadow:0 2px 8px rgba(79,70,229,.2);}
62
+ .soma-url:hover{box-shadow:0 4px 14px rgba(79,70,229,.35);}
63
+ .soma-right{margin-left:auto;display:flex;align-items:center;gap:8px;}
64
+ .soma-contact{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:20px;
65
+ font-family:monospace;font-size:8.5px;font-weight:600;color:#0d9488;
66
+ background:rgba(13,148,136,.06);border:1px solid rgba(13,148,136,.12);text-decoration:none;}
67
+ button.primary{background:linear-gradient(135deg,#6366f1,#4f46e5)!important;border:none!important;
68
+ box-shadow:0 4px 12px rgba(99,102,241,.25)!important;font-weight:700!important;border-radius:10px!important;}
69
+ button.primary:hover{box-shadow:0 6px 20px rgba(99,102,241,.35)!important;}
70
+ .viewer-panel{min-height:700px;}
71
+ .viewer-panel .gr-html{min-height:680px;}
72
+ """
73
+
74
+ def build_ui():
75
+ with gr.Blocks(title="한지(HANJI) · HWP AI Agent 서비스") as app:
76
+
77
+ gr.HTML(f"<style>{SOMA_CUSTOM_CSS}</style>")
78
+
79
+ # ── Top Bar ──
80
+ gr.HTML("""
81
+ <div class="soma-topbar">
82
+ <span class="soma-logo">한지<em>(HANJI)</em></span>
83
+ <span class="soma-sep"></span>
84
+ <span class="soma-desc">HWP AI Agent 서비스</span>
85
+ <a class="soma-url" href="https://hanji.ginigen.ai" target="_blank">🔗 hanji.ginigen.ai</a>
86
+ <span class="soma-right">
87
+ <a class="soma-contact" href="mailto:ginigenaihp@gmail.com">📧 문의 · 온프레미스 · 제휴</a>
88
+ </span>
89
+ </div>""")
90
+
91
+ # ── States ──
92
+ ref_text_state = gr.State("")
93
+ ref_hwpx_path_state = gr.State("")
94
+ state = gr.State({"final_doc": "", "search_count": 0})
95
+ dummy_state = gr.State("")
96
+ doc_text_state = gr.State("")
97
+
98
+ # ══════════════════════════════════════════════════
99
+ # MAIN LAYOUT: Left 1/3 Controls | Right 2/3 Viewer
100
+ # ══════════════════════════════════════════════════
101
+ with gr.Row(equal_height=False):
102
+
103
+ # ── LEFT PANEL (1/3) ──────────────────────────
104
+ with gr.Column(scale=1, min_width=320):
105
+
106
+ # Prompt
107
+ prompt_input = gr.Textbox(
108
+ label="📌 프롬프트",
109
+ placeholder="예: 2026년 AI 보안 유망기업 육성 지원사업 공모 안내문을 작���해주세요.",
110
+ lines=3)
111
+
112
+ # File upload
113
+ ref_file_upload = gr.File(
114
+ label="📎 레퍼런스 문서",
115
+ file_types=[".hwp",".hwpx",".hml",".pdf",".docx",".txt",".md",
116
+ ".csv",".json",".xml",".xlsx",".xls",".py",".html",".log"])
117
+ ref_upload_status = gr.Textbox(label="파일 상태", interactive=False, lines=2,
118
+ placeholder="레퍼런스 파일을 업로드하면 여기에 상태가 표시됩니다.")
119
+
120
+ # Settings (compact)
121
+ with gr.Row():
122
+ max_search_slider = gr.Slider(minimum=5, maximum=100, value=20, step=5,
123
+ label="🔍 검색", scale=1)
124
+ temperature_slider = gr.Slider(minimum=0.1, maximum=1.0, value=0.6, step=0.05,
125
+ label="🌡 Temp", scale=1)
126
+
127
+ # Action buttons
128
+ with gr.Row():
129
+ run_btn = gr.Button("🚀 문서 생성", variant="primary", scale=2)
130
+ stop_btn = gr.Button("⛔", variant="secondary", scale=0)
131
+
132
+ # Status indicator
133
+ search_counter = gr.Markdown("대기 중")
134
+
135
+ # HWPX Download
136
+ with gr.Row():
137
+ gen_hml_btn = gr.Button("📥 HWPX 변환", variant="primary", scale=2)
138
+ copy_text_btn = gr.Button("📋", variant="secondary", scale=0)
139
+ hml_status = gr.Textbox(label="", interactive=False, value="",
140
+ placeholder="HWPX 변환 상태", lines=1)
141
+ hml_file = gr.File(label="다운로드", file_types=[".hwpx"], visible=True)
142
+
143
+ # Generated text (collapsed)
144
+ with gr.Accordion("📝 생성된 텍스트", open=False):
145
+ final_doc_box = gr.Textbox(label="", value="", interactive=True, lines=12,
146
+ placeholder="SOMA 파이프라인 실행 후 최종 문서 텍스트")
147
+
148
+ # Pipeline internals (collapsed)
149
+ with gr.Accordion("🧬 파이프라인 로그", open=False):
150
+ agent_stream = gr.Textbox(label="Agent Stream", value="", interactive=False, lines=6)
151
+ search_log_box = gr.Textbox(label="Search Log", value="", interactive=False, lines=4)
152
+ agent_log_box = gr.Textbox(label="Pipeline Log", value="", interactive=False, lines=6)
153
+
154
+ # Doc Chat (collapsed)
155
+ with gr.Accordion("📎 문서 분석 챗", open=False):
156
+ doc_upload = gr.File(label="📄 문서 업로드",
157
+ file_types=[".hwp",".hwpx",".hml",".pdf",".docx",".txt",".md",
158
+ ".csv",".json",".xml",".xlsx",".xls",".py",".html",".log"])
159
+ doc_upload_status = gr.Textbox(label="", interactive=False, lines=1)
160
+ doc_chatbot = gr.Chatbot(label="💬 Chat", height=200)
161
+ with gr.Row():
162
+ doc_msg = gr.Textbox(label="", placeholder="질문하세요...", lines=1, scale=4)
163
+ doc_send_btn = gr.Button("🚀", variant="primary", scale=0)
164
+ doc_clear_btn = gr.Button("🗑️ Clear", size="sm")
165
+
166
+ # ── RIGHT PANEL (2/3) — DOCUMENT VIEWER ──────
167
+ with gr.Column(scale=2, min_width=500, elem_classes=["viewer-panel"]):
168
+ viewer_main = gr.HTML(value=_SAMPLE_PREVIEW)
169
+
170
+ # ── Hidden component for ohaeng (pipeline needs it) ──
171
+ ohaeng_display = gr.HTML(value="", visible=False)
172
+
173
+ # ── Event Handlers
174
+ def handle_ref_upload(file):
175
+ if file is None:
176
+ return "", "", "", _viewer_empty("파일을 선택하면 여기에 미리보기가 표시됩니다.")
177
+ fpath = file.name if hasattr(file, 'name') else str(file)
178
+ fname = os.path.basename(fpath)
179
+ ext = Path(fpath).suffix.lower()
180
+
181
+ # ── 바이너리 HWP 감지 → 변환 안내 ──
182
+ if ext == '.hwp' and _is_binary_hwp(fpath):
183
+ text, err = process_uploaded_file(fpath)
184
+ preview = hwpx_to_html_preview(fpath)
185
+ if text:
186
+ status = f"📄 {fname} ({len(text):,}자 추출)\n\n{_HWP_CONVERT_GUIDE}"
187
+ return text, status, "", preview
188
+ return "", f"❌ {fname}: {err}", "", preview
189
+
190
+ # ── HWPX 파일 → 스타일 복원 모드 ──
191
+ hwpx_path = ""
192
+ if ext == '.hwpx':
193
+ try:
194
+ with zipfile.ZipFile(fpath, 'r') as zf:
195
+ if 'Contents/header.xml' in zf.namelist():
196
+ hwpx_path = fpath
197
+ styles = analyze_hwpx_styles(fpath)
198
+ print(f"📋 레퍼런스 HWPX 분석 완료: charPr {styles['char_count']}개, "
199
+ f"paraPr {styles['para_count']}개, "
200
+ f"borderFill {styles['bf_count']}개")
201
+ except:
202
+ pass
203
+
204
+ # ── 그 외 파일 ──
205
+ text, err = process_uploaded_file(fpath)
206
+ # 뷰어: HWP/HWPX만 렌더링
207
+ if ext in ('.hwp', '.hwpx'):
208
+ preview = hwpx_to_html_preview(fpath)
209
+ else:
210
+ preview = _viewer_empty(f"{fname} — HWP/HWPX 파일만 미리보기 지원됩니다.")
211
+ if text:
212
+ status = f"✅ {fname} ({len(text):,}자)"
213
+ if hwpx_path:
214
+ status += " 🎯 HWPX 스타일 복원 모드 활성"
215
+ status += "\n💡 원본 서식만 사용, 내용은 프롬프트 주제로 새로 생성됩니다"
216
+ return text, status, hwpx_path, preview
217
+ return "", f"❌ {fname}: {err}", "", preview
218
+
219
+ ref_file_upload.change(fn=handle_ref_upload, inputs=[ref_file_upload],
220
+ outputs=[ref_text_state, ref_upload_status, ref_hwpx_path_state, viewer_main])
221
+
222
+ def run_soma(prompt, max_search, temperature, ref_text, ref_hwpx_path=""):
223
+ if not prompt.strip():
224
+ yield (ohaeng_cards_html("水"), "⚠️ 프롬프트를 입력하세요.", "", "", "", "대기 중", "")
225
+ return
226
+
227
+ full_prompt = prompt
228
+ if ref_text and ref_text.strip():
229
+ # HWPX 레퍼런스가 있으면 → 서식 전용, AI 프롬프트에 원본 내용 미주입
230
+ # (SectionCloner가 서식을 처리하므로 내용은 프롬프트 주제만으로 생성)
231
+ if ref_hwpx_path and os.path.exists(ref_hwpx_path):
232
+ # HWPX 모드: 서식 구조 힌트만 간략히 추가
233
+ full_prompt = (
234
+ f"{prompt}\n\n"
235
+ f"[서식 참고: 공문서 형식으로 작성. "
236
+ f"# 제목, ## Ⅰ.~Ⅴ. 섹션, ### 소항목, □ 핵심항목, ○ 세부사항, "
237
+ f"| 표 | 형식을 활용하여 체계적으로 구성할 것]"
238
+ )
239
+ else:
240
+ # 일반 파일(PDF/TXT 등): 참고자료로 활용
241
+ ref_content = ref_text.strip()[:8000]
242
+ full_prompt = f"{prompt}\n\n[참고자료]\n{ref_content}"
243
+
244
+ stream_acc, log_acc, search_log, final_doc = "", "", "", ""
245
+ active_agent, sc = "水", 0
246
+
247
+ for chunk in soma_pipeline(full_prompt, int(max_search), temperature):
248
+ if chunk.get("done"):
249
+ final_doc = chunk.get("final_doc", "")
250
+ sc = chunk.get("search_count", sc)
251
+ log_acc = chunk.get("log", "")
252
+ search_log = chunk.get("search_log", "")
253
+ break
254
+
255
+ active_agent = chunk.get("active", active_agent)
256
+ tok = chunk.get("stream", "")
257
+ if tok:
258
+ stream_acc += tok
259
+ if len(stream_acc) > 8000:
260
+ stream_acc = "...(이전 생략)...\n" + stream_acc[-6000:]
261
+
262
+ if chunk.get("log"): log_acc = chunk["log"]
263
+ if chunk.get("search_log"): search_log = chunk["search_log"]
264
+ if chunk.get("search_count") is not None: sc = chunk["search_count"]
265
+ if chunk.get("final_doc"): final_doc = chunk["final_doc"]
266
+
267
+ yield (ohaeng_cards_html(active_agent), stream_acc, search_log, log_acc,
268
+ final_doc if final_doc else "", f"🔍 {sc} / {int(max_search)}", "")
269
+
270
+ yield (ohaeng_cards_html("金"), stream_acc + "\n\n🎉 완료!", search_log, log_acc,
271
+ final_doc, f"✅ 완료: {sc}회 검색", final_doc)
272
+
273
+ run_btn.click(
274
+ fn=run_soma,
275
+ inputs=[prompt_input, max_search_slider, temperature_slider, ref_text_state, ref_hwpx_path_state],
276
+ outputs=[ohaeng_display, agent_stream, search_log_box, agent_log_box, final_doc_box, search_counter, dummy_state])
277
+
278
+ def make_hml(doc_text, ref_hwpx_path):
279
+ if not doc_text or not doc_text.strip():
280
+ return None, "❌ 문서를 먼저 생성하세요.", _viewer_empty("HWPX 생성 후 여기에 표시됩니다.")
281
+ try:
282
+ # 레퍼런스 HWPX가 있으면 스타일 복원 모드
283
+ if ref_hwpx_path and os.path.exists(ref_hwpx_path):
284
+ path = generate_hwpx(doc_text.strip(), ref_hwpx_path=ref_hwpx_path)
285
+ mode = "🎯 레퍼런스 스타일 복원"
286
+ else:
287
+ path = generate_hwpx(doc_text.strip())
288
+ mode = "📄 report 템플릿"
289
+
290
+ title = normalize_text_for_title(doc_text.strip())
291
+ safe_title = re.sub(r'[\\/:*?"<>|]', '', title)[:40].strip() or "문서"
292
+ new_path = os.path.join(os.path.dirname(path), f"{safe_title}.hwpx")
293
+ os.rename(path, new_path)
294
+
295
+ # page_guard 결과 표시
296
+ status = f"✅ 생성 완료 ({mode})"
297
+ if ref_hwpx_path and os.path.exists(ref_hwpx_path):
298
+ guard = page_guard_check(ref_hwpx_path, new_path)
299
+ if guard["status"] == "PASS":
300
+ status += f" | 📏 page_guard PASS (ref={guard['ref_chars']}자 → out={guard['out_chars']}자)"
301
+ else:
302
+ status += f" | ⚠️ page_guard {len(guard['errors'])}건 경고"
303
+
304
+ # 생성된 HWPX 뷰어 렌더링
305
+ preview = hwpx_to_html_preview(new_path)
306
+ return new_path, status, preview
307
+
308
+ except Exception as e:
309
+ return None, f"❌ 오류: {e}", _viewer_empty(f"생성 오류: {e}")
310
+
311
+ gen_hml_btn.click(fn=make_hml, inputs=[final_doc_box, ref_hwpx_path_state],
312
+ outputs=[hml_file, hml_status, viewer_main])
313
+
314
+ # Doc Chat handlers
315
+ def handle_doc_upload(file):
316
+ if file is None:
317
+ return "", "파일을 선택해주세요."
318
+ fpath = file.name if hasattr(file, 'name') else str(file)
319
+ fname = os.path.basename(fpath)
320
+ ext = Path(fpath).suffix.lower()
321
+
322
+ # 바이너리 HWP 감지
323
+ is_bin_hwp = (ext == '.hwp' and _is_binary_hwp(fpath))
324
+
325
+ text, err = process_uploaded_file(fpath)
326
+ if text:
327
+ status = f"✅ {fname} ({len(text):,}자)"
328
+ if is_bin_hwp:
329
+ status = f"📄 {fname} ({len(text):,}자 추출) — 바이너리 HWP (텍스트만 추출됨)"
330
+ return text, status
331
+ return "", f"❌ {fname}: {err}"
332
+
333
+ doc_upload.change(fn=handle_doc_upload, inputs=[doc_upload], outputs=[doc_text_state, doc_upload_status])
334
+ doc_send_btn.click(fn=doc_chat_respond, inputs=[doc_msg, doc_chatbot, doc_text_state], outputs=[doc_chatbot])
335
+ doc_msg.submit(fn=doc_chat_respond, inputs=[doc_msg, doc_chatbot, doc_text_state], outputs=[doc_chatbot])
336
+ doc_clear_btn.click(fn=lambda: ([], ""), outputs=[doc_chatbot, doc_text_state])
337
+
338
+ return app
339
+
340
+
341
+ # ============================================================
342
+ # ⑧ Entry Point — FastAPI 메인 + Gradio 서브마운트
343
+ # ============================================================
344
+ from fastapi import FastAPI, Request as _FAReq
345
+ from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
346
+ import uvicorn
347
+
348
+ # ── FastAPI 메인 앱 ──
349
+ app = FastAPI()
350
+
351
+ _APP_DIR = os.path.dirname(os.path.abspath(__file__))
352
+ _index_path = os.path.join(_APP_DIR, "index.html")
353
+
354
+ # ── ohah/hwpjs 백그라운드 설치 ──
355
+ threading.Thread(target=_install_hwpjs, daemon=True).start()
356
+
357
+
358
+ # ── "/" → index.html 서빙 ──
359
+ @app.get("/")
360
+ async def _serve_index():
361
+ if os.path.exists(_index_path):
362
+ return FileResponse(_index_path, media_type="text/html")
363
+ return HTMLResponse("<h1>index.html not found</h1>", status_code=404)
364
+
365
+ @app.get("/ui")
366
+ async def _serve_ui():
367
+ return await _serve_index()
368
+
369
+
370
+ # ── SOMA API ──
371
+ import asyncio as _asyncio
372
+ import queue as _queue
373
+
374
+ _file_registry = {}
375
+ _doc_text_store = {} # sid → text
376
+
377
+ @app.post("/soma/run")
378
+ async def _soma_run(req: _FAReq):
379
+ try:
380
+ body = await req.json()
381
+ prompt = body.get("prompt", "").strip()
382
+ max_search = int(body.get("max_search", 20))
383
+ temperature = float(body.get("temperature", 0.6))
384
+ ref_text = body.get("ref_text", "") # 직접 전달
385
+ ref_sid = body.get("ref_sid", "") # doc-upload에서 받은 sid
386
+ if not ref_text and ref_sid:
387
+ ref_text = _doc_text_store.get(ref_sid, "")
388
+ if not prompt:
389
+ return JSONResponse({"error": "prompt 없음"}, status_code=400)
390
+
391
+ # 레퍼런스 텍스트가 있으면 프롬프트에 추가
392
+ full_prompt = prompt
393
+ if ref_text and ref_text.strip():
394
+ ref_snippet = ref_text.strip()[:8000]
395
+ full_prompt = f"{prompt}\n\n[참고자료]\n{ref_snippet}"
396
+
397
+ # 동기 제너레이터를 별도 스레드에서 실행 → 이벤트 루프 블로킹 방지
398
+ q = _queue.Queue()
399
+
400
+ def _run_in_thread():
401
+ try:
402
+ for chunk in soma_pipeline(full_prompt, max_search, temperature):
403
+ q.put(json.dumps(chunk, ensure_ascii=False))
404
+ except Exception as e:
405
+ q.put(json.dumps({"error": str(e), "done": True}))
406
+ finally:
407
+ q.put(None) # 종료 시그널
408
+
409
+ threading.Thread(target=_run_in_thread, daemon=True).start()
410
+
411
+ async def _async_generate():
412
+ while True:
413
+ # 큐에서 비동기로 가져오기 (이벤트 루프 블로킹 없음)
414
+ try:
415
+ item = await _asyncio.get_event_loop().run_in_executor(
416
+ None, lambda: q.get(timeout=300))
417
+ except:
418
+ break
419
+ if item is None:
420
+ yield "data: [DONE]\n\n"
421
+ break
422
+ yield f"data: {item}\n\n"
423
+
424
+ return StreamingResponse(_async_generate(),
425
+ media_type="text/event-stream",
426
+ headers={"Cache-Control": "no-cache",
427
+ "X-Accel-Buffering": "no"})
428
+ except Exception as e:
429
+ return JSONResponse({"error": str(e)}, status_code=500)
430
+
431
+
432
+
433
+ @app.post("/soma/hml")
434
+ async def _soma_hml(req: _FAReq):
435
+ try:
436
+ body = await req.json()
437
+ content = body.get("content", "").strip()
438
+ if not content:
439
+ return JSONResponse({"error": "content 없음"}, status_code=400)
440
+
441
+ def _blocking():
442
+ path = generate_hwpx(content)
443
+ title = normalize_text_for_title(content)
444
+ safe = re.sub(r'[\\/:*?"<>|]', '', title)[:40].strip() or "문서"
445
+ new_path = os.path.join(os.path.dirname(path), f"{safe}.hwpx")
446
+ os.rename(path, new_path)
447
+ return new_path
448
+
449
+ new_path = await _asyncio.get_event_loop().run_in_executor(None, _blocking)
450
+ fname = os.path.basename(new_path)
451
+ _file_registry[fname] = new_path
452
+
453
+ return JSONResponse({"file_url": f"/file/{fname}",
454
+ "filename": fname,
455
+ "file_path": new_path})
456
+ except Exception as e:
457
+ return JSONResponse({"error": str(e)}, status_code=500)
458
+
459
+
460
+ @app.get("/file/{fname}")
461
+ async def _serve_file(fname: str):
462
+ fpath = _file_registry.get(fname)
463
+ if fpath and os.path.exists(fpath):
464
+ return FileResponse(fpath, filename=fname,
465
+ media_type="application/octet-stream")
466
+ return JSONResponse({"error": "파일 없음"}, status_code=404)
467
+
468
+
469
+ @app.post("/soma/preview")
470
+ async def _soma_preview(req: _FAReq):
471
+ try:
472
+ body = await req.json()
473
+ if "b64" in body:
474
+ import base64 as _b64
475
+ ext = body.get("ext", ".hwpx").lower()
476
+ tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
477
+ tmp.write(_b64.b64decode(body["b64"]))
478
+ tmp.close()
479
+ fpath = tmp.name
480
+ else:
481
+ fpath = body.get("file_path", "")
482
+
483
+ if not fpath or not os.path.exists(fpath):
484
+ return HTMLResponse(_viewer_empty("파일을 찾을 수 없습니다."))
485
+
486
+ preview = await _asyncio.get_event_loop().run_in_executor(
487
+ None, hwpx_to_html_preview, fpath)
488
+ return HTMLResponse(preview)
489
+ except Exception as e:
490
+ return HTMLResponse(_viewer_empty(f"미리보기 오류: {e}"))
491
+
492
+
493
+ @app.get("/soma/status")
494
+ async def _soma_status():
495
+ return JSONResponse({
496
+ "status": "ok",
497
+ "hwpjs_ready": _HWPJS_READY,
498
+ "engine": "ohah/hwpjs WASM" if _HWPJS_READY else "Python lxml"
499
+ })
500
+
501
+ # HF Spaces 호환 — 헬스체크
502
+ @app.get("/api/health")
503
+ async def _health():
504
+ return JSONResponse({"status": "ok"})
505
+
506
+
507
+ # ── 문서 업로드 (텍스트 추출) ──
508
+
509
+ @app.post("/soma/doc-upload")
510
+ async def _soma_doc_upload(req: _FAReq):
511
+ """업로드된 문서에서 텍스트 추출 (b64 또는 file_path)"""
512
+ try:
513
+ body = await req.json()
514
+
515
+ fpath = body.get("file_path", "")
516
+ if not (fpath and os.path.exists(fpath)):
517
+ import base64 as _b64
518
+ b64 = body.get("b64", "")
519
+ fname = body.get("filename", "document")
520
+ ext = body.get("ext", ".txt").lower()
521
+ tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
522
+ tmp.write(_b64.b64decode(b64))
523
+ tmp.close()
524
+ fpath = tmp.name
525
+
526
+ text, err = await _asyncio.get_event_loop().run_in_executor(
527
+ None, process_uploaded_file, fpath)
528
+
529
+ if text:
530
+ sid = str(id(text))[-8:]
531
+ _doc_text_store[sid] = text
532
+ return JSONResponse({"ok": True, "sid": sid,
533
+ "chars": len(text),
534
+ "preview": text[:200]})
535
+ return JSONResponse({"ok": False, "error": err or "텍스트 추출 실패"})
536
+ except Exception as e:
537
+ return JSONResponse({"ok": False, "error": str(e)})
538
+
539
+
540
+ # ── 문서 QnA 챗 (SSE 스트리밍) ──
541
+ @app.post("/soma/chat")
542
+ async def _soma_chat(req: _FAReq):
543
+ """문서 기반 QnA 챗 — SSE 스트리밍"""
544
+ try:
545
+ body = await req.json()
546
+ message = body.get("message", "").strip()
547
+ sid = body.get("sid", "")
548
+ history = body.get("history", [])
549
+
550
+ if not message:
551
+ return JSONResponse({"error": "message 없음"}, status_code=400)
552
+ if not FIREWORKS_API_KEY:
553
+ return JSONResponse({"error": "FIREWORKS_API_KEY 미설정"}, status_code=500)
554
+
555
+ doc_text = _doc_text_store.get(sid, "")
556
+
557
+ # 메시지 구성
558
+ if doc_text:
559
+ user_content = f"## 📄 업로드된 문서 내용\n---\n{doc_text[:12000]}\n---\n\n## 💬 질문\n{message}\n\n위 문서 내용을 바탕으로 답변해주세요."
560
+ else:
561
+ user_content = message
562
+
563
+ api_messages = [{"role": "system", "content": DOC_CHAT_SYSTEM}]
564
+ for h in (history or [])[-6:]:
565
+ if isinstance(h, (list, tuple, dict)):
566
+ if isinstance(h, dict):
567
+ api_messages.append({"role": h.get("role","user"), "content": h.get("content","")})
568
+ elif len(h) == 2:
569
+ api_messages.append({"role": "user", "content": h[0] or ""})
570
+ api_messages.append({"role": "assistant", "content": h[1] or ""})
571
+ api_messages.append({"role": "user", "content": user_content})
572
+
573
+ q2 = _queue.Queue()
574
+
575
+ def _chat_thread():
576
+ try:
577
+ headers = {"Accept":"application/json","Content-Type":"application/json",
578
+ "Authorization": f"Bearer {FIREWORKS_API_KEY}"}
579
+ payload = {"model": FIREWORKS_MODEL, "max_tokens": 16000,
580
+ "temperature": 0.6, "stream": True, "messages": api_messages}
581
+ resp = requests.post(FIREWORKS_URL, headers=headers, json=payload,
582
+ stream=True, timeout=180)
583
+ resp.raise_for_status()
584
+ for raw_line in resp.iter_lines():
585
+ if not raw_line: continue
586
+ line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
587
+ if not line.startswith("data: "): continue
588
+ data = line[6:]
589
+ if data.strip() == "[DONE]":
590
+ break
591
+ try:
592
+ chunk = json.loads(data)
593
+ delta = chunk["choices"][0]["delta"].get("content", "")
594
+ if delta:
595
+ q2.put(json.dumps({"delta": delta}, ensure_ascii=False))
596
+ except:
597
+ pass
598
+ except Exception as e:
599
+ q2.put(json.dumps({"error": str(e)}))
600
+ finally:
601
+ q2.put(None)
602
+
603
+ threading.Thread(target=_chat_thread, daemon=True).start()
604
+
605
+ async def _async_chat():
606
+ while True:
607
+ try:
608
+ item = await _asyncio.get_event_loop().run_in_executor(
609
+ None, lambda: q2.get(timeout=300))
610
+ except:
611
+ break
612
+ if item is None:
613
+ yield "data: [DONE]\n\n"
614
+ break
615
+ yield f"data: {item}\n\n"
616
+
617
+ return StreamingResponse(_async_chat(),
618
+ media_type="text/event-stream",
619
+ headers={"Cache-Control": "no-cache",
620
+ "X-Accel-Buffering": "no"})
621
+ except Exception as e:
622
+ return JSONResponse({"error": str(e)}, status_code=500)
623
+
624
+
625
+ # ── Gradio를 /gradio 서브경로에 마운트 ──
626
+ demo = build_ui()
627
+ app = gr.mount_gradio_app(app, demo, path="/gradio")
628
+
629
+ print("✅ FastAPI 메인 서버")
630
+ print(" / → index.html")
631
+ print(" /gradio → Gradio UI")
632
+ print(" /soma/* → API")
633
+
634
+ uvicorn.run(app, host="0.0.0.0", port=7860)
core.cpython-312-x86_64-linux-gnu.so ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:480e7f95f1c788c03126ca8da468833ea268e6d6de00f5ab44ec9efbfef771f7
3
+ size 1650968
emergence_matrix_document.json ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "version": "1.0.0",
4
+ "description": "6-Layer 문서작성 창발 매트릭스 — Koestler Bisociation 기반 이종 레이어 교차로 창발적 통찰 유도",
5
+ "philosophy": "이종 레이어 간 거리가 멀수록 높은 창발성 보너스. 에이전트가 자기 레이어 외 원격 레이어의 사고 패턴을 교차 적용할 때 독창적 문서가 탄생한다.",
6
+ "system": "SOMA Pre-AGI Document Emergence Engine",
7
+ "author": "Ginigen AI (지니젠AI)"
8
+ },
9
+ "layers": {
10
+ "RESEARCH": {
11
+ "desc": "탐색·수집 — 외부 세계의 정보를 탐지하고 정제하는 인지의 입구",
12
+ "agent": "水",
13
+ "layer_index": 0,
14
+ "strategies": {
15
+ "탐색_패턴": [
16
+ "수렴적 검색: 핵심 키워드를 좁혀가며 정밀 탐색",
17
+ "발산적 검색: 연관 키워드를 확장하며 의외의 연결고리 발견",
18
+ "시계열 추적: 동일 주제의 과거→현재→미래 변화 흐름 조사",
19
+ "반증 검색: 주장을 반박하는 자료를 의도적으로 탐색",
20
+ "교차 도메인 탐색: 전혀 다른 분야에서 유사 패턴 발견",
21
+ "원전 소급: 2차 자료에서 1차 원전(법령, 논문, 통계)으로 거슬러 올라감",
22
+ "이해관계자 매핑: 주제에 영향 받는 모든 주체별 관점 수집",
23
+ "메타 검색: '이 주제에 대한 기존 보고서/연구'를 검색하여 선행 프레임 파악"
24
+ ],
25
+ "정보_품질_필터": [
26
+ "출처 위계: 공식 법령 > 정부 보고서 > 학술 논문 > 언론 > 블로그",
27
+ "시점 검증: 최신 데이터인지, 폐기된 정보인지 연도 확인",
28
+ "교차 검증: 동일 팩트를 3개 이상 독립 출처에서 확인",
29
+ "통계 맥락화: 절대값뿐 아니라 비율, 추세, 비교 대상까지 수집",
30
+ "인용 체인: 누가 누구를 인용하는지 추적하여 원천 신뢰도 평가"
31
+ ],
32
+ "발견_프레임": [
33
+ "빈칸 탐지: 기존 자료에서 다루지 않는 영역(gap) 식별",
34
+ "이상값 포착: 예상과 다른 수치/사례를 발견하면 원인 추적",
35
+ "패턴 인식: 서로 다른 자료에서 반복되는 공통 패턴 추출",
36
+ "암묵지 발굴: 공식 문서에는 없지만 현장 인터뷰/사례에서 드러나는 실질 정보"
37
+ ]
38
+ }
39
+ },
40
+ "STRUCTURE": {
41
+ "desc": "설계·조직 — 정보를 체계적 골격으로 변환하는 건축 행위",
42
+ "agent": "木",
43
+ "layer_index": 1,
44
+ "strategies": {
45
+ "구조_아키타입": [
46
+ "두괄식(역피라미드): 결론 → 근거 → 배경. 의사결정자 대상 보고서",
47
+ "미괄식(서사형): 배경 → 분석 → 결론. 설득·제안 문서",
48
+ "병렬형: 동등한 주제를 나란히 배치. 비교·대안 분석",
49
+ "계층형(드릴다운): 거시 → 미시. 정책 → 세부 실행계획",
50
+ "문제-해결형: 현황(문제) → 원인 분석 → 해결 방안 → 기대효과",
51
+ "시계열형: 과거 → 현재 → 미래. 로드맵·추진 일정",
52
+ "MECE 분해: 상호배제·전체포괄. 빠짐없는 분류 체계"
53
+ ],
54
+ "섹션_설계_원리": [
55
+ "1섹션 1메시지: 각 섹션이 전달하는 핵심 메시지 1개를 먼저 정의",
56
+ "전환 브릿지: 섹션 간 논리적 연결문으로 자연스러운 흐름 보장",
57
+ "깊이 균형: 중요 섹션은 깊게, 부수 섹션은 간결하게",
58
+ "표·그림 배치: 복잡한 데이터는 표로, 흐름은 도식으로 시각화",
59
+ "반복 삼각: 핵심 메시지를 서론·본론·결론에서 세 번 반복"
60
+ ],
61
+ "창발적_구조": [
62
+ "역설 구조: 통념과 반대되는 결론을 먼저 제시하고 논증 전개",
63
+ "렌즈 전환: 동일 주제를 경제/기술/사회/법률 렌즈로 순환 분석",
64
+ "케이스 대비: 이론 섹션마다 실제 사례를 쌍으로 배치",
65
+ "미래 역산: 목표 상태를 먼저 정의하고 현재로 역산하여 단계 설계"
66
+ ]
67
+ }
68
+ },
69
+ "ARGUMENT": {
70
+ "desc": "논증·검증 — 주장을 단련하고 오류를 소각하는 비판적 사고 엔진",
71
+ "agent": "火",
72
+ "layer_index": 2,
73
+ "strategies": {
74
+ "논증_패턴": [
75
+ "연역법: 일반 원칙 → 구체 결론. 법령·정책 근거 활용",
76
+ "귀납법: 사례 축적 → 일반화. 데이터·통계 기반",
77
+ "유추법: A 분야 성공사례 → B 분야 적용 가능성 논증",
78
+ "소거법: 가능한 대안을 하나씩 배제하여 최적안 도출",
79
+ "비용편익: 정량적 비교로 실행 타당성 논증",
80
+ "반사실 논증: '만약 이 정책이 없었다면' 가상 시나리오로 필요성 입증"
81
+ ],
82
+ "팩트체크_프로토콜": [
83
+ "수치 검증: 출처의 원본 데이터와 대조",
84
+ "논리 검증: 전제 → 결론 사이 비약이 없는지 점검",
85
+ "시점 검증: 데이터의 기준 시점이 문서 맥락에 적합한지 확인",
86
+ "범위 검증: 일부 사례를 전체로 일반화하는 오류 탐지",
87
+ "이해충돌 검증: 출처 기관의 이해관계가 데이터에 편향을 줄 가능성 점검"
88
+ ],
89
+ "비판적_메타인지": [
90
+ "자기반박: 내가 작성한 논증에 대해 가장 강력한 반론을 스스로 제기",
91
+ "맹점 탐색: 내 분석에서 빠진 관점이 무엇인지 체계적으로 점검",
92
+ "확증편향 차단: 찬성 근거만 모았는지, 반대 근거도 균형 있게 수집했는지 자문",
93
+ "불확실성 명시: 확실한 것과 추정인 것을 구분하여 표현"
94
+ ]
95
+ }
96
+ },
97
+ "EVIDENCE": {
98
+ "desc": "통합·실체화 — 흩어진 조각을 하나의 완성된 문서로 주조하는 용광로",
99
+ "agent": "土",
100
+ "layer_index": 3,
101
+ "strategies": {
102
+ "통합_기법": [
103
+ "모자이크 통합: 각 에이전트 산출물의 최선 부분을 선별·재조합",
104
+ "논리 직조: 섹션 간 인과관계와 논리 체인을 명시적으로 연결",
105
+ "톤 통일: 전체 문서의 문체·경어법·전문용어 수준을 일관되게 조율",
106
+ "분량 밸런싱: 섹션별 분량을 중요도에 비례하여 재조정",
107
+ "중복 제거: 여러 에이전트가 반복한 내용을 단일화",
108
+ "표·데이터 삽입: 논증을 뒷받침하는 정량 데이터를 표 형식으로 정리"
109
+ ],
110
+ "완성도_체크": [
111
+ "LATCH 구조 검증: Location·Alphabet·Time·Category·Hierarchy 중 적절한 분류 사용 여부",
112
+ "So-What 테스트: 각 문단의 마지막에 '그래서 어떻다는 것인가?'에 답이 있는지",
113
+ "Elevator Test: 문서 전체를 30초 요약으로 축약 가능한지",
114
+ "실행 가능성: 제안된 방안이 구체적 실행 단계를 포함하는지"
115
+ ],
116
+ "독자_최적화": [
117
+ "독자 프로파일링: 의사결정권자/실무자/전문가 등 1차 독자 명확화",
118
+ "전문용어 게이트: 독자 수준에 맞는 용어 선택, 필요시 괄호 설명 추가",
119
+ "행동 유도: 문서를 읽은 후 독자가 취해야 할 구체적 행동 명시"
120
+ ]
121
+ }
122
+ },
123
+ "NARRATIVE": {
124
+ "desc": "서사·맥락 — 팩트를 이야기로 변환하여 기억과 설득력을 증폭하는 레이어",
125
+ "agent": null,
126
+ "layer_index": 4,
127
+ "strategies": {
128
+ "서사_기법": [
129
+ "프레이밍: 동일 사실을 '위기' 또는 '기회'로 틀 짓기에 따라 설득력 변화",
130
+ "구체화: 추상적 통계를 개인·기업·지역의 구체적 스토리로 변환",
131
+ "대비 효과: 변경 전/후, 국내/해외, 현재/미래를 나란히 보여주는 극적 대비",
132
+ "진행형 서사: '현재 진행 중인 변화'를 강조하여 시급성 부여",
133
+ "영웅의 여정: 문제 발견 → 시도와 실패 → 해결책 발견 → 성과 구조"
134
+ ],
135
+ "수사_장치": [
136
+ "질문 삽입: '과연 이 정책은 효과가 있을까?' 같은 독자 참여형 질문",
137
+ "숫자의 힘: '연간 2.3조원'보다 '국민 1인당 월 3,800원' 같은 체감 환산",
138
+ "유추: 복잡한 시스템을 일상적 비유로 설명",
139
+ "점진적 공개: 결론을 즉시 노출하지 않고 단서를 쌓아가며 논리적 긴장감 조성"
140
+ ],
141
+ "맥락_설정": [
142
+ "역사적 맥락: 이 정책/기술이 필요하게 된 역사적 배경",
143
+ "글로벌 맥락: 해외 주요국의 동향과 한국의 위치",
144
+ "기술적 맥락: 기반 기술의 성숙도와 실현 가능성",
145
+ "제도적 맥락: 관련 법령·규정·거버넌스 체계"
146
+ ]
147
+ }
148
+ },
149
+ "IMPACT": {
150
+ "desc": "영향·가치 — 문서가 현실에 미치는 파급력과 의사결정 촉진력을 극대화",
151
+ "agent": "金",
152
+ "layer_index": 5,
153
+ "strategies": {
154
+ "영향력_증폭": [
155
+ "정량 기대효과: 비용 절감, 매출 증대, 효율 향상 등 수치로 표현",
156
+ "정성 기대효과: 국민 편익, 산업 경쟁력, 사회적 가치 등 질적 효과",
157
+ "리스크 완화: 미시행 시 발생할 부정적 영향을 대비 제시",
158
+ "단계별 마일스톤: 단기(3개월)/중기(1년)/장기(3년) 성과 로드맵",
159
+ "파급 효과 체인: 1차 효과 → 2차 파생 효과 → 산업 생태계 변화"
160
+ ],
161
+ "최종_정제": [
162
+ "문체 일관성: 전체 문서의 어조·경어법·번호 체계 통일",
163
+ "용어 정밀도: 동의어 반복 제거, 약어 첫 등장시 풀네임 병기",
164
+ "논리 완결성: 서론의 문제제기가 결론에서 해결되는지 확인",
165
+ "형식 점검: 제목·번호·표·참고문헌 등 서식 규칙 준수",
166
+ "가독성 최적화: 문장 길이, 단락 크기, 여백 균형"
167
+ ],
168
+ "메타인지_자기검증": [
169
+ "오류 복구: 이전 단계 에이전트의 실수를 감지하고 자체 수정",
170
+ "자기 반성: 내 정제가 과도하지 않은지, 원래 의도를 훼손하지 않았는지 점검",
171
+ "확신도 태깅: 각 주장에 대한 확신도(높음/중간/낮음)를 내부적으로 평가",
172
+ "개선 루프: 수정한 부분이 새로운 문제를 야기하지 않는지 재검토"
173
+ ]
174
+ }
175
+ }
176
+ },
177
+ "cross_layer_bonus": {
178
+ "_desc": "레이어 간 거리가 멀수록 교차 적용 시 창발성이 높다. 값 = 창발성 보너스 가중치.",
179
+ "RESEARCH↔IMPACT": {
180
+ "bonus": 0.15,
181
+ "hint": "데이터에서 바로 시사점을 도출하면 가장 독창적 통찰이 탄생한다. 수집 단계에서 이미 '이 데이터가 의사결정에 어떤 영향을 줄까?'를 자문하라."
182
+ },
183
+ "RESEARCH↔NARRATIVE": {
184
+ "bonus": 0.12,
185
+ "hint": "건조한 데이터를 수집하면서 동시에 '이것이 어떤 이야기가 될 수 있을까?'를 상상하라."
186
+ },
187
+ "STRUCTURE↔IMPACT": {
188
+ "bonus": 0.11,
189
+ "hint": "구조 설계 단계에서 이미 '각 섹션이 최종 의사결정에 어떻게 기여하는가?'를 고려하라."
190
+ },
191
+ "STRUCTURE↔EVIDENCE": {
192
+ "bonus": 0.10,
193
+ "hint": "골격과 살을 동시에 고려하라. 구조가 근거를 자연스럽게 배치할 수 있는 형태인지 검증."
194
+ },
195
+ "ARGUMENT↔NARRATIVE": {
196
+ "bonus": 0.09,
197
+ "hint": "논증과 서사를 교차하라. 차가운 논리에 따뜻한 이야기를 입히면 설득력이 배가된다."
198
+ },
199
+ "RESEARCH↔ARGUMENT": {
200
+ "bonus": 0.06,
201
+ "hint": "수집과 검증을 동시에 수행하라. 자료를 모으면서 신뢰성을 실시간 평가."
202
+ },
203
+ "EVIDENCE↔IMPACT": {
204
+ "bonus": 0.05,
205
+ "hint": "통합 작성 시 최종 임팩트를 염두에 두면 불필요한 내용이 자연히 걸러진다."
206
+ }
207
+ },
208
+ "writing_principles": [
209
+ {
210
+ "id": 1,
211
+ "name": "모듈화(분할)",
212
+ "hint": "복잡한 주제를 독립적 하위 모듈로 분리하여 각각을 정밀하게 다루되, 최종 통합 시 시너지를 노려라.",
213
+ "application": "대주제를 3~5개 독립 섹션으로 분해. 각 섹션이 단독으로도 의미를 갖되 전체 조합 시 더 큰 그림을 형성."
214
+ },
215
+ {
216
+ "id": 2,
217
+ "name": "반론 선제(역발상)",
218
+ "hint": "독자가 제기할 반론을 예측하여 먼저 제시하고 논파하라. '그럼에도 불구하고' 구조.",
219
+ "application": "각 제안의 예상 비판(비용, 부작용, 실현가능성)을 미리 명시하고 대응 논리를 배치."
220
+ },
221
+ {
222
+ "id": 3,
223
+ "name": "관점 전환(차원변경)",
224
+ "hint": "동일 주제를 미시↔거시, 단기↔장기, 국내↔글로벌, 공급↔수요 등 다른 차원에서 바라보라.",
225
+ "application": "하나의 정책을 정부/기업/국민/국제사회 4개 관점에서 각각 분석하여 입체적 보고서 작성."
226
+ },
227
+ {
228
+ "id": 4,
229
+ "name": "융합 제안(통합)",
230
+ "hint": "서로 관련 없어 보이는 분야를 연결하여 독창적 해법을 제시하라.",
231
+ "application": "A 분야의 해결법을 B 분야에 이식. 예: 핀테크 규제 경험을 헬스테크 규제에 적용."
232
+ },
233
+ {
234
+ "id": 5,
235
+ "name": "위기→기회(전화위복)",
236
+ "hint": "문제점·리스크를 분석할 때, 그 자체를 차별화 기회로 전환할 수 없는지 탐색하라.",
237
+ "application": "규제 장벽 → '규제 준수 선점을 통한 경쟁 우위 확보' 같은 역전 논리."
238
+ },
239
+ {
240
+ "id": 6,
241
+ "name": "자기 참조(메타인지)",
242
+ "hint": "문서 자체가 자신의 한계와 전제를 투명하게 밝히면 신뢰도가 올라간다.",
243
+ "application": "분석의 한계, 데이터 제약, 추정 범위를 명시하여 독자가 판단할 근거 제공."
244
+ },
245
+ {
246
+ "id": 7,
247
+ "name": "계단식 공개(점진적 복잡성)",
248
+ "hint": "간단한 개념에서 시작하여 점진적으로 복잡한 내용으로 진행하라.",
249
+ "application": "개요(1문단) → 핵심(2문단) → 세부(5문단) → 기술적 상세(부록) 구조."
250
+ },
251
+ {
252
+ "id": 8,
253
+ "name": "공백의 힘(추출)",
254
+ "hint": "불필요한 내용을 과감히 제거하라. 빈 공간이 핵심 메시지를 돋보이게 한다.",
255
+ "application": "각 섹션에서 '없어도 되는 문장'을 최소 20% 제거. 핵심만 남겨 밀도 향상."
256
+ }
257
+ ],
258
+ "policy_dilemmas": [
259
+ {
260
+ "dilemma": "규제 vs 혁신",
261
+ "resolution": "규제 샌드박스: 한시적 규제 유예로 혁신 실험 허용 후 결과 기반 규제 설계",
262
+ "writing_tip": "양측 논리를 균형 있게 제시한 뒤, 제3의 절충안(샌드박스/단계적 도입)을 제안"
263
+ },
264
+ {
265
+ "dilemma": "보안 vs 편의성",
266
+ "resolution": "리스크 기반 인증: 저위험 거래는 간편 인증, 고위험 거래만 강화 인증",
267
+ "writing_tip": "이분법을 피하고 '위험 수준별 차등 접근' 같은 그라데이션 해법을 제시"
268
+ },
269
+ {
270
+ "dilemma": "중앙집중 vs 분산",
271
+ "resolution": "하이브리드 거버넌스: 표준·감독은 중앙, 실행·혁신은 분산",
272
+ "writing_tip": "각 모델의 장단점을 표로 비교한 뒤 결합 모델의 시너지를 논증"
273
+ },
274
+ {
275
+ "dilemma": "성과 vs 형평",
276
+ "resolution": "차등 지원: 성과 기반 인센티브 + 기초 역량 강화 지원 병행",
277
+ "writing_tip": "경쟁과 포용의 균형점을 수치 시뮬레이션으로 제시"
278
+ },
279
+ {
280
+ "dilemma": "단기 vs 장기",
281
+ "resolution": "단계별 로드맵: 단기 Quick-Win으로 추진력 확보, 장기 구조개혁으로 지속성 확보",
282
+ "writing_tip": "로드맵 표를 활용하여 단기(~6개월)/중기(1~2년)/장기(3~5년) 마일스톤 제시"
283
+ },
284
+ {
285
+ "dilemma": "개방 vs 보호",
286
+ "resolution": "조건부 개방: 핵심 기술은 보호하되 생태계 활성화를 위해 주변 기술은 개방",
287
+ "writing_tip": "개방 범위와 보호 범위를 명확히 구분하는 매트릭스 제시"
288
+ }
289
+ ],
290
+ "perspective_transforms": [
291
+ {
292
+ "from": "미시(개별 사례)",
293
+ "to": "거시(시스템 전체)",
294
+ "hint": "개별 기업 사례에서 시작하여 산업 전체 트렌드로 확장",
295
+ "writing_tip": "사례 → 패턴 → 시사점 → 정책 제언 순서로 상향 전개"
296
+ },
297
+ {
298
+ "from": "현재(As-Is)",
299
+ "to": "미래(To-Be)",
300
+ "hint": "현재 상태를 진단한 뒤 목표 상태를 선명하게 그리고 갭을 분석",
301
+ "writing_tip": "As-Is/To-Be 비교표 + 갭 분석 + 이행 로드맵 3종 세트"
302
+ },
303
+ {
304
+ "from": "공급자 관점",
305
+ "to": "수요자 관점",
306
+ "hint": "정책 입안자/기업이 아닌 최종 사용자/국민의 시선에서 재해석",
307
+ "writing_tip": "사용자 여정 맵핑으로 Pain Point를 시각화하면 설득력이 배가"
308
+ },
309
+ {
310
+ "from": "정량(숫자)",
311
+ "to": "정성(의미)",
312
+ "hint": "통계 뒤에 숨은 인간적 의미와 사회적 맥락을 함께 서술",
313
+ "writing_tip": "수치 제시 직후 '이것이 의미하는 바는' 문장으로 해석 레이어 추가"
314
+ },
315
+ {
316
+ "from": "국내",
317
+ "to": "글로벌",
318
+ "hint": "한국 상황을 글로벌 벤치마크와 비교하여 위치 파악",
319
+ "writing_tip": "주요국 비교표(미국/EU/일본/한국)로 객관적 포지셔닝 제시"
320
+ },
321
+ {
322
+ "from": "기술",
323
+ "to": "제도",
324
+ "hint": "기술적 가능성을 제도적 실현가능성과 교차 검토",
325
+ "writing_tip": "기술 성숙도(TRL)와 규제 준비도를 2×2 매트릭스로 동시 평가"
326
+ }
327
+ ],
328
+ "metacognitive_protocols": {
329
+ "_desc": "에이전트 간 상호 검증(상극)을 메타인지적 자기검증으로 구현하는 프로토콜",
330
+ "self_reflection": {
331
+ "trigger": "각 에이전트 출력 완료 직전",
332
+ "questions": [
333
+ "내 분석에서 가장 약한 고리(weakest link)는 어디인가?",
334
+ "내가 암묵적으로 가정하고 있는 전제는 무엇인가?",
335
+ "반대 입장에서 보면 어떤 반론이 가능한가?",
336
+ "이 문서를 읽는 비전문가도 핵심을 이해할 수 있는가?"
337
+ ]
338
+ },
339
+ "error_recovery": {
340
+ "trigger": "다음 에이전트가 이전 에이전트의 출력을 받을 때",
341
+ "protocol": [
342
+ "이전 에이전트의 주장 중 근거가 불충분한 항목 식별",
343
+ "논리적 비약 또는 자기모순 탐지",
344
+ "누락된 중요 관점 추가 제안",
345
+ "수정 사항을 [수정] 태그로 명시하여 투명성 확보"
346
+ ]
347
+ },
348
+ "confidence_calibration": {
349
+ "levels": {
350
+ "HIGH": "3개 이상 독립 출처에서 교차 검증된 팩트",
351
+ "MEDIUM": "1~2개 출처 기반, 논리적으로 타당하나 추가 검증 권장",
352
+ "LOW": "추정·추론 기반, 반드시 '추정' 또는 '가능성' 표현 사용"
353
+ }
354
+ }
355
+ }
356
+ }
engine.dat ADDED
@@ -0,0 +1 @@
 
 
1
+ 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
index.html ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>한지 HANJI · On-Premise HWP AI Agent</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
8
+ <style>
9
+ *{margin:0;padding:0;box-sizing:border-box;}
10
+ :root{
11
+ --bg:#ffffff;--bg2:#f8f9fb;--bg3:#f1f3f6;
12
+ --text:#1a1a2e;--text2:#475569;--muted:#94a3b8;
13
+ --border:#e8ecf1;--border2:#d1d5db;
14
+ --accent:#4f46e5;--accent2:#6366f1;--accent-light:rgba(99,102,241,.08);
15
+ --teal:#0d9488;--teal-light:rgba(13,148,136,.08);
16
+ --amber:#d97706;--red:#dc2626;--green:#16a34a;
17
+ --r:12px;--r-sm:8px;
18
+ --font:'Noto Sans KR',sans-serif;--mono:'JetBrains Mono',monospace;
19
+ --shadow:0 4px 16px rgba(0,0,0,.06);--shadow-lg:0 8px 32px rgba(0,0,0,.08);
20
+ --tr:.2s ease;
21
+ }
22
+ html,body{height:100%;overflow:hidden;font-family:var(--font);background:var(--bg);color:var(--text);font-size:13px;-webkit-font-smoothing:antialiased;}
23
+ ::-webkit-scrollbar{width:5px;height:5px;}
24
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:10px;}
25
+ ::selection{background:var(--accent-light);color:var(--accent);}
26
+ @keyframes shimmer{0%,100%{background-position:0%}50%{background-position:100%}}
27
+ @keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
28
+ @keyframes spin{to{transform:rotate(360deg)}}
29
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
30
+
31
+ .topbar{height:46px;display:flex;align-items:center;gap:10px;padding:0 20px;background:var(--bg);border-bottom:1px solid var(--border);}
32
+ .logo{font-weight:900;font-size:15px;color:#1e1b4b;letter-spacing:-.3px;}
33
+ .logo em{font-style:normal;font-weight:400;font-size:11px;color:var(--text2);margin-left:2px;}
34
+ .topbar-sep{width:1px;height:18px;background:var(--border);margin:0 2px;}
35
+ .topbar-desc{font-size:11px;color:var(--text2);font-weight:500;}
36
+ .topbar-url{display:inline-flex;align-items:center;gap:4px;padding:3px 12px;border-radius:20px;
37
+ font-family:var(--mono);font-size:9.5px;font-weight:600;letter-spacing:.3px;
38
+ background:linear-gradient(135deg,#4f46e5,#4338ca);color:#fff;text-decoration:none;
39
+ box-shadow:0 2px 8px rgba(79,70,229,.2);transition:var(--tr);}
40
+ .topbar-url:hover{box-shadow:0 4px 14px rgba(79,70,229,.35);transform:translateY(-1px);}
41
+ .topbar-right{margin-left:auto;display:flex;align-items:center;gap:8px;}
42
+ .topbar-contact{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:20px;
43
+ font-family:var(--mono);font-size:8.5px;font-weight:600;color:var(--teal);
44
+ background:var(--teal-light);border:1px solid rgba(13,148,136,.12);
45
+ text-decoration:none;transition:var(--tr);}
46
+ .topbar-contact:hover{background:rgba(13,148,136,.12);}
47
+ .chip{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:20px;
48
+ font-family:var(--mono);font-size:8px;font-weight:600;letter-spacing:.8px;
49
+ background:var(--accent-light);color:var(--accent);border:1px solid rgba(99,102,241,.12);}
50
+ .chip-teal{background:var(--teal-light);color:var(--teal);border-color:rgba(13,148,136,.12);}
51
+ .dot-live{width:5px;height:5px;border-radius:50%;background:var(--green);animation:pulse 2s infinite;}
52
+
53
+ .main{display:grid;grid-template-columns:340px 1fr;height:calc(100vh - 50px);overflow:hidden;}
54
+ .left{border-right:1px solid var(--border);overflow-y:auto;padding:16px;background:var(--bg);}
55
+ .section{margin-bottom:14px;animation:fadeUp .4s ease both;}
56
+ .section:nth-child(2){animation-delay:.05s;}
57
+ .section:nth-child(3){animation-delay:.1s;}
58
+ .label{font-family:var(--mono);font-size:9px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
59
+ textarea{width:100%;border:1px solid var(--border);border-radius:var(--r-sm);padding:10px 12px;font-family:var(--font);font-size:12.5px;color:var(--text);background:var(--bg);resize:none;outline:none;transition:var(--tr);line-height:1.6;}
60
+ textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-light);}
61
+ textarea::placeholder{color:var(--muted);font-size:11.5px;}
62
+
63
+ .file-drop{border:2px dashed var(--border);border-radius:var(--r);padding:18px;text-align:center;cursor:pointer;transition:var(--tr);background:var(--bg2);}
64
+ .file-drop:hover{border-color:var(--accent);background:var(--accent-light);}
65
+ .file-drop.has-file{border-color:var(--green);background:rgba(22,163,74,.04);}
66
+ .file-icon{font-size:22px;margin-bottom:4px;opacity:.4;}
67
+ .file-hint{font-size:10px;color:var(--muted);font-family:var(--mono);}
68
+ .file-name{font-size:11px;font-weight:600;color:var(--green);margin-top:4px;display:none;}
69
+
70
+ .slider-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
71
+ .slider-group{background:var(--bg2);border-radius:var(--r-sm);padding:8px 10px;}
72
+ .slider-label{font-family:var(--mono);font-size:8.5px;font-weight:600;color:var(--muted);margin-bottom:4px;display:flex;justify-content:space-between;}
73
+ .slider-val{color:var(--accent);font-weight:700;}
74
+ input[type=range]{width:100%;height:4px;-webkit-appearance:none;background:var(--border);border-radius:4px;outline:none;}
75
+ input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.15);}
76
+
77
+ .btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border-radius:var(--r-sm);font-family:var(--font);font-size:12px;font-weight:600;border:none;cursor:pointer;transition:var(--tr);width:100%;}
78
+ .btn-primary{background:linear-gradient(135deg,var(--accent),#4338ca);color:#fff;box-shadow:0 4px 12px rgba(79,70,229,.25);}
79
+ .btn-primary:hover{box-shadow:0 6px 20px rgba(79,70,229,.35);transform:translateY(-1px);}
80
+ .btn-primary:active{transform:translateY(0);}
81
+ .btn-primary:disabled{opacity:.5;cursor:not-allowed;transform:none;}
82
+ .btn-secondary{background:var(--bg2);color:var(--text2);border:1px solid var(--border);}
83
+ .btn-secondary:hover{background:var(--bg3);}
84
+ .btn-sm{padding:6px 10px;font-size:10.5px;border-radius:6px;}
85
+ .btn-row{display:grid;grid-template-columns:1fr auto;gap:8px;}
86
+ .btn-row-2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
87
+
88
+ .status-bar{font-family:var(--mono);font-size:10px;color:var(--muted);padding:6px 0;display:flex;align-items:center;gap:6px;}
89
+ .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;}
90
+ .status-idle{background:var(--muted);}
91
+ .status-run{background:var(--accent);animation:pulse 1.5s infinite;}
92
+ .status-done{background:var(--green);}
93
+ .status-err{background:var(--red);}
94
+
95
+ .acc{border:1px solid var(--border);border-radius:var(--r-sm);overflow:hidden;margin-bottom:8px;}
96
+ .acc-header{display:flex;align-items:center;gap:6px;padding:8px 12px;cursor:pointer;font-family:var(--mono);font-size:9.5px;font-weight:600;color:var(--text2);background:var(--bg2);transition:var(--tr);user-select:none;}
97
+ .acc-header:hover{background:var(--bg3);}
98
+ .acc-arrow{font-size:8px;transition:var(--tr);color:var(--muted);}
99
+ .acc.open .acc-arrow{transform:rotate(90deg);}
100
+ .acc-body{display:none;padding:10px 12px;border-top:1px solid var(--border);background:var(--bg);}
101
+ .acc.open .acc-body{display:block;}
102
+ .acc textarea{font-size:11px;font-family:var(--mono);line-height:1.6;background:var(--bg2);border:none;}
103
+
104
+ .dl-area{background:var(--bg2);border-radius:var(--r-sm);padding:10px 12px;}
105
+ .dl-fname{font-family:var(--mono);font-size:10px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;}
106
+ .dl-status{font-family:var(--mono);font-size:9px;margin-top:4px;}
107
+ .dl-status.ok{color:var(--green);}
108
+ .dl-status.err{color:var(--red);}
109
+
110
+ .right{background:#d8dce2;overflow:hidden;display:flex;flex-direction:column;}
111
+ .vt{display:flex;align-items:center;gap:8px;padding:8px 16px;background:var(--bg);border-bottom:1px solid var(--border);flex-shrink:0;}
112
+ .vt-dots{display:flex;gap:5px;}
113
+ .vt-dots span{width:10px;height:10px;border-radius:50%;display:block;}
114
+ .vt-fname{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--text);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
115
+ .vt-badge{font-family:var(--mono);font-size:7.5px;font-weight:700;padding:2px 10px;border-radius:20px;letter-spacing:.5px;}
116
+ .badge-r{background:var(--teal-light);color:var(--teal);border:1px solid rgba(13,148,136,.15);}
117
+ .badge-e{background:var(--accent-light);color:var(--accent);border:1px solid rgba(99,102,241,.12);}
118
+ .badge-l{background:rgba(251,191,36,.1);color:var(--amber);border:1px solid rgba(251,191,36,.2);animation:pulse 1.5s infinite;}
119
+
120
+ .vb{flex:1;overflow:hidden;position:relative;}
121
+ .vb iframe{position:absolute;top:0;left:0;width:100%!important;height:100%!important;border:none!important;display:block;}
122
+ .ve{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;color:var(--muted);background:#d8dce2;}
123
+ .ve-icon{font-size:48px;opacity:.2;}
124
+ .ve-text{font-family:var(--mono);font-size:10px;text-align:center;line-height:1.8;}
125
+
126
+ .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(100px);padding:10px 20px;border-radius:var(--r);font-size:11px;font-weight:600;color:#fff;z-index:9999;opacity:0;transition:.3s ease;pointer-events:none;box-shadow:var(--shadow-lg);}
127
+ .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
128
+ .toast.ok{background:var(--green);}.toast.err{background:var(--red);}.toast.info{background:var(--accent);}
129
+
130
+ .spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:none;}
131
+ .running .spinner{display:inline-block;}
132
+ #fileInput{display:none;}
133
+
134
+ @media(max-width:900px){
135
+ .main{grid-template-columns:1fr;grid-template-rows:auto 1fr;}
136
+ .left{max-height:40vh;border-right:none;border-bottom:1px solid var(--border);}
137
+ }
138
+ </style>
139
+ </head>
140
+ <body>
141
+
142
+ <div class="topbar">
143
+ <span class="logo">한지<em>(HANJI)</em></span>
144
+ <span class="topbar-sep"></span>
145
+ <span class="topbar-desc">HWP AI Agent 서���스</span>
146
+ <a class="topbar-url" href="https://hanji.ginigen.ai" target="_blank">🔗 hanji.ginigen.ai</a>
147
+ <span class="topbar-right">
148
+ <a class="topbar-contact" href="mailto:ginigenaihp@gmail.com">📧 문의 · 온프레미스 · 제휴</a>
149
+ </span>
150
+ </div>
151
+
152
+ <div class="main">
153
+ <div class="left">
154
+
155
+ <div class="section">
156
+ <div class="label">📌 프롬프트</div>
157
+ <textarea id="prompt" rows="3" placeholder="예: 2026년 AI 보안 유망기업 육성 지원사업 공모 안내문을 작성해주세요."></textarea>
158
+ </div>
159
+
160
+ <div class="section">
161
+ <div class="label">📎 레퍼런스 문서</div>
162
+ <div class="file-drop" id="fileDrop" onclick="document.getElementById('fileInput').click()">
163
+ <div class="file-icon">📄</div>
164
+ <div class="file-hint">클릭 또는 드래그하여 업로드</div>
165
+ <div class="file-hint" style="margin-top:2px;">HWP · HWPX · PDF · DOCX · TXT</div>
166
+ <div class="file-name" id="fileName"></div>
167
+ </div>
168
+ <input type="file" id="fileInput" accept=".hwp,.hwpx,.hml,.pdf,.docx,.txt,.md,.csv,.json,.xml,.xlsx">
169
+ </div>
170
+
171
+ <div class="section">
172
+ <div class="slider-row">
173
+ <div class="slider-group">
174
+ <div class="slider-label"><span>🔍 검색</span><span class="slider-val" id="valSearch">20</span></div>
175
+ <input type="range" min="5" max="100" step="5" value="20" id="slSearch" oninput="document.getElementById('valSearch').textContent=this.value">
176
+ </div>
177
+ <div class="slider-group">
178
+ <div class="slider-label"><span>🌡 Temp</span><span class="slider-val" id="valTemp">0.6</span></div>
179
+ <input type="range" min="0.1" max="1.0" step="0.05" value="0.6" id="slTemp" oninput="document.getElementById('valTemp').textContent=this.value">
180
+ </div>
181
+ </div>
182
+ </div>
183
+
184
+ <div class="section">
185
+ <div class="btn-row">
186
+ <button class="btn btn-primary" id="runBtn" onclick="runPipeline()">
187
+ <span class="spinner" id="btnSpin"></span>
188
+ <span id="btnLabel">🚀 문서 생성</span>
189
+ </button>
190
+ <button class="btn btn-secondary" onclick="stopPipeline()" style="width:44px" title="중지">⛔</button>
191
+ </div>
192
+ <div class="status-bar"><span class="dot status-idle" id="statusDot"></span><span id="statusText">대기 중</span></div>
193
+ </div>
194
+
195
+ <div class="section">
196
+ <div class="dl-area">
197
+ <div class="label" style="margin-bottom:4px">📥 HWPX 변환</div>
198
+ <div class="dl-fname" id="dlFname">파일 없음</div>
199
+ <div class="btn-row-2">
200
+ <button class="btn btn-primary btn-sm" id="dlBtn" onclick="downloadHwpx()" disabled>📥 HWPX</button>
201
+ <button class="btn btn-secondary btn-sm" onclick="copyDoc()">📋 복사</button>
202
+ </div>
203
+ <div class="dl-status" id="dlStatus"></div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- Doc Chat — 레퍼런스 문서 기반 QnA -->
208
+ <div class="acc open" id="accChat">
209
+ <div class="acc-header" onclick="toggleAcc('accChat')"><span class="acc-arrow">▶</span> 💬 문서 QnA 챗</div>
210
+ <div class="acc-body">
211
+ <div id="chatDocStatus" style="font-family:var(--mono);font-size:9px;color:var(--muted);margin-bottom:6px;">📎 레퍼런스 문서를 업로드하면 자동 연동됩니다</div>
212
+ <div id="chatMessages" style="max-height:220px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--r-sm);padding:8px;margin-bottom:8px;background:var(--bg2);font-size:11px;line-height:1.7;"></div>
213
+ <div style="display:grid;grid-template-columns:1fr auto;gap:6px;">
214
+ <input type="text" id="chatInput" placeholder="문서에 대해 질문하세요..." style="font-size:12px;padding:8px 10px;" onkeydown="if(event.key==='Enter')sendChat()">
215
+ <button class="btn btn-primary btn-sm" onclick="sendChat()" style="width:40px;">🚀</button>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <div class="acc" id="accDoc">
221
+ <div class="acc-header" onclick="toggleAcc('accDoc')"><span class="acc-arrow">▶</span> 📝 생성된 텍스트</div>
222
+ <div class="acc-body"><textarea id="docArea" rows="10" placeholder="문서 생성 후 표시됩니다"></textarea></div>
223
+ </div>
224
+ <div class="acc" id="accStream">
225
+ <div class="acc-header" onclick="toggleAcc('accStream')"><span class="acc-arrow">▶</span> ⚡ 에이전트 스트림</div>
226
+ <div class="acc-body"><textarea id="streamArea" rows="8" readonly placeholder="에이전트 실시간 출력"></textarea></div>
227
+ </div>
228
+ <div class="acc" id="accLog">
229
+ <div class="acc-header" onclick="toggleAcc('accLog')"><span class="acc-arrow">▶</span> 🧬 파이프라인 로그</div>
230
+ <div class="acc-body"><textarea id="logArea" rows="6" readonly placeholder="파이프라인 로그"></textarea></div>
231
+ </div>
232
+ </div>
233
+
234
+ <div class="right">
235
+ <div class="vt">
236
+ <div class="vt-dots"><span style="background:#ff5f57"></span><span style="background:#febc2e"></span><span style="background:#28c840"></span></div>
237
+ <span class="vt-fname" id="viewerFname">📄 sample_hanji.hwpx</span>
238
+ <span class="vt-badge badge-e" id="badgeEngine">Python lxml</span>
239
+ <span class="vt-badge badge-r" id="badgeRender">HWPX · Full Render v3</span>
240
+ </div>
241
+ <div class="vb" id="viewerBody">
242
+ <div class="ve"><div class="ve-icon">⏳</div><div class="ve-text">로딩 중...</div></div>
243
+ </div>
244
+ </div>
245
+ </div>
246
+
247
+ <div class="toast" id="toast"></div>
248
+ <a id="dlLink" style="display:none"></a>
249
+
250
+ <script>
251
+ let running=false,finalDoc='',sc=0;
252
+ function getBase(){return '';}
253
+
254
+ function showToast(m,t='info'){const e=document.getElementById('toast');e.className='toast '+t;e.textContent=m;e.classList.add('show');setTimeout(()=>e.classList.remove('show'),3000);}
255
+ function toggleAcc(id){document.getElementById(id).classList.toggle('open');}
256
+ function setStatus(t,s='idle'){document.getElementById('statusText').textContent=t;document.getElementById('statusDot').className='dot status-'+s;}
257
+
258
+ const fi=document.getElementById('fileInput'),fd=document.getElementById('fileDrop'),fn=document.getElementById('fileName');
259
+ fi.addEventListener('change',function(){if(this.files.length)handleFile(this.files[0]);});
260
+ fd.addEventListener('dragover',e=>{e.preventDefault();fd.style.borderColor='var(--accent)';});
261
+ fd.addEventListener('dragleave',()=>{fd.style.borderColor='';});
262
+ fd.addEventListener('drop',e=>{e.preventDefault();fd.style.borderColor='';if(e.dataTransfer.files.length)handleFile(e.dataTransfer.files[0]);});
263
+
264
+ function handleFile(f){
265
+ fn.textContent='✅ '+f.name+' ('+Math.round(f.size/1024)+'KB)';fn.style.display='block';fd.classList.add('has-file');
266
+ // 챗용 텍스트 추출 동시 수행
267
+ uploadDocForChat(f);
268
+ // HWP/HWPX → 뷰어 미리보기
269
+ const ext=f.name.split('.').pop().toLowerCase();
270
+ if(['hwp','hwpx'].includes(ext)){
271
+ const r=new FileReader();
272
+ r.onload=function(){renderB64(r.result.split(',')[1],'.'+ext,f.name);};
273
+ r.readAsDataURL(f);
274
+ }
275
+ }
276
+
277
+ function setEmpty(icon,msg){document.getElementById('viewerBody').innerHTML='<div class="ve"><div class="ve-icon">'+icon+'</div><div class="ve-text">'+msg+'</div></div>';}
278
+
279
+ async function renderPath(path,title){
280
+ setEmpty('⏳','렌더링 중...');
281
+ document.getElementById('viewerFname').textContent='📄 '+title;
282
+ const b=document.getElementById('badgeRender');b.className='vt-badge badge-l';b.textContent='로딩...';
283
+ try{
284
+ const r=await fetch(getBase()+'/soma/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_path:path})});
285
+ if(!r.ok)throw new Error(r.status);applyHTML(await r.text());
286
+ }catch(e){setEmpty('❌',e.message);b.className='vt-badge badge-e';b.textContent='Error';}
287
+ }
288
+
289
+ async function renderB64(b64,ext,title){
290
+ setEmpty('⏳','렌더링 중...');
291
+ document.getElementById('viewerFname').textContent='📄 '+title;
292
+ const b=document.getElementById('badgeRender');b.className='vt-badge badge-l';b.textContent='로딩...';
293
+ try{
294
+ const r=await fetch(getBase()+'/soma/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({b64,ext})});
295
+ if(!r.ok)throw new Error(r.status);applyHTML(await r.text());
296
+ }catch(e){setEmpty('❌',e.message);b.className='vt-badge badge-e';b.textContent='Error';}
297
+ }
298
+
299
+ function applyHTML(h){
300
+ const v=document.getElementById('viewerBody');
301
+ if(h.includes('srcdoc=')){v.innerHTML=h;}
302
+ else{v.innerHTML='<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:#fff;padding:16px;overflow:auto;">'+h+'</div>';}
303
+ const b=document.getElementById('badgeRender');
304
+ b.className='vt-badge badge-r';b.textContent=h.includes('ohah/hwpjs')?'✨ ohah/hwpjs WASM':'HWPX · Full Render v3';
305
+ }
306
+
307
+ async function runPipeline(){
308
+ const p=document.getElementById('prompt').value.trim();
309
+ if(!p){showToast('프롬프트를 입력하세요.','err');return;}
310
+ running=true;finalDoc='';sc=0;
311
+ document.getElementById('runBtn').classList.add('running');
312
+ document.getElementById('btnLabel').textContent='생성 중...';
313
+ document.getElementById('dlBtn').disabled=true;
314
+ setStatus('파이프라인 실행 중...','run');
315
+ document.getElementById('accStream').classList.add('open');
316
+ try{
317
+ const r=await fetch(getBase()+'/soma/run',{method:'POST',headers:{'Content-Type':'application/json'},
318
+ body:JSON.stringify({prompt:p,max_search:+document.getElementById('slSearch').value,temperature:+document.getElementById('slTemp').value,ref_sid:chatSid||''})});
319
+ if(!r.ok)throw new Error(await r.text());
320
+ const rd=r.body.getReader();const dec=new TextDecoder();let buf='',stream='',log='';
321
+ while(true){
322
+ const{done,value}=await rd.read();if(done||!running)break;
323
+ buf+=dec.decode(value,{stream:true});const lines=buf.split('\n');buf=lines.pop();
324
+ for(const l of lines){
325
+ if(!l.startsWith('data: '))continue;const d=l.slice(6);
326
+ if(d==='[DONE]'){running=false;break;}
327
+ try{const c=JSON.parse(d);
328
+ if(c.stream){stream+=c.stream;document.getElementById('streamArea').value=stream.slice(-4000);}
329
+ if(c.log){document.getElementById('logArea').value=c.log;}
330
+ if(c.search_count!=null){sc=c.search_count;setStatus('🔍 '+sc+'회 검색...','run');}
331
+ if(c.final_doc){finalDoc=c.final_doc;document.getElementById('docArea').value=finalDoc;}
332
+ if(c.done){finalDoc=c.final_doc||finalDoc;document.getElementById('docArea').value=finalDoc;}
333
+ }catch{}
334
+ }
335
+ }
336
+ document.getElementById('runBtn').classList.remove('running');
337
+ document.getElementById('btnLabel').textContent='🚀 문서 생성';
338
+ document.getElementById('dlBtn').disabled=!finalDoc;
339
+ if(finalDoc){setStatus('✅ 완료 · '+sc+'회 검색','done');showToast('✅ 문서 생성 완료!','ok');document.getElementById('accDoc').classList.add('open');}
340
+ else setStatus('완료 (문서 없음)','idle');
341
+ }catch(e){setStatus('❌ '+e.message,'err');showToast('❌ '+e.message,'err');
342
+ document.getElementById('runBtn').classList.remove('running');document.getElementById('btnLabel').textContent='🚀 문서 생성';}
343
+ running=false;
344
+ }
345
+ function stopPipeline(){running=false;setStatus('⛔ 중지됨','idle');document.getElementById('runBtn').classList.remove('running');document.getElementById('btnLabel').textContent='🚀 문서 생성';showToast('⛔ 중지됨');}
346
+
347
+ async function downloadHwpx(){
348
+ if(!finalDoc){showToast('문서를 먼저 생성하세요.','err');return;}
349
+ document.getElementById('dlStatus').className='dl-status';document.getElementById('dlStatus').textContent='변환 중...';
350
+ try{
351
+ const r=await fetch(getBase()+'/soma/hml',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:finalDoc})});
352
+ if(!r.ok)throw new Error(await r.text());const d=await r.json();
353
+ if(d.file_url){
354
+ const a=document.getElementById('dlLink');a.href=getBase()+d.file_url;a.download=d.filename||'문서.hwpx';a.click();
355
+ document.getElementById('dlFname').textContent=d.filename||'문서.hwpx';
356
+ document.getElementById('dlStatus').className='dl-status ok';document.getElementById('dlStatus').textContent='✓ 다운로드 완료';
357
+ showToast('✅ HWPX 다운로드','ok');
358
+ if(d.file_path)renderPath(d.file_path,d.filename||'문서.hwpx');
359
+ }else throw new Error('file_url 없음');
360
+ }catch(e){document.getElementById('dlStatus').className='dl-status err';document.getElementById('dlStatus').textContent='⚠ '+e.message;showToast('❌ '+e.message,'err');}
361
+ }
362
+ /* ── SIMPLE MD → HTML ── */
363
+ function md2html(s){
364
+ return s
365
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
366
+ .replace(/^### (.+)$/gm,'<div style="font-weight:700;font-size:12px;color:#1e40af;margin:8px 0 4px;border-left:3px solid #6366f1;padding-left:8px;">$1</div>')
367
+ .replace(/^## (.+)$/gm,'<div style="font-weight:800;font-size:13px;color:#1e3a5f;margin:10px 0 4px;padding-bottom:3px;border-bottom:1.5px solid #e2e8f0;">$1</div>')
368
+ .replace(/^# (.+)$/gm,'<div style="font-weight:900;font-size:14px;color:#0f172a;margin:12px 0 6px;">$1</div>')
369
+ .replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')
370
+ .replace(/\*(.+?)\*/g,'<i>$1</i>')
371
+ .replace(/`([^`]+)`/g,'<code style="background:#f1f5f9;padding:1px 5px;border-radius:3px;font-size:10px;color:#6366f1;">$1</code>')
372
+ .replace(/^- (.+)$/gm,'<div style="padding-left:14px;text-indent:-10px;margin:2px 0;">• $1</div>')
373
+ .replace(/^\d+\. (.+)$/gm,function(m,p1){return '<div style="padding-left:14px;margin:2px 0;">'+m.match(/^\d+/)[0]+'. '+p1+'</div>';})
374
+ .replace(/\n{2,}/g,'<div style="margin:6px 0;"></div>')
375
+ .replace(/\n/g,'<br>');
376
+ }
377
+
378
+ function copyDoc(){const t=document.getElementById('docArea').value;if(!t){showToast('텍스트 없음','err');return;}navigator.clipboard.writeText(t).then(()=>showToast('📋 복사됨','ok'));}
379
+
380
+ /* ── DOC CHAT ── */
381
+ let chatSid='',chatHistory=[];
382
+
383
+ function addChatMsg(role,text){
384
+ const box=document.getElementById('chatMessages');
385
+ const color=role==='user'?'var(--accent)':role==='system'?'var(--muted)':'var(--teal)';
386
+ const label=role==='user'?'👤':role==='system'?'ℹ️':'🤖';
387
+ const rendered=role==='user'?text.replace(/\n/g,'<br>'):md2html(text);
388
+ box.innerHTML+='<div style="margin-bottom:6px;"><span style="font-weight:700;color:'+color+';font-size:10px;">'+label+'</span> <span>'+rendered+'</span></div>';
389
+ box.scrollTop=box.scrollHeight;
390
+ }
391
+
392
+ async function uploadDocForChat(file){
393
+ /* 레퍼런스 업로드 시 자동 호출 — 챗용 텍스트 추출 */
394
+ try{
395
+ const b64=await new Promise(function(res,rej){const r=new FileReader();r.onload=function(){res(r.result.split(',')[1]);};r.onerror=rej;r.readAsDataURL(file);});
396
+ const ext='.'+file.name.split('.').pop().toLowerCase();
397
+ const resp=await fetch(getBase()+'/soma/doc-upload',{method:'POST',
398
+ headers:{'Content-Type':'application/json'},body:JSON.stringify({b64:b64,filename:file.name,ext:ext})});
399
+ const d=await resp.json();
400
+ if(d.ok){
401
+ chatSid=d.sid;chatHistory=[];
402
+ document.getElementById('chatDocStatus').innerHTML='✅ <b>'+file.name+'</b> ('+d.chars.toLocaleString()+'자) 연동됨';
403
+ document.getElementById('chatMessages').innerHTML='';
404
+ addChatMsg('system','📄 문서 로드 완료 ('+d.chars.toLocaleString()+'자). 질문하세요!');
405
+ }
406
+ }catch(e){
407
+ document.getElementById('chatDocStatus').textContent='❌ 문서 분석 실패: '+e.message;
408
+ }
409
+ }
410
+
411
+ async function sendChat(){
412
+ var input=document.getElementById('chatInput');
413
+ var msg=input.value.trim();
414
+ if(!msg){return;}
415
+ input.value='';
416
+ addChatMsg('user',msg);
417
+ chatHistory.push([msg,'']);
418
+
419
+ try{
420
+ var r=await fetch(getBase()+'/soma/chat',{method:'POST',
421
+ headers:{'Content-Type':'application/json'},
422
+ body:JSON.stringify({message:msg,sid:chatSid,history:chatHistory.slice(-6)})});
423
+ if(!r.ok){throw new Error('서버 오류 '+r.status);}
424
+ var rd=r.body.getReader();
425
+ var dec=new TextDecoder();
426
+ var buf='',full='';
427
+ var box=document.getElementById('chatMessages');
428
+ var assistDiv=document.createElement('div');
429
+ assistDiv.style.marginBottom='6px';
430
+ assistDiv.innerHTML='<span style="font-weight:700;color:var(--teal);font-size:10px;">🤖</span> <span id="chatStream"></span>';
431
+ box.appendChild(assistDiv);
432
+ var streamSpan=document.getElementById('chatStream');
433
+
434
+ while(true){
435
+ var result=await rd.read();
436
+ if(result.done){break;}
437
+ buf+=dec.decode(result.value,{stream:true});
438
+ var lines=buf.split('\n');
439
+ buf=lines.pop();
440
+ for(var i=0;i<lines.length;i++){
441
+ var l=lines[i];
442
+ if(l.indexOf('data: ')!==0){continue;}
443
+ var d=l.slice(6);
444
+ if(d==='[DONE]'){break;}
445
+ try{var c=JSON.parse(d);if(c.delta){full+=c.delta;streamSpan.innerHTML=md2html(full);}}catch(e2){}
446
+ }
447
+ box.scrollTop=box.scrollHeight;
448
+ }
449
+ // Remove temp id
450
+ streamSpan.removeAttribute('id');
451
+ chatHistory[chatHistory.length-1][1]=full;
452
+ }catch(e){addChatMsg('system','❌ '+e.message);}
453
+ }
454
+
455
+ window.addEventListener('DOMContentLoaded',async()=>{
456
+ try{const r=await fetch(getBase()+'/soma/status');const s=await r.json();document.getElementById('badgeEngine').textContent=s.engine||'Python lxml';}catch{}
457
+ try{
458
+ const r=await fetch(getBase()+'/soma/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_path:'/app/sample_hanji.hwpx'})});
459
+ if(r.ok){applyHTML(await r.text());document.getElementById('viewerFname').textContent='📄 한지(HANJI) 서비스 소개';}
460
+ else setEmpty('📄','문서를 생성하거나 레퍼런스를 업로드하세요.');
461
+ }catch{setEmpty('📄','문서를 생성하거나 레퍼런스를 업로드하세요.');}
462
+ // 샘플 문서 챗 자동 연동
463
+ try{
464
+ var cr=await fetch(getBase()+'/soma/doc-upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_path:'/app/sample_hanji.hwpx'})});
465
+ var cd=await cr.json();
466
+ if(cd.ok){chatSid=cd.sid;document.getElementById('chatDocStatus').innerHTML='✅ <b>한지 서비스 소개</b> ('+cd.chars.toLocaleString()+'자) 연동됨';addChatMsg('system','📄 기본 문서 로드 완료. 한지(HANJI)에 대해 질문하세요!');}
467
+ }catch{}
468
+ });
469
+ </script>
470
+ </body>
471
+ </html>
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ huggingface_hub
2
+ requests
3
+ fastapi
4
+ uvicorn[standard]
5
+ gradio
6
+ olefile
7
+ pyhwp
8
+ PyPDF2
9
+ pdfplumber
10
+ openpyxl
11
+ lxml
sample_hanji.hwpx ADDED
Binary file (68.8 kB). View file