From fe775e6d322ae45c5ede8987a53a6bd14c8b3523 Mon Sep 17 00:00:00 2001 From: HONGDAE KIM Date: Fri, 13 Feb 2026 17:31:43 +0900 Subject: [PATCH] feat: improve CSV parsing and dtype inference robustness --- README.md | 221 +++++++++++++++++-------------------- bitnet_tools/__init__.py | 15 +++ bitnet_tools/analysis.py | 195 ++++++++++++++++++++++++++++++++ bitnet_tools/cli.py | 64 +++++++++++ bitnet_tools/ui/app.js | 70 ++++++++++++ bitnet_tools/ui/index.html | 59 ++++++++++ bitnet_tools/ui/styles.css | 58 ++++++++++ bitnet_tools/web.py | 95 ++++++++++++++++ pyproject.toml | 20 ++++ tests/test_analysis.py | 42 +++++++ 10 files changed, 719 insertions(+), 120 deletions(-) create mode 100644 bitnet_tools/__init__.py create mode 100644 bitnet_tools/analysis.py create mode 100644 bitnet_tools/cli.py create mode 100644 bitnet_tools/ui/app.js create mode 100644 bitnet_tools/ui/index.html create mode 100644 bitnet_tools/ui/styles.css create mode 100644 bitnet_tools/web.py create mode 100644 pyproject.toml create mode 100644 tests/test_analysis.py diff --git a/README.md b/README.md index 7b0fadb..27213e7 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,63 @@ +# BitNet 로컬 분석 환경 시작 가이드 (개인용) -# BitNet 로컬화 완성 가이드 (개인용 · 데이터 분석 중심) - -> 목표: **개인 사용 전제**로, 최고 성능보다 **편의성/안정성/UI·UX**을 우선하고 -> “CSV/텍스트 요약, 간단한 질의응답, 분석 보조”가 가능한 수준으로 구성합니다. +> 목표: **BitNet 중심(웬만하면 BitNet만 사용)**으로 로컬 LLM 환경을 빠르게 띄우고, +> CSV/텍스트 요약 + 간단한 질의응답 + 분석 보조까지 바로 시작할 수 있게 구성합니다. --- -## 0) 권장 완성 형태 (이번 문서의 최종 목표) - -아래 3개를 묶어서 쓰는 구성을 권장합니다. - -1. **추론 엔진**: Ollama (로컬 모델 실행) -2. **채팅 UI**: Open WebUI (브라우저에서 편하게 사용) -3. **분석 환경**: JupyterLab + Python (pandas/matplotlib) +## 0) 이번 문서에서 바로 할 일 -이 조합의 장점: -- 설치/업데이트가 단순함 -- UI/UX가 직관적임 -- 개인용 문서/CSV 분석 워크플로우와 잘 맞음 +1. Ollama 설치 및 실행 +2. BitNet 모델 1개 Pull +3. CLI로 동작 확인 +4. Open WebUI 연결 +5. JupyterLab에서 CSV 분석 + BitNet 해석 워크플로우 구성 --- -## 1) 단계별 로드맵 (완성까지) - -### Step 1. 환경 기준 확정 (30분) -- OS / RAM / GPU(VRAM) 확인 -- 디스크 여유 30GB+ 확보 -- 목표를 “빠른 응답”보다 “안정 동작”으로 설정 - -### Step 2. 로컬 추론 먼저 성공 (1시간) -- Ollama 설치 -- BitNet 또는 경량 모델 1개 pull -- CLI에서 단일 프롬프트 테스트 +## 1) 사전 확인 (10~20분) -### Step 3. UI/UX 구성 (1시간) -- Open WebUI 연결 -- 대화 템플릿/시스템 프롬프트 저장 -- 자주 쓰는 프리셋(요약, 표분석, 리포트)을 버튼/스니펫으로 정리 +- OS 확인 +- RAM/VRAM 확인 +- 디스크 여유 최소 30GB +- 목표를 “최고 성능”보다 “안정 동작”으로 설정 -### Step 4. 데이터 분석 파이프라인 연결 (1~2시간) -- JupyterLab 설치 -- pandas로 CSV 전처리 -- 모델에게 “분석 해석/요약/인사이트 설명” 역할 부여 - -### Step 5. 편의성/안정화 (1시간) -- 자동 시작(선택), 로그 위치 정리 -- 모델/프롬프트 버전 관리 -- 백업 정책(대화 내역/노트북) +권장 기준: +- RAM 16GB 이하: BitNet의 작은 파라미터 모델 우선 +- RAM 32GB 이상: 컨텍스트/토큰 여유를 조금 더 확대 +- GPU가 없으면 컨텍스트를 짧게 유지(2048~4096) --- -## 2) 빠른 설치 예시 (Ubuntu 기준, 개인용) - -> 아래는 “동작 우선” 기준 예시입니다. +## 2) Step-by-step 시작 절차 (BitNet 우선) -### 2-1) Ollama 설치 및 실행 +### Step 1. Ollama 설치 ```bash curl -fsSL https://ollama.com/install.sh | sh ollama serve ``` -### 2-2) 모델 다운로드 +### Step 2. BitNet 모델 다운로드 + +아래 `bitnet-model-tag`는 실제 사용 가능한 태그로 바꿔 입력하세요. + ```bash -# 예시: 가벼운 모델부터 시작 -ollama pull qwen2.5:3b -# BitNet 계열 사용 시 해당 태그로 교체 -# ollama pull +ollama pull ``` -### 2-3) CLI 테스트 +예: ```bash -ollama run qwen2.5:3b "다음 CSV 컬럼 설명을 5줄로 요약해줘: ..." +ollama pull bitnet:latest ``` -### 2-4) Open WebUI (Docker) +> 참고: 태그명은 시점/배포처에 따라 달라질 수 있으니 `ollama search bitnet` 또는 배포 페이지의 최신 태그를 우선 확인하세요. + +### Step 3. CLI 동작 확인 +```bash +ollama run "다음 CSV 컬럼 설명을 5줄로 요약해줘: user_id, order_cnt, total_amount" +``` + +### Step 4. Open WebUI 연결 (Docker) ```bash docker run -d \ --name open-webui \ @@ -86,61 +70,40 @@ docker run -d \ 접속: `http://localhost:3000` ---- - -## 3) 개인용 기본 설정값 (성능보다 편의성) - -### 모델/생성 파라미터 (출발점) -- temperature: `0.3 ~ 0.6` (분석/요약 안정성) -- top_p: `0.9` -- max tokens: `512 ~ 1024` -- context: `4096` (RAM 부족하면 2048) - -### 시스템 프롬프트 권장 -- “모르면 모른다고 답하기” -- “추정/사실 분리해서 출력하기” -- “표/수치 해석 시 근거 컬럼명 명시하기” - -### 모델 선택 기준 -- RAM 16GB 이하: 1.5B~3B 우선 -- RAM 32GB 수준: 7B 저양자화 시도 가능 -- GPU 없으면 작은 모델 + 짧은 컨텍스트가 안정적 +### Step 5. JupyterLab 분석 환경 +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install jupyterlab pandas matplotlib +jupyter lab +``` --- -## 4) UI/UX 개선 체크리스트 (실사용 핵심) - -### A. 채팅 화면 편의성 -- [ ] 프리셋 프롬프트 3개 저장 - - 데이터 요약 - - 이상치 원인 가설 - - 주간 리포트 초안 -- [ ] 응답 포맷 강제 템플릿 사용 - - `핵심요약 / 근거 / 한계 / 다음행동` -- [ ] 긴 답변은 bullet 기준으로 출력하도록 고정 +## 3) BitNet 기본 설정값 (안정성 우선) -### B. 분석 워크플로우 UX -- [ ] CSV 업로드 → 컬럼 진단 프롬프트 자동 실행 -- [ ] EDA 결과(기초통계/결측치/상관) 후 모델에 해석 요청 -- [ ] “분석 노트” Markdown 자동 저장 +- temperature: `0.2 ~ 0.5` +- top_p: `0.9` +- max_tokens: `512 ~ 1024` +- context: `2048 ~ 4096` (메모리 여유 있으면 확대) -### C. 반복 업무 자동화 -- [ ] 자주 쓰는 질의는 스니펫 파일(`prompts.md`)로 관리 -- [ ] 월 1회 모델/프롬프트 정리(안 쓰는 것 삭제) +시스템 프롬프트 권장: +- 모르면 모른다고 답하기 +- 추정/사실 분리하기 +- 표/수치 해석 시 근거 컬럼명을 명시하기 --- -## 5) 데이터 분석 최소 파이프라인 예시 +## 4) 데이터 분석 최소 워크플로우 (BitNet only) 1. JupyterLab에서 CSV 로딩 -2. pandas로 결측/타입/분포 확인 -3. 핵심 통계 결과를 텍스트로 정리 -4. 모델에 아래 형태로 전달 - - 데이터 설명 - - 궁금한 질문 3개 - - 원하는 출력 형식(표/요약) +2. pandas로 결측/타입/기초통계 계산 +3. 계산 결과를 텍스트로 정리 +4. 정리된 텍스트를 BitNet에 입력해 인사이트/한계/추가 데이터 제안 받기 예시 프롬프트: + ```text 너는 데이터 분석 보조자야. 아래 통계를 바탕으로 @@ -150,44 +113,62 @@ docker run -d \ 를 간결하게 제시해줘. ``` +응답 형식 템플릿(권장): +- 핵심요약 +- 근거 +- 한계 +- 다음행동 + --- -## 6) 운영 안정화 (개인용 수준) +## 5) 운영 안정화 체크리스트 -- 모델은 1~2개만 유지 (관리 단순화) -- 프롬프트 템플릿은 “검증된 것”만 남기기 -- 실패 대응 규칙: - - 응답 느리면: 컨텍스트/토큰 감소 - - 품질 낮으면: temperature 낮추기 - - 메모리 부족이면: 더 작은 모델 사용 +- [ ] BitNet 모델 1~2개만 유지 +- [ ] 프롬프트 템플릿은 검증된 것만 유지 +- [ ] 느릴 때: context/max_tokens 감소 +- [ ] 품질 흔들릴 때: temperature 하향 +- [ ] 메모리 부족 시: 더 작은 BitNet 모델로 전환 -백업 권장: -- Open WebUI 데이터 볼륨 백업 -- Jupyter 노트북/CSV 원본 분리 보관 +백업: +- Open WebUI 데이터 볼륨 주기적 백업 +- Jupyter 노트북/원본 CSV 분리 보관 --- -## 7) 추천 최종 구성 (당장 시작용) +## 6) 지금 바로 실행할 최소 커맨드 모음 -- 모델: 3B급 1개 + (선택) BitNet 계열 1개 -- UI: Open WebUI -- 분석: JupyterLab + pandas + matplotlib -- 사용 패턴: - - 데이터 전처리는 Python - - 해석/요약/리포트 초안은 LLM +```bash +# 1) Ollama +curl -fsSL https://ollama.com/install.sh | sh +ollama serve + +# 2) BitNet pull +ollama pull + +# 3) 테스트 +ollama run "샘플 매출 데이터를 요약해줘" +``` -이 구성만으로도 **개인 사용 기준의 중간 수준 데이터 분석 보조**는 충분히 가능합니다. +필요하면 다음 단계에서 환경(OS/CPU/RAM/GPU)에 맞춰 +- 정확한 BitNet 태그 +- 권장 context/max_tokens +- Open WebUI 프리셋 프롬프트 3종 +까지 바로 좁혀서 제안할 수 있습니다. --- -## 8) 다음에 바로 맞춤화할 항목 +## 7) GitHub 반영(적용) 절차 + +로컬에서 문서/설정을 수정한 뒤 아래 순서로 GitHub에 반영합니다. -아래 정보만 있으면, 제가 실행 커맨드와 파라미터를 당신 환경 기준으로 더 좁혀줄 수 있습니다. +```bash +git add README.md +git commit -m "docs: update BitNet setup guide" +git push origin +``` -- OS -- CPU / RAM -- GPU / VRAM -- 주 데이터 형태(CSV, 로그, 문서) -- 하루 사용량(질문 횟수/응답 길이) -======= +PR 생성 시 체크 포인트: +- 변경 목적(왜 바꿨는지) 1~2줄 +- 실행/검증한 명령어 +- 사용자 관점에서 달라진 점(BitNet 우선 흐름, 실행 순서 명확화 등) diff --git a/bitnet_tools/__init__.py b/bitnet_tools/__init__.py new file mode 100644 index 0000000..9e8880d --- /dev/null +++ b/bitnet_tools/__init__.py @@ -0,0 +1,15 @@ +"""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", +] diff --git a/bitnet_tools/analysis.py b/bitnet_tools/analysis.py new file mode 100644 index 0000000..be038ff --- /dev/null +++ b/bitnet_tools/analysis.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from dataclasses import dataclass +import csv +from datetime import datetime +import io +import json +from pathlib import Path +from statistics import mean, pstdev +from typing import Any + + +@dataclass +class DataSummary: + row_count: int + column_count: int + columns: list[str] + dtypes: dict[str, str] + missing_counts: dict[str, int] + numeric_stats: dict[str, dict[str, float]] + top_values: dict[str, list[tuple[str, int]]] + + def to_dict(self) -> dict[str, Any]: + return { + "row_count": self.row_count, + "column_count": self.column_count, + "columns": self.columns, + "dtypes": self.dtypes, + "missing_counts": self.missing_counts, + "numeric_stats": self.numeric_stats, + "top_values": self.top_values, + } + + +def _to_float(value: str) -> float | None: + v = value.strip() + if not v: + return None + try: + return float(v) + except ValueError: + return None + + +def _to_int(value: str) -> int | None: + v = value.strip() + if not v: + return None + try: + if any(ch in v for ch in ".eE"): + return None + return int(v) + except ValueError: + return None + + +def _to_iso_date(value: str) -> datetime | None: + v = value.strip() + if not v: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(v, fmt) + except ValueError: + pass + return None + + +def _percentile(values: list[float], p: float) -> float: + ordered = sorted(values) + if not ordered: + raise ValueError("values cannot be empty") + idx = (len(ordered) - 1) * p + lower = int(idx) + upper = min(lower + 1, len(ordered) - 1) + weight = idx - lower + return ordered[lower] * (1 - weight) + ordered[upper] * weight + + +def _parse_csv_text(csv_text: str) -> tuple[list[str], list[dict[str, str]]]: + sample = csv_text[:4096] + delimiter = "," + try: + dialect = csv.Sniffer().sniff(sample, delimiters=[",", "\t", ";", "|"]) + delimiter = dialect.delimiter + except csv.Error: + delimiter = "," + + reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter) + if reader.fieldnames is None: + raise ValueError("CSV header not found") + + columns = [str(c) for c in reader.fieldnames] + rows = list(reader) + return columns, rows + + +def summarize_rows(rows: list[dict[str, str]], columns: list[str]) -> DataSummary: + missing_counts = {col: 0 for col in columns} + numeric_values: dict[str, list[float]] = {col: [] for col in columns} + value_counts: dict[str, dict[str, int]] = {col: {} for col in columns} + seen_non_missing: dict[str, list[str]] = {col: [] for col in columns} + + for row in rows: + for col in columns: + raw = (row.get(col) or "").strip() + if raw == "": + missing_counts[col] += 1 + continue + seen_non_missing[col].append(raw) + value_counts[col][raw] = value_counts[col].get(raw, 0) + 1 + num = _to_float(raw) + if num is not None: + numeric_values[col].append(num) + + dtypes: dict[str, str] = {} + numeric_stats: dict[str, dict[str, float]] = {} + top_values: dict[str, list[tuple[str, int]]] = {} + + for col in columns: + non_missing = seen_non_missing[col] + values = numeric_values[col] + + if non_missing and all(_to_int(v) is not None for v in non_missing): + dtypes[col] = "int" + elif non_missing and len(values) == len(non_missing): + dtypes[col] = "float" + elif non_missing and all(_to_iso_date(v) is not None for v in non_missing): + dtypes[col] = "date" + else: + dtypes[col] = "string" + + if values and len(values) == len(non_missing): + stats = { + "count": float(len(values)), + "mean": float(mean(values)), + "min": float(min(values)), + "q1": float(_percentile(values, 0.25)), + "median": float(_percentile(values, 0.5)), + "q3": float(_percentile(values, 0.75)), + "max": float(max(values)), + } + stats["std"] = float(pstdev(values)) if len(values) > 1 else 0.0 + numeric_stats[col] = stats + + ranked = sorted(value_counts[col].items(), key=lambda x: (-x[1], x[0])) + top_values[col] = ranked[:5] + + return DataSummary( + row_count=len(rows), + column_count=len(columns), + columns=columns, + dtypes=dtypes, + missing_counts=missing_counts, + numeric_stats=numeric_stats, + top_values=top_values, + ) + + +def build_analysis_payload_from_csv_text(csv_text: str, question: str) -> dict[str, Any]: + columns, rows = _parse_csv_text(csv_text) + summary = summarize_rows(rows, columns) + prompt = build_prompt(summary.to_dict(), question) + return { + "question": question, + "summary": summary.to_dict(), + "prompt": prompt, + } + + +def build_prompt(summary: dict[str, Any], question: str) -> str: + return ( + "너는 BitNet 기반 데이터 분석 보조자야.\n" + "아래 데이터 요약을 바탕으로 답변해.\n" + "출력 형식: 핵심요약 / 근거 / 한계 / 다음행동\n\n" + f"사용자 질문: {question}\n\n" + f"데이터 요약(JSON):\n{json.dumps(summary, ensure_ascii=False, indent=2)}" + ) + + +def build_analysis_payload(csv_path: str | Path, question: str) -> dict[str, Any]: + path = Path(csv_path) + if not path.exists(): + raise FileNotFoundError(f"CSV file not found: {path}") + + raw = path.read_text(encoding="utf-8") + columns, rows = _parse_csv_text(raw) + summary = summarize_rows(rows, columns).to_dict() + + return { + "csv_path": str(path), + "question": question, + "summary": summary, + "prompt": build_prompt(summary, question), + } diff --git a/bitnet_tools/cli.py b/bitnet_tools/cli.py new file mode 100644 index 0000000..0d91da4 --- /dev/null +++ b/bitnet_tools/cli.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + +from .analysis import build_analysis_payload +from .web import serve + + +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() + + +def run_analyze(args: argparse.Namespace) -> int: + payload = build_analysis_payload(args.csv, args.question) + args.out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"payload saved: {args.out}") + + if args.model: + print(f"running ollama model: {args.model}") + answer = run_ollama(args.model, payload["prompt"]) + print("\n=== BitNet answer ===") + print(answer) + + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="BitNet CSV analysis tools") + sub = parser.add_subparsers(dest="command", required=True) + + analyze = sub.add_parser("analyze", help="Build prompt payload from CSV") + analyze.add_argument("csv", type=Path, help="Input CSV path") + analyze.add_argument("--question", required=True, help="Analysis question") + analyze.add_argument("--model", default=None, help="Optional Ollama model tag") + analyze.add_argument( + "--out", + type=Path, + default=Path("analysis_payload.json"), + help="Where to store generated payload JSON", + ) + analyze.set_defaults(func=run_analyze) + + ui = sub.add_parser("ui", help="Run local web UI") + ui.add_argument("--host", default="127.0.0.1") + ui.add_argument("--port", type=int, default=8765) + ui.set_defaults(func=lambda a: serve(a.host, a.port) or 0) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) 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 답변까지 한 번에.

