diff --git a/bitnet_tools/__init__.py b/bitnet_tools/__init__.py
index 316a94c..a36b517 100644
--- a/bitnet_tools/__init__.py
+++ b/bitnet_tools/__init__.py
@@ -1,5 +1,21 @@
"""Utilities for BitNet-focused local data analysis workflows."""
+
+from .analysis import (
+ build_analysis_payload,
+ build_analysis_payload_from_csv_text,
+ build_prompt,
+ summarize_rows,
+)
+
+__all__ = [
+ "build_analysis_payload",
+ "build_analysis_payload_from_csv_text",
+ "build_prompt",
+ "summarize_rows",
+]
+
from .analysis import build_analysis_payload, summarize_rows
__all__ = ["build_analysis_payload", "summarize_rows"]
+
diff --git a/bitnet_tools/ui/app.js b/bitnet_tools/ui/app.js
new file mode 100644
index 0000000..9312dd4
--- /dev/null
+++ b/bitnet_tools/ui/app.js
@@ -0,0 +1,70 @@
+const csvFile = document.getElementById('csvFile');
+const csvText = document.getElementById('csvText');
+const question = document.getElementById('question');
+const model = document.getElementById('model');
+const analyzeBtn = document.getElementById('analyzeBtn');
+const runBtn = document.getElementById('runBtn');
+const summary = document.getElementById('summary');
+const prompt = document.getElementById('prompt');
+const answer = document.getElementById('answer');
+
+let latestPrompt = '';
+
+csvFile.addEventListener('change', async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ csvText.value = await file.text();
+});
+
+document.querySelectorAll('.chip').forEach((chip) => {
+ chip.addEventListener('click', () => {
+ question.value = chip.dataset.q;
+ });
+});
+
+document.getElementById('copyPrompt').addEventListener('click', async () => {
+ if (!latestPrompt) return;
+ await navigator.clipboard.writeText(latestPrompt);
+});
+
+analyzeBtn.addEventListener('click', async () => {
+ summary.textContent = '분석 중...';
+ const res = await fetch('/api/analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ csv_text: csvText.value,
+ question: question.value,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ summary.textContent = data.error || 'error';
+ return;
+ }
+
+ latestPrompt = data.prompt;
+ summary.textContent = JSON.stringify(data.summary, null, 2);
+ prompt.textContent = data.prompt;
+ answer.textContent = '';
+});
+
+runBtn.addEventListener('click', async () => {
+ if (!latestPrompt) {
+ answer.textContent = '먼저 분석을 실행해 프롬프트를 생성하세요.';
+ return;
+ }
+ if (!model.value.trim()) {
+ answer.textContent = '모델 태그를 입력하세요. 예: bitnet:latest';
+ return;
+ }
+
+ answer.textContent = 'BitNet 실행 중...';
+ const res = await fetch('/api/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: model.value.trim(), prompt: latestPrompt }),
+ });
+ const data = await res.json();
+ answer.textContent = res.ok ? data.answer : (data.error || 'error');
+});
diff --git a/bitnet_tools/ui/index.html b/bitnet_tools/ui/index.html
new file mode 100644
index 0000000..bb7d18b
--- /dev/null
+++ b/bitnet_tools/ui/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ BitNet CSV Analyzer
+
+
+
+
+ BitNet CSV Analyzer
+ CSV 업로드 → 자동 요약 → BitNet 답변까지 한 번에.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bitnet_tools/ui/styles.css b/bitnet_tools/ui/styles.css
new file mode 100644
index 0000000..0ee1949
--- /dev/null
+++ b/bitnet_tools/ui/styles.css
@@ -0,0 +1,58 @@
+:root {
+ color-scheme: dark;
+ --bg: #111827;
+ --panel: #1f2937;
+ --text: #f9fafb;
+ --muted: #9ca3af;
+ --accent: #22c55e;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
+}
+.container {
+ max-width: 980px;
+ margin: 24px auto;
+ padding: 0 16px 40px;
+}
+.sub { color: var(--muted); }
+.panel {
+ background: var(--panel);
+ border-radius: 10px;
+ padding: 14px;
+ margin: 12px 0;
+}
+.row { display: flex; justify-content: space-between; gap: 12px; align-items: end; }
+label { display: block; margin-bottom: 8px; color: var(--muted); }
+textarea, input {
+ width: 100%;
+ background: #0b1220;
+ color: var(--text);
+ border: 1px solid #334155;
+ border-radius: 8px;
+ padding: 10px;
+}
+button {
+ border: none;
+ border-radius: 8px;
+ padding: 10px 14px;
+ background: var(--accent);
+ color: #052e16;
+ font-weight: 700;
+ cursor: pointer;
+}
+.chips { display: flex; gap: 8px; margin-bottom: 8px; }
+.chip { background: #334155; color: #e2e8f0; }
+.actions { display: flex; gap: 8px; }
+pre {
+ white-space: pre-wrap;
+ background: #0b1220;
+ border: 1px solid #334155;
+ border-radius: 8px;
+ padding: 10px;
+ max-height: 320px;
+ overflow: auto;
+}
diff --git a/bitnet_tools/web.py b/bitnet_tools/web.py
new file mode 100644
index 0000000..97aa372
--- /dev/null
+++ b/bitnet_tools/web.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+import subprocess
+from urllib.parse import urlparse
+
+from .analysis import build_analysis_payload_from_csv_text
+
+
+UI_DIR = Path(__file__).parent / "ui"
+
+
+def run_ollama(model: str, prompt: str) -> str:
+ proc = subprocess.run(
+ ["ollama", "run", model, prompt],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(proc.stderr.strip() or "ollama run failed")
+ return proc.stdout.strip()
+
+
+class Handler(BaseHTTPRequestHandler):
+ def _send_json(self, data: dict, status: int = HTTPStatus.OK) -> None:
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _send_file(self, path: Path, content_type: str) -> None:
+ if not path.exists():
+ self.send_error(HTTPStatus.NOT_FOUND)
+ return
+ data = path.read_bytes()
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_GET(self) -> None:
+ route = urlparse(self.path).path
+ if route == "/" or route == "/index.html":
+ return self._send_file(UI_DIR / "index.html", "text/html; charset=utf-8")
+ if route == "/app.js":
+ return self._send_file(UI_DIR / "app.js", "application/javascript; charset=utf-8")
+ if route == "/styles.css":
+ return self._send_file(UI_DIR / "styles.css", "text/css; charset=utf-8")
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def do_POST(self) -> None:
+ route = urlparse(self.path).path
+ content_length = int(self.headers.get("Content-Length", "0"))
+ raw = self.rfile.read(content_length)
+ try:
+ payload = json.loads(raw.decode("utf-8")) if raw else {}
+ except json.JSONDecodeError:
+ return self._send_json({"error": "invalid json"}, HTTPStatus.BAD_REQUEST)
+
+ try:
+ if route == "/api/analyze":
+ csv_text = str(payload.get("csv_text", ""))
+ question = str(payload.get("question", "")).strip()
+ if not csv_text.strip():
+ return self._send_json({"error": "csv_text is required"}, HTTPStatus.BAD_REQUEST)
+ if not question:
+ question = "이 데이터의 핵심 인사이트를 알려줘"
+ result = build_analysis_payload_from_csv_text(csv_text, question)
+ return self._send_json(result)
+
+ if route == "/api/run":
+ model = str(payload.get("model", "")).strip()
+ prompt = str(payload.get("prompt", "")).strip()
+ if not model or not prompt:
+ return self._send_json({"error": "model and prompt are required"}, HTTPStatus.BAD_REQUEST)
+ answer = run_ollama(model, prompt)
+ return self._send_json({"answer": answer})
+
+ except Exception as exc: # runtime surface for UI
+ return self._send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+
+def serve(host: str = "127.0.0.1", port: int = 8765) -> None:
+ server = ThreadingHTTPServer((host, port), Handler)
+ print(f"BitNet UI running at http://{host}:{port}")
+ server.serve_forever()