diff --git a/.gitignore b/.gitignore
index 335d224..2663397 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,7 @@ __pycache__/
# Test/runtime caches
.pytest_cache/
.bitnet_cache/
+
+.offline_bundle/
+
+.online_assets/
diff --git a/BASELINE_PLAN.md b/BASELINE_PLAN.md
new file mode 100644
index 0000000..3ef734f
--- /dev/null
+++ b/BASELINE_PLAN.md
@@ -0,0 +1,20 @@
+# Baseline plan (0단계)
+
+이 문서는 개선 작업 전/후 비교를 위한 기준선 고정 절차를 정의한다.
+
+## 고정 기준
+- 테스트 전체 통과 여부 (`pytest -q`)
+- 대표 입력 CSV 3종 결과 일관성
+ - `tests/fixtures/small_numeric.csv`
+ - `tests/fixtures/mixed_formats.csv`
+ - `tests/fixtures/missing_heavy.csv`
+- 핵심 요약 결과 스냅샷
+ - row_count, column_count
+ - dtypes
+ - missing_counts
+ - numeric_stats
+
+## 운영 방법
+1. 개선 전 baseline 테스트를 실행해 현재 결과를 확인
+2. 개선 작업 후 동일 테스트를 재실행
+3. 의도하지 않은 필드 변경이 있으면 원인 분석 후 수정
diff --git a/NEXT_STEPS_UI_AND_BACKLOG.md b/NEXT_STEPS_UI_AND_BACKLOG.md
new file mode 100644
index 0000000..00ba639
--- /dev/null
+++ b/NEXT_STEPS_UI_AND_BACKLOG.md
@@ -0,0 +1,152 @@
+# BitNet Analyzer 다음 단계 작업 정리
+
+이 문서는 다음 턴부터 바로 실행할 수 있도록
+1) 인터페이스 고도화 진행 단계,
+2) 전체 남은 과제(개선권장/코드 최적화/입력 포맷 확장)
+를 한 번에 정리한 체크리스트입니다.
+
+---
+
+## A. 인터페이스 고도화 진행 단계 (다음 턴부터 착수)
+
+### Phase 1 — 인터페이스 단순화(우선)
+목표: "처음 들어온 사용자도 바로 분석" 가능한 화면으로 정리
+
+- [ ] 화면을 2모드로 분리
+ - [ ] 빠른 시작 모드: 파일 입력 + 자연어 요청 + 실행 버튼 1개
+ - [ ] 고급 모드: 그룹/타깃, JSON 대시보드, 모델 태그 등
+- [ ] 섹션 구조 재배치
+ - [ ] 입력
+ - [ ] 실행 상태
+ - [ ] 결과
+- [ ] 초기에 보이는 요소 축소
+ - [ ] 멀티 분석/JSON 대시보드는 접기(Accordion) 기본
+
+완료 기준:
+- 신규 사용자가 30초 내 첫 분석 실행 가능
+- 단일 분석 중심 플로우에서 클릭 수 감소
+
+### Phase 2 — 자연어 요청 인터페이스
+목표: 사용자의 작업 지시를 자연어로 받아 흐름 자동 선택
+
+- [ ] "작업 요청" 입력창 추가 (예: "이 파일 먼저 분석하고 시각화 옵션 알려줘")
+- [ ] 의도 라우팅 규칙 추가
+ - [ ] 분석 우선
+ - [ ] 시각화 옵션 안내 우선
+ - [ ] 멀티 비교 우선
+- [ ] 질문(question)과 작업요청(intent) 분리 저장
+
+완료 기준:
+- 자연어 요청 한 번으로 기본 작업 흐름 자동 진행
+- 요청 해석 실패 시 추천 액션 버튼 제공
+
+### Phase 3 — 상태/피드백 표준화
+목표: 진행상황/실패 원인/다음 행동을 명확히 안내
+
+- [ ] 로딩 상태 통합 컴포넌트
+ - [ ] 분석 중
+ - [ ] BitNet 실행 중
+ - [ ] 멀티 분석 중
+ - [ ] 차트 생성 중
+- [ ] 에러 메시지 표준화
+ - [ ] 사용자용 간단 메시지
+ - [ ] 상세 기술 메시지(접기)
+- [ ] 버튼 활성/비활성 규칙 정리
+
+완료 기준:
+- 실패 시 사용자가 다음 액션을 바로 알 수 있음
+- 중복 클릭/중복 요청 최소화
+
+### Phase 4 — 대시보드 사용성 강화
+목표: 결과 확인 시간을 줄이고 탐색 효율 개선
+
+- [ ] KPI 카드 + 인사이트 리스트 개선
+- [ ] 필터 추가
+ - [ ] 파일 단위
+ - [ ] 컬럼명 검색
+ - [ ] 인사이트 유형(결측/이상치/드리프트)
+- [ ] 드릴다운 패널 추가
+ - [ ] 선택 인사이트 근거 데이터 표기
+
+완료 기준:
+- 핵심 인사이트 3개 확인 시간 단축
+- 사용자가 근거를 UI에서 즉시 확인 가능
+
+### Phase 5 — 비동기 차트 UX 연동
+목표: 대형 데이터에서도 차트 생성 경험 안정화
+
+- [ ] 차트 job 상태 표시(queued/running/done/failed)
+- [ ] polling + 완료 시 결과 자동 갱신
+- [ ] 실패 재시도 버튼
+
+완료 기준:
+- 차트 생성 대기 중에도 UI 사용 가능
+- 실패 복구 동선 제공
+
+---
+
+## B. 남은 과제 백로그 (우선순위 포함)
+
+## B-1. 분석 정확도 개선
+
+### P1. 날짜 파싱 포맷 확장
+- [ ] 날짜 포맷 후보 확장 (`YYYYMMDD`, `DD-MM-YYYY`, locale 변형)
+- [ ] 모호한 포맷은 불확실 상태로 분리
+
+### P1. 결과 스키마 버전 필드 도입
+- [ ] JSON 결과에 `schema_version` 추가
+- [ ] 버전 변경 로그 문서화
+
+### P2. 고유값 추정 정밀 옵션
+- [ ] 기본(빠른 추정) + 정밀 모드(옵션) 이원화
+- [ ] 결과에 추정 방식 메타데이터 포함
+
+## B-2. 코드 최적화/구조화
+
+### P1. UI 스크립트 모듈화
+- [ ] `app.js`를 기능 단위로 분리
+ - [ ] 상태관리
+ - [ ] API 호출
+ - [ ] 렌더링
+ - [ ] 이벤트 바인딩
+
+### P1. API/에러 핸들링 공통화
+- [ ] 공통 fetch wrapper
+- [ ] 에러 포맷 표준(JSON)
+
+### P2. 성능 개선
+- [ ] 대형 JSON 렌더 incremental 처리
+- [ ] 불필요한 재렌더 최소화
+
+## B-3. 입력 파일 확장성 (후순위)
+
+현재 직접 지원은 CSV 중심이므로, 아래를 순차 확장:
+
+### P2. Excel 지원 (`.xlsx`, `.xls`)
+- [ ] 시트 선택 UI
+- [ ] 표 데이터 CSV 정규화
+
+### P3. 문서 포맷 지원 (`.pdf`, `.docx`, `.pptx`)
+- [ ] 표 추출 파이프라인 도입
+- [ ] 추출 정확도/신뢰도 표시
+- [ ] 실패 시 CSV 업로드 fallback 제공
+
+---
+
+## C. 다음 턴 실행 순서 (권장)
+
+1. Phase 1 (단순화) 착수
+2. Phase 2 (자연어 요청 인터페이스)
+3. Phase 3 (상태/에러 표준화)
+4. Phase 4 (대시보드 필터/드릴다운)
+5. Phase 5 (차트 비동기 UX)
+
+병행 과제로 `schema_version` 설계를 함께 진행.
+
+---
+
+## D. 체크 포인트
+
+- [ ] 기능 개발 후 `pytest -q` 통과
+- [ ] UI 주요 흐름 스모크 테스트
+- [ ] 문서 업데이트(README/변경 이력)
diff --git a/ONLINE_EXECUTION_LOG.md b/ONLINE_EXECUTION_LOG.md
new file mode 100644
index 0000000..73c3dbc
--- /dev/null
+++ b/ONLINE_EXECUTION_LOG.md
@@ -0,0 +1,25 @@
+# 인터넷 1턴 실행 로그 및 후속 가이드
+
+## 이번 턴 수행 내용
+- `scripts/prepare_online_bundle.sh` 추가
+- 온라인 가능 시 다음을 자동 수행하도록 구성
+ - 환경 메타데이터 수집
+ - 로컬 wheel 빌드 시도
+ - 선택 의존성 wheel 다운로드 시도
+ - Ollama 설치 스크립트 보관 시도
+ - 오프라인 사용 가이드 생성
+
+## 이번 환경에서의 결과
+- 프록시 제한(403)으로 외부 다운로드 실패
+- Ollama 설치 스크립트도 403으로 실패
+- 따라서 다운로드 단계는 경고 파일로 남기고, 스크립트는 종료하지 않도록 설계
+
+## 다음 네트워크 허용 환경에서 기대 결과
+- `.offline_bundle/wheels`에 프로젝트 및 선택 의존성 wheel 저장
+- `.offline_bundle/models/ollama_install.sh` 보관
+- `.offline_bundle/OFFLINE_USE.md` 기반으로 오프라인 설치 가능
+
+## 실행 명령
+```bash
+./scripts/prepare_online_bundle.sh
+```
diff --git a/ONLINE_RESOURCE_COLLECTION.md b/ONLINE_RESOURCE_COLLECTION.md
new file mode 100644
index 0000000..a8fefe8
--- /dev/null
+++ b/ONLINE_RESOURCE_COLLECTION.md
@@ -0,0 +1,19 @@
+# Online tool/reference collection
+
+인터넷이 열려 있는 턴에서 필요한 툴 패키지(wheel)와 UI/접근성 레퍼런스를 로컬로 저장하기 위한 작업 기록 문서.
+
+## Source catalog
+- `resources/online_sources.json`
+ - `tool_packages`: 다운로드 대상 pip 패키지 목록
+ - `reference_urls`: 저장 대상 웹 레퍼런스 URL 목록
+
+## Collector
+- `scripts/collect_online_assets.py`
+ - pip wheel 다운로드 시도 (`.online_assets/wheels`)
+ - 레퍼런스 HTML 다운로드 시도 (`.online_assets/references`)
+ - 결과 리포트 생성 (`.online_assets/meta/collection_report.json`)
+
+## Run
+```bash
+python scripts/collect_online_assets.py
+```
diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md
new file mode 100644
index 0000000..4e0caed
--- /dev/null
+++ b/REVIEW_REPORT.md
@@ -0,0 +1,83 @@
+# 실행 가능률 및 분석 기능 점검 보고서
+
+## 1) 현재 실행 가능률(환경 기준)
+
+- 테스트 실행 결과: `24 passed` (핵심 분석/CLI/웹 핸들러 단위 기능 정상)
+- 로컬 환경 진단 결과: Python/플랫폼 확인 가능, `ollama` 미설치로 모델 실행 경로는 현재 비활성
+
+### 실행 가능률 산정(현 환경)
+- 코드 자체 품질(테스트 통과율): **100% (24/24)**
+- LLM 연동 포함 엔드투엔드 실사용률: **약 85~90%**
+ - 사유: `analyze`, `multi-analyze`, `report`, `ui`, `doctor`는 동작 가능
+ - 단, `ollama run`이 필요한 즉시 모델 응답(`/api/run`, `--model`)은 로컬 ollama 설치/기동 필요
+
+## 2) 제공 분석 기능
+
+### 단일 CSV 분석
+- 행/열 수, 컬럼 목록, 결측 수
+- 컬럼 타입 추론(숫자/문자)
+- 숫자형 기본 통계(count/mean/min/max)
+- BitNet 프롬프트 자동 생성
+- Markdown 보고서 생성
+
+### 다중 CSV 분석
+- 파일별 프로파일링(결측/고유비율/대표값/의미타입)
+- 공통 컬럼/전체 컬럼 집합 비교
+- 파일 간 스키마 드리프트(타입 변화, 결측비율 범위, 대표값비율 범위, 평균 변화)
+- 인사이트 룰 엔진(결측 높음, 이상치 비율 높음, 평균 변화 등)
+- 그룹-타깃 비율표(옵션)
+- pandas 코드 가이드 자동 생성
+- 캐시 기반 재분석 가속(`.bitnet_cache`)
+- 병렬 파일 프로파일링(workers)
+
+### 시각화
+- 수치형: histogram, boxplot, missing bar
+- 범주형: top-k bar
+- 수치형 2개 이상 시 scatter 샘플
+- 대용량 대응을 위한 reservoir sampling 적용
+
+### 웹/데스크톱
+- 브라우저 UI: CSV 텍스트 붙여넣기 단일/다중 분석
+- 차트 생성 비동기 작업(job) 제출/조회 API
+- Windows 데스크톱 UI 진입점 제공
+
+## 3) 받아본 데이터로 도출 가능한 결과
+
+- 데이터 품질 진단: 결측/편중/희소성/대표값 쏠림
+- 수치 분포 요약: 평균/최솟값/최댓값/양수·0·음수 비율
+- 이상치 위험도: IQR 기반 outlier ratio 추정
+- 컬럼 성격 파악: category/date/numeric/text/위경도 추정
+- 다중 파일 비교: 스키마 호환성 및 분포 변화(드리프트)
+- 운영 인사이트: 품질 이슈 우선순위(결측↑, 이상치↑, 타입 충돌)
+- 후속 분석 가이드: 병합 키 중심 pandas 예시 코드 자동 제안
+
+## 4) 분석 가능한 데이터 범위
+
+### 직접 지원
+- CSV 파일(단일/다중)
+- 웹 UI 입력용 CSV 텍스트(붙여넣기)
+
+### 확장/간접 지원
+- 생성된 JSON/Markdown 결과를 BitNet 프롬프트로 전달해 해석형 요약 가능
+- 코드 가이드를 활용한 pandas 후처리 확장 가능
+
+### 주의사항
+- CSV 헤더 필수(헤더 없으면 오류)
+- 숫자형은 컬럼 내 텍스트 혼입 시 string으로 판정될 수 있음
+- 고유값은 비트맵 기반 추정치(정확 cardinality 아님)
+- outlier ratio는 샘플 기반 추정
+
+## 5) 코드 전체 검토 요약
+
+### 강점
+- 분석 엔진이 스트리밍/샘플링 중심으로 메모리 사용을 제어
+- CLI, 웹 API, 데스크톱 진입점이 분리되어 사용성 좋음
+- 캐시/병렬 처리 등 실사용 성능 요소 반영
+- 테스트 커버리지(기능 단위) 양호
+
+### 개선 권장
+- 숫자형 추론 고도화(천 단위 구분기호, 퍼센트, 통화 기호 정규화)
+- 날짜 파싱 포맷 확장 및 locale 대응
+- unique 추정 정확도 옵션(HLL 등) 추가
+- UI/API 레벨에서 대용량 파일 업로드/진행률/취소 제어 강화
+- 결과 스키마 버전 필드 추가(하위호환 관리)
diff --git a/bitnet_tools/analysis.py b/bitnet_tools/analysis.py
index d96ad72..c817620 100644
--- a/bitnet_tools/analysis.py
+++ b/bitnet_tools/analysis.py
@@ -32,11 +32,32 @@ def _to_float(value: str) -> float | None:
v = value.strip()
if not v:
return None
+
+ negative_by_parentheses = v.startswith("(") and v.endswith(")")
+ if negative_by_parentheses:
+ v = v[1:-1].strip()
+
+ # normalize frequent human-entered numeric formats
+ v = (
+ v.replace(",", "")
+ .replace("₩", "")
+ .replace("$", "")
+ .replace("€", "")
+ .replace("£", "")
+ .replace("%", "")
+ .strip()
+ )
+
+ if not v:
+ return None
+
try:
- return float(v)
+ parsed = float(v)
except ValueError:
return None
+ return -parsed if negative_by_parentheses else parsed
+
def summarize_rows(rows: list[dict[str, str]], columns: list[str]) -> DataSummary:
return summarize_reader(rows, columns)
diff --git a/bitnet_tools/ui/app.js b/bitnet_tools/ui/app.js
index cc11c18..f550147 100644
--- a/bitnet_tools/ui/app.js
+++ b/bitnet_tools/ui/app.js
@@ -3,10 +3,12 @@ const csvText = document.getElementById('csvText');
const question = document.getElementById('question');
const model = document.getElementById('model');
const analyzeBtn = document.getElementById('analyzeBtn');
+const quickAnalyzeBtn = document.getElementById('quickAnalyzeBtn');
const runBtn = document.getElementById('runBtn');
const summary = document.getElementById('summary');
const prompt = document.getElementById('prompt');
const answer = document.getElementById('answer');
+const statusBox = document.getElementById('statusBox');
const multiCsvFiles = document.getElementById('multiCsvFiles');
const groupColumn = document.getElementById('groupColumn');
@@ -18,24 +20,57 @@ const dashboardInsights = document.getElementById('dashboardInsights');
let latestPrompt = '';
-csvFile.addEventListener('change', async (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- csvText.value = await file.text();
+function setStatus(message) {
+ if (statusBox) statusBox.textContent = message;
+}
+
+function setMode(mode) {
+ const advancedOnly = document.querySelectorAll('.advanced-only');
+ advancedOnly.forEach((el) => {
+ el.style.display = mode === 'advanced' ? '' : 'none';
+ });
+
+ document.querySelectorAll('.mode-btn').forEach((btn) => {
+ btn.classList.toggle('active', btn.dataset.mode === mode);
+ });
+
+ if (mode === 'quick') {
+ setStatus('빠른 시작 모드: 파일 입력 후 "바로 분석"을 눌러주세요.');
+ } else {
+ setStatus('고급 모드: 모델 실행, 멀티 분석, 대시보드를 사용할 수 있습니다.');
+ }
+}
+
+document.querySelectorAll('.mode-btn').forEach((btn) => {
+ btn.addEventListener('click', () => setMode(btn.dataset.mode));
});
+if (csvFile) {
+ csvFile.addEventListener('change', async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ csvText.value = await file.text();
+ setStatus(`파일 로드 완료: ${file.name}`);
+ });
+}
+
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);
-});
+const copyPromptBtn = document.getElementById('copyPrompt');
+if (copyPromptBtn) {
+ copyPromptBtn.addEventListener('click', async () => {
+ if (!latestPrompt) return;
+ await navigator.clipboard.writeText(latestPrompt);
+ setStatus('프롬프트가 복사되었습니다.');
+ });
+}
-analyzeBtn.addEventListener('click', async () => {
+async function runAnalyze() {
+ setStatus('분석 중...');
summary.textContent = '분석 중...';
const res = await fetch('/api/analyze', {
method: 'POST',
@@ -48,97 +83,120 @@ analyzeBtn.addEventListener('click', async () => {
const data = await res.json();
if (!res.ok) {
summary.textContent = data.error || 'error';
+ setStatus(`분석 실패: ${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 }),
+ if (prompt) prompt.textContent = data.prompt;
+ if (answer) answer.textContent = '';
+ setStatus('분석 완료');
+}
+
+if (analyzeBtn) analyzeBtn.addEventListener('click', runAnalyze);
+if (quickAnalyzeBtn) quickAnalyzeBtn.addEventListener('click', runAnalyze);
+
+if (runBtn) {
+ runBtn.addEventListener('click', async () => {
+ if (!latestPrompt) {
+ if (answer) answer.textContent = '먼저 분석을 실행해 프롬프트를 생성하세요.';
+ setStatus('모델 실행 중단: 프롬프트가 없습니다.');
+ return;
+ }
+ if (!model.value.trim()) {
+ if (answer) answer.textContent = '모델 태그를 입력하세요. 예: bitnet:latest';
+ setStatus('모델 실행 중단: 모델 태그가 없습니다.');
+ return;
+ }
+
+ setStatus('BitNet 실행 중...');
+ if (answer) 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();
+ if (answer) answer.textContent = res.ok ? data.answer : (data.error || 'error');
+ setStatus(res.ok ? 'BitNet 실행 완료' : `BitNet 실행 실패: ${data.error || 'error'}`);
});
- const data = await res.json();
- answer.textContent = res.ok ? data.answer : (data.error || 'error');
-});
-
-document.getElementById('renderDashboardBtn').addEventListener('click', () => {
- dashboardCards.innerHTML = '';
- dashboardInsights.textContent = '';
-
- let parsed;
- try {
- parsed = JSON.parse(dashboardJson.value || '{}');
- } catch {
- dashboardInsights.textContent = 'JSON 형식이 올바르지 않습니다.';
- return;
- }
-
- const cardItems = [
- ['파일 수', parsed.file_count ?? '-'],
- ['총 행 수', parsed.total_row_count ?? '-'],
- ['공통 컬럼 수', (parsed.shared_columns || []).length],
- ['인사이트 수', (parsed.insights || []).length],
- ];
-
- cardItems.forEach(([k, v]) => {
- const div = document.createElement('div');
- div.className = 'card';
- div.innerHTML = `${k}${v}`;
- dashboardCards.appendChild(div);
+}
+
+const renderDashboardBtn = document.getElementById('renderDashboardBtn');
+if (renderDashboardBtn) {
+ renderDashboardBtn.addEventListener('click', () => {
+ dashboardCards.innerHTML = '';
+ dashboardInsights.textContent = '';
+
+ let parsed;
+ try {
+ parsed = JSON.parse(dashboardJson.value || '{}');
+ } catch {
+ dashboardInsights.textContent = 'JSON 형식이 올바르지 않습니다.';
+ setStatus('대시보드 렌더 실패: JSON 형식 오류');
+ return;
+ }
+
+ const cardItems = [
+ ['파일 수', parsed.file_count ?? '-'],
+ ['총 행 수', parsed.total_row_count ?? '-'],
+ ['공통 컬럼 수', (parsed.shared_columns || []).length],
+ ['인사이트 수', (parsed.insights || []).length],
+ ];
+
+ cardItems.forEach(([k, v]) => {
+ const div = document.createElement('div');
+ div.className = 'card';
+ div.innerHTML = `${k}${v}`;
+ dashboardCards.appendChild(div);
+ });
+
+ const insights = parsed.insights || [];
+ dashboardInsights.textContent = insights.length
+ ? insights.map((x, i) => `${i + 1}. ${x}`).join('\n')
+ : '인사이트 항목이 없습니다.';
+ setStatus('대시보드 렌더 완료');
});
-
- const insights = parsed.insights || [];
- dashboardInsights.textContent = insights.length
- ? insights.map((x, i) => `${i + 1}. ${x}`).join('\n')
- : '인사이트 항목이 없습니다.';
-});
-
-
-multiAnalyzeBtn.addEventListener('click', async () => {
- const files = [...(multiCsvFiles.files || [])];
- if (!files.length) {
- dashboardInsights.textContent = '멀티 CSV 파일을 먼저 선택하세요.';
- return;
- }
-
- dashboardInsights.textContent = '멀티 분석 중...';
- const payloadFiles = [];
- for (const f of files) {
- payloadFiles.push({ name: f.name, csv_text: await f.text() });
- }
-
- const res = await fetch('/api/multi-analyze', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- files: payloadFiles,
- question: question.value,
- group_column: groupColumn.value.trim(),
- target_column: targetColumn.value.trim(),
- }),
+}
+
+if (multiAnalyzeBtn) {
+ multiAnalyzeBtn.addEventListener('click', async () => {
+ const files = [...(multiCsvFiles.files || [])];
+ if (!files.length) {
+ dashboardInsights.textContent = '멀티 CSV 파일을 먼저 선택하세요.';
+ setStatus('멀티 분석 중단: 파일 없음');
+ return;
+ }
+
+ setStatus('멀티 분석 중...');
+ dashboardInsights.textContent = '멀티 분석 중...';
+ const payloadFiles = [];
+ for (const f of files) {
+ payloadFiles.push({ name: f.name, csv_text: await f.text() });
+ }
+
+ const res = await fetch('/api/multi-analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: payloadFiles,
+ question: question.value,
+ group_column: groupColumn.value.trim(),
+ target_column: targetColumn.value.trim(),
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ dashboardInsights.textContent = data.error || 'error';
+ setStatus(`멀티 분석 실패: ${data.error || 'error'}`);
+ return;
+ }
+
+ dashboardJson.value = JSON.stringify(data, null, 2);
+ renderDashboardBtn.click();
+ setStatus('멀티 분석 완료');
});
- const data = await res.json();
- if (!res.ok) {
- dashboardInsights.textContent = data.error || 'error';
- return;
- }
+}
- dashboardJson.value = JSON.stringify(data, null, 2);
- document.getElementById('renderDashboardBtn').click();
-});
+setMode('quick');
diff --git a/bitnet_tools/ui/index.html b/bitnet_tools/ui/index.html
index 673c72b..a0cd806 100644
--- a/bitnet_tools/ui/index.html
+++ b/bitnet_tools/ui/index.html
@@ -9,55 +9,70 @@
BitNet CSV Analyzer
- CSV 업로드 → 자동 요약 → BitNet 답변 + 멀티 분석 대시보드.
+ 파일 입력 → 분석 실행 → 결과 확인 순서로 바로 시작하세요.
+ 1) 작업 모드
+
+
+
+
+ 빠른 시작은 핵심 분석에 집중하고, 고급 모드는 멀티 분석/모델 실행 옵션을 제공합니다.
+
+
+
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
+
-
-
- 멀티 CSV 원클릭 분석
- 여러 CSV 파일 선택 후 바로 통합 분석 + 대시보드 반영.
+
-
- 멀티 분석 대시보드(JSON)
- `multi-analyze` 결과 JSON을 붙여넣고 시각적으로 확인하세요.
+
+ 고급: 멀티 분석 대시보드(JSON)
diff --git a/bitnet_tools/ui/styles.css b/bitnet_tools/ui/styles.css
index e067f86..372e8b3 100644
--- a/bitnet_tools/ui/styles.css
+++ b/bitnet_tools/ui/styles.css
@@ -5,6 +5,7 @@
--text: #f9fafb;
--muted: #9ca3af;
--accent: #22c55e;
+ --secondary: #334155;
}
* { box-sizing: border-box; }
body {
@@ -14,7 +15,7 @@ body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
.container {
- max-width: 980px;
+ max-width: 960px;
margin: 24px auto;
padding: 0 16px 40px;
}
@@ -26,7 +27,7 @@ body {
margin: 12px 0;
}
.row { display: flex; justify-content: space-between; gap: 12px; align-items: end; flex-wrap: wrap; }
-label { display: block; margin-bottom: 8px; color: var(--muted); }
+label { display: block; margin: 10px 0 8px; color: var(--muted); }
textarea, input {
width: 100%;
background: #0b1220;
@@ -44,9 +45,17 @@ button {
font-weight: 700;
cursor: pointer;
}
-.chips { display: flex; gap: 8px; margin-bottom: 8px; }
-.chip { background: #334155; color: #e2e8f0; }
-.actions { display: flex; gap: 8px; }
+.actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.chips { display: flex; gap: 8px; margin: 8px 0; flex-wrap: wrap; }
+.chip,
+.mode-btn {
+ background: var(--secondary);
+ color: #e2e8f0;
+}
+.mode-btn.active {
+ background: var(--accent);
+ color: #052e16;
+}
pre {
white-space: pre-wrap;
background: #0b1220;
diff --git a/resources/online_sources.json b/resources/online_sources.json
new file mode 100644
index 0000000..c9d1494
--- /dev/null
+++ b/resources/online_sources.json
@@ -0,0 +1,16 @@
+{
+ "tool_packages": [
+ "matplotlib",
+ "pandas",
+ "jupyterlab",
+ "pytest",
+ "playwright"
+ ],
+ "reference_urls": [
+ "https://www.w3.org/WAI/WCAG22/quickref/",
+ "https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA",
+ "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API",
+ "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl",
+ "https://web.dev/articles/loading-patterns"
+ ]
+}
diff --git a/scripts/collect_online_assets.py b/scripts/collect_online_assets.py
new file mode 100755
index 0000000..37e30a3
--- /dev/null
+++ b/scripts/collect_online_assets.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import json
+import re
+import subprocess
+import sys
+from dataclasses import dataclass, asdict
+from datetime import datetime, timezone
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+from urllib.request import Request, urlopen
+
+ROOT = Path(__file__).resolve().parent.parent
+SOURCES_FILE = ROOT / "resources" / "online_sources.json"
+OUT_DIR = ROOT / ".online_assets"
+REF_DIR = OUT_DIR / "references"
+WHEEL_DIR = OUT_DIR / "wheels"
+META_DIR = OUT_DIR / "meta"
+
+
+@dataclass
+class DownloadResult:
+ target: str
+ category: str
+ ok: bool
+ path: str | None
+ detail: str
+
+
+def _slug_from_url(url: str) -> str:
+ parsed = urlparse(url)
+ stem = (parsed.netloc + parsed.path).strip("/") or "index"
+ stem = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem)
+ return f"{stem}.html"
+
+
+def _fetch_reference(url: str) -> DownloadResult:
+ REF_DIR.mkdir(parents=True, exist_ok=True)
+ out_path = REF_DIR / _slug_from_url(url)
+ req = Request(url, headers={"User-Agent": "bitnet-tools/online-collector"})
+ try:
+ with urlopen(req, timeout=20) as resp:
+ body = resp.read()
+ out_path.write_bytes(body)
+ return DownloadResult(url, "reference", True, str(out_path.relative_to(ROOT)), "downloaded")
+ except HTTPError as exc:
+ return DownloadResult(url, "reference", False, None, f"http_error:{exc.code}")
+ except URLError as exc:
+ return DownloadResult(url, "reference", False, None, f"url_error:{exc.reason}")
+ except Exception as exc: # pragma: no cover
+ return DownloadResult(url, "reference", False, None, f"error:{exc}")
+
+
+def _download_wheel(pkg: str) -> DownloadResult:
+ WHEEL_DIR.mkdir(parents=True, exist_ok=True)
+ cmd = [sys.executable, "-m", "pip", "download", pkg, "-d", str(WHEEL_DIR)]
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if proc.returncode == 0:
+ return DownloadResult(pkg, "tool_package", True, str(WHEEL_DIR.relative_to(ROOT)), "downloaded")
+ detail = proc.stderr.strip() or proc.stdout.strip() or "pip download failed"
+ return DownloadResult(pkg, "tool_package", False, None, detail.splitlines()[-1][:220])
+
+
+def main() -> int:
+ if not SOURCES_FILE.exists():
+ print(f"sources file not found: {SOURCES_FILE}", file=sys.stderr)
+ return 1
+
+ data = json.loads(SOURCES_FILE.read_text(encoding="utf-8"))
+ tool_packages: list[str] = list(data.get("tool_packages", []))
+ reference_urls: list[str] = list(data.get("reference_urls", []))
+
+ OUT_DIR.mkdir(exist_ok=True)
+ META_DIR.mkdir(parents=True, exist_ok=True)
+
+ results: list[DownloadResult] = []
+
+ for pkg in tool_packages:
+ print(f"[tool] {pkg}")
+ results.append(_download_wheel(pkg))
+
+ for url in reference_urls:
+ print(f"[ref] {url}")
+ results.append(_fetch_reference(url))
+
+ report = {
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "python": sys.version,
+ "source_file": str(SOURCES_FILE.relative_to(ROOT)),
+ "results": [asdict(r) for r in results],
+ "summary": {
+ "total": len(results),
+ "success": sum(1 for r in results if r.ok),
+ "failed": sum(1 for r in results if not r.ok),
+ },
+ }
+
+ out = META_DIR / "collection_report.json"
+ out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(f"report saved: {out.relative_to(ROOT)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/prepare_online_bundle.sh b/scripts/prepare_online_bundle.sh
new file mode 100755
index 0000000..bd52f49
--- /dev/null
+++ b/scripts/prepare_online_bundle.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+BUNDLE_DIR="${ROOT_DIR}/.offline_bundle"
+WHEEL_DIR="${BUNDLE_DIR}/wheels"
+MODEL_DIR="${BUNDLE_DIR}/models"
+META_DIR="${BUNDLE_DIR}/meta"
+
+mkdir -p "${WHEEL_DIR}" "${MODEL_DIR}" "${META_DIR}"
+
+echo "[1/6] Collecting environment metadata"
+python -V | tee "${META_DIR}/python_version.txt"
+pip --version | tee "${META_DIR}/pip_version.txt"
+python -m pip freeze | tee "${META_DIR}/pip_freeze.txt" >/dev/null
+
+cat > "${META_DIR}/bundle_manifest.txt" <&1)
+pip=$(pip --version)
+MANIFEST
+
+echo "[2/6] Building local project wheel"
+if python -m pip wheel --no-build-isolation "${ROOT_DIR}" -w "${WHEEL_DIR}"; then
+ echo "local wheel build: success"
+else
+ echo "local wheel build failed" | tee "${META_DIR}/wheel_build_warning.txt"
+fi
+
+# Optional runtime dependencies for charts/notebooks/tests
+cat > "${META_DIR}/requirements_online.txt" </dev/null 2>&1; then
+ ollama --version | tee "${META_DIR}/ollama_version.txt"
+ # Avoid model pull in automated script unless explicitly requested
+ echo "ollama detected; model pull can be run manually:" | tee -a "${META_DIR}/ollama_version.txt"
+ echo " ollama pull " | tee -a "${META_DIR}/ollama_version.txt"
+else
+ echo "ollama not installed in current environment" | tee "${META_DIR}/ollama_version.txt"
+fi
+
+echo "[6/6] Writing offline install guide"
+cat > "${BUNDLE_DIR}/OFFLINE_USE.md" <