+ +
+ + + +
+ +
+ +
+ + + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+

데이터 요약

+

+      
+ +
+

생성 프롬프트

+ +

+      
+ +
+

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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..77de93a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "bitnet-tools" +version = "0.2.0" +description = "BitNet-first local data analysis helper" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +bitnet-analyze = "bitnet_tools.cli:main" + +[tool.setuptools.package-data] +bitnet_tools = ["ui/*.html", "ui/*.js", "ui/*.css"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..44a48ff --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,42 @@ +from bitnet_tools.analysis import ( + build_analysis_payload, + build_analysis_payload_from_csv_text, + summarize_rows, +) + + +def test_summarize_rows_basic(): + rows = [ + {"user_id": "1", "amount": "10.0", "segment": "a", "dt": "2026-01-01"}, + {"user_id": "2", "amount": "20.5", "segment": "b", "dt": "2026-01-02"}, + {"user_id": "3", "amount": "5.0", "segment": "", "dt": "2026-01-03"}, + ] + summary = summarize_rows(rows, ["user_id", "amount", "segment", "dt"]) + + assert summary.row_count == 3 + assert summary.column_count == 4 + assert summary.missing_counts["segment"] == 1 + assert "amount" in summary.numeric_stats + assert summary.numeric_stats["amount"]["median"] == 10.0 + assert "std" in summary.numeric_stats["amount"] + assert summary.dtypes["segment"] == "string" + assert summary.dtypes["user_id"] == "int" + assert summary.dtypes["dt"] == "date" + assert summary.top_values["segment"][0] == ("a", 1) + + +def test_build_analysis_payload(tmp_path): + p = tmp_path / "sample.csv" + p.write_text("a,b\n1,10\n2,20\n", encoding="utf-8") + + payload = build_analysis_payload(p, "평균 b를 설명해줘") + + assert payload["csv_path"].endswith("sample.csv") + assert payload["summary"]["row_count"] == 2 + assert "핵심요약 / 근거 / 한계 / 다음행동" in payload["prompt"] + + +def test_build_analysis_payload_from_csv_text_with_semicolon(): + payload = build_analysis_payload_from_csv_text("x;y\n1;2\n3;4\n", "질문") + assert payload["summary"]["column_count"] == 2 + assert payload["summary"]["numeric_stats"]["y"]["max"] == 4.0