Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions bitnet_tools/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const UI = {
refreshSheetsBtn: document.getElementById('refreshSheetsBtn'),
csvText: document.getElementById('csvText'),
question: document.getElementById('question'),
showVizOptionsBtn: document.getElementById('showVizOptionsBtn'),
vizRecommendation: document.getElementById('vizRecommendation'),
intent: document.getElementById('intent'),
intentActions: document.getElementById('intentActions'),
model: document.getElementById('model'),
Expand Down Expand Up @@ -85,6 +87,7 @@ const appState = {
uploadedFile: null,
detectedInputType: 'csv',
candidateTables: [],
vizRecommendation: null,
};

const CONFIDENCE_THRESHOLD_DEFAULT = 0.7;
Expand Down Expand Up @@ -676,6 +679,32 @@ function setPreprocessStatusText(text) {
if (UI.preprocessStatus) UI.preprocessStatus.textContent = text;
}

function renderVizRecommendation(rec) {
appState.vizRecommendation = rec || null;
if (!UI.vizRecommendation) return;
if (!rec) {
UI.vizRecommendation.textContent = '추천 시각화 옵션이 여기에 표시됩니다.';
return;
}
const charts = Array.isArray(rec.recommended_chart_types) ? rec.recommended_chart_types.join(', ') : '-';
UI.vizRecommendation.textContent = `의도: ${rec.intent || '-'}\n추천 차트: ${charts}\n추천 사유: ${rec.reason || '-'}`;
}

async function fetchVizRecommendation() {
clearError();
const question = (UI.question?.value || '').trim();
try {
const rec = await postJson('/api/viz/recommend', { question }, '시각화 추천');
renderVizRecommendation(rec);
setStatus('시각화 추천을 확인했습니다.');
return rec;
} catch (err) {
showError(err.userMessage || '시각화 추천 실패', err.detail || '');
setStatus('시각화 추천 실패');
return null;
}
}

function stopPreprocessPolling() {
if (appState.preprocessJob.pollTimer) {
clearInterval(appState.preprocessJob.pollTimer);
Expand Down Expand Up @@ -771,7 +800,13 @@ async function pollChartJobOnce() {
if (result.status === 'done') {
stopChartPolling();
UI.retryChartsJobBtn.disabled = true;
setStatus('차트 작업 완료');
if (result.fallback) {
const fallbackText = JSON.stringify(result.fallback, null, 2);
setChartsJobStatusText(`job=${result.job_id} status=done (fallback)\nreason=${result.fallback_reason || 'chart_generation_failed'}\n${fallbackText}`);
setStatus('차트 생성 실패로 표/요약 fallback을 제공합니다.');
} else {
setStatus('차트 작업 완료');
}
} else if (result.status === 'failed') {
stopChartPolling();
UI.retryChartsJobBtn.disabled = false;
Expand Down Expand Up @@ -812,7 +847,9 @@ async function startChartsJob() {
const payloadFiles = await buildMultiPayloadFiles(files);
appState.chartJob.files = payloadFiles;

const queued = await postJson('/api/charts/jobs', { files: payloadFiles }, '차트 작업 생성');
const rec = appState.vizRecommendation || await fetchVizRecommendation();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute chart recommendation for the current question

This reuses appState.vizRecommendation if it exists, so after a user fetches recommendations once and then edits the question, startChartsJob() still sends the old selected_chart_types. Because /api/charts/jobs only auto-recommends when selected_chart_types is empty, the stale list overrides the new question and can generate charts for the wrong intent.

Useful? React with 👍 / 👎.

const selectedChartTypes = Array.isArray(rec?.recommended_chart_types) ? rec.recommended_chart_types : [];
const queued = await postJson('/api/charts/jobs', { files: payloadFiles, question: UI.question?.value || '', selected_chart_types: selectedChartTypes }, '차트 작업 생성');
appState.chartJob.id = queued.job_id;
appState.chartJob.status = queued.status;
UI.retryChartsJobBtn.disabled = true;
Expand All @@ -836,7 +873,10 @@ async function retryChartsJob() {
clearError();
toggleBusy(true);
try {
const queued = await postJson('/api/charts/jobs', { files: appState.chartJob.files }, '차트 작업 재시도');
const selectedChartTypes = Array.isArray(appState.vizRecommendation?.recommended_chart_types)
? appState.vizRecommendation.recommended_chart_types
: [];
const queued = await postJson('/api/charts/jobs', { files: appState.chartJob.files, question: UI.question?.value || '', selected_chart_types: selectedChartTypes }, '차트 작업 재시도');
appState.chartJob.id = queued.job_id;
appState.chartJob.status = queued.status;
UI.retryChartsJobBtn.disabled = true;
Expand Down Expand Up @@ -1120,6 +1160,7 @@ function bindEvents() {

UI.analyzeBtn?.addEventListener('click', runByIntent);
UI.quickAnalyzeBtn?.addEventListener('click', runByIntent);
UI.showVizOptionsBtn?.addEventListener('click', fetchVizRecommendation);
UI.runBtn?.addEventListener('click', runModel);
UI.multiAnalyzeBtn?.addEventListener('click', runMultiAnalyze);
UI.startChartsJobBtn?.addEventListener('click', startChartsJob);
Expand Down Expand Up @@ -1195,6 +1236,7 @@ function init() {
if (UI.retryPreprocessBtn) UI.retryPreprocessBtn.disabled = true;
setChartsJobStatusText('차트 작업 대기 중');
setPreprocessStatusText('입력 전처리 대기 중');
renderVizRecommendation(null);
}

init();
4 changes: 4 additions & 0 deletions bitnet_tools/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ <h2>2) 입력</h2>
<button class="chip" data-q="실행 가능한 다음 액션 5개를 우선순위로 제안해줘">다음행동</button>
</div>
<textarea id="question" rows="3">핵심 인사이트 3개와 근거를 알려줘</textarea>
<div class="actions">
<button id="showVizOptionsBtn" type="button">시각화 옵션 보기</button>
</div>
<pre id="vizRecommendation" aria-live="polite">추천 시각화 옵션이 여기에 표시됩니다.</pre>

<label>작업 요청(intent)</label>
<textarea id="intent" rows="2" placeholder="예: 파일 먼저 분석하고 이상치만 보여줘"></textarea>
Expand Down
97 changes: 62 additions & 35 deletions bitnet_tools/visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from typing import Any


SUPPORTED_CHART_TYPES = {"histogram", "boxplot", "missing", "bar", "scatter", "line"}


SAMPLE_CAP = 20000
TOP_K = 10

Expand Down Expand Up @@ -103,6 +106,7 @@ def create_file_charts(
out_dir: Path,
max_numeric: int = 3,
max_categorical: int = 2,
selected_chart_types: list[str] | None = None,
) -> list[str]:
plt = _ensure_matplotlib()

Expand All @@ -116,36 +120,41 @@ def create_file_charts(

artifacts: list[str] = []
stem = _safe_stem(csv_path)
selected = {c.strip().lower() for c in (selected_chart_types or []) if c}
if not selected:
selected = set(SUPPORTED_CHART_TYPES)

for col in numeric_cols:
values: list[float] = profiles[col]["values"]
missing = profiles[col]["missing"]
if not values:
continue

fig = plt.figure(figsize=(7, 4))
plt.hist(values, bins=20)
plt.title(f"{stem} - {col} histogram(sample)")
plt.xlabel(col)
plt.ylabel("count")
plt.tight_layout()
out = out_dir / f"{stem}_{col}_hist.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

fig = plt.figure(figsize=(5, 4))
plt.boxplot(values, vert=True)
plt.title(f"{stem} - {col} boxplot(sample)")
plt.ylabel(col)
plt.tight_layout()
out = out_dir / f"{stem}_{col}_box.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))
if "histogram" in selected:
fig = plt.figure(figsize=(7, 4))
plt.hist(values, bins=20)
plt.title(f"{stem} - {col} histogram(sample)")
plt.xlabel(col)
plt.ylabel("count")
plt.tight_layout()
out = out_dir / f"{stem}_{col}_hist.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

if "boxplot" in selected:
fig = plt.figure(figsize=(5, 4))
plt.boxplot(values, vert=True)
plt.title(f"{stem} - {col} boxplot(sample)")
plt.ylabel(col)
plt.tight_layout()
out = out_dir / f"{stem}_{col}_box.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

total = profiles[col]["seen"] + missing
if total > 0:
if total > 0 and "missing" in selected:
fig = plt.figure(figsize=(5, 3))
plt.bar(["non_missing", "missing"], [profiles[col]["seen"], missing])
plt.title(f"{stem} - {col} missing overview")
Expand All @@ -162,17 +171,18 @@ def create_file_charts(

labels = [x[0] for x in items]
counts = [x[1] for x in items]
fig = plt.figure(figsize=(8, 4))
plt.bar(range(len(labels)), counts)
plt.xticks(range(len(labels)), labels, rotation=30, ha="right")
plt.title(f"{stem} - {col} top values")
plt.tight_layout()
out = out_dir / f"{stem}_{col}_top.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

if len(numeric_cols) >= 2:
if "bar" in selected:
fig = plt.figure(figsize=(8, 4))
plt.bar(range(len(labels)), counts)
plt.xticks(range(len(labels)), labels, rotation=30, ha="right")
plt.title(f"{stem} - {col} top values")
plt.tight_layout()
out = out_dir / f"{stem}_{col}_top.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

if len(numeric_cols) >= 2 and ("scatter" in selected or "line" in selected):
x_col, y_col = numeric_cols[0], numeric_cols[1]
xs: list[float] = []
ys: list[float] = []
Expand All @@ -192,7 +202,7 @@ def create_file_charts(
seen += 1
_reservoir_pair(xs, ys, x, y, seen, SAMPLE_CAP)

if xs and ys:
if xs and ys and "scatter" in selected:
fig = plt.figure(figsize=(6, 5))
plt.scatter(xs, ys, alpha=0.6, s=12)
plt.title(f"{stem} - {x_col} vs {y_col} scatter(sample)")
Expand All @@ -204,11 +214,28 @@ def create_file_charts(
plt.close(fig)
artifacts.append(str(out))

if xs and ys and "line" in selected:
pairs = sorted(zip(xs, ys), key=lambda pair: pair[0])
fig = plt.figure(figsize=(6, 4))
plt.plot([p[0] for p in pairs], [p[1] for p in pairs], linewidth=1.3)
plt.title(f"{stem} - {x_col} vs {y_col} line(sample)")
plt.xlabel(x_col)
plt.ylabel(y_col)
plt.tight_layout()
out = out_dir / f"{stem}_{x_col}_{y_col}_line.png"
fig.savefig(out)
plt.close(fig)
artifacts.append(str(out))

return artifacts


def create_multi_charts(csv_paths: list[Path], out_dir: Path) -> dict[str, Any]:
def create_multi_charts(
csv_paths: list[Path],
out_dir: Path,
selected_chart_types: list[str] | None = None,
) -> dict[str, Any]:
results: dict[str, Any] = {}
for p in csv_paths:
results[str(p)] = create_file_charts(p, out_dir)
results[str(p)] = create_file_charts(p, out_dir, selected_chart_types=selected_chart_types)
return results
74 changes: 74 additions & 0 deletions bitnet_tools/viz_recommender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class VizRecommendation:
intent: str
chart_types: list[str]
reason: str


_INTENT_RULES: list[tuple[tuple[str, ...], VizRecommendation]] = [
(
("추이", "트렌드", "변화", "시계열", "trend", "over time"),
VizRecommendation(
intent="trend",
chart_types=["line", "scatter"],
reason="시간/순서 기반 변화 파악에는 선형 추세와 분포 확인이 유리합니다.",
),
),
(
("비교", "랭킹", "상위", "하위", "compare", "ranking"),
VizRecommendation(
intent="comparison",
chart_types=["bar", "boxplot"],
reason="그룹 간 크기 비교에는 막대, 분산 비교에는 박스플롯이 적합합니다.",
),
),
(
("관계", "상관", "영향", "relationship", "correlation"),
VizRecommendation(
intent="relationship",
chart_types=["scatter", "histogram"],
reason="변수 간 관계는 산점도로, 단일 변수 분포는 히스토그램으로 확인합니다.",
),
),
(
("비율", "구성", "점유", "composition", "ratio"),
VizRecommendation(
intent="composition",
chart_types=["bar"],
reason="구성 비교는 범주형 막대 차트로 읽기 쉽고 왜곡이 적습니다.",
),
),
(
("결측", "누락", "품질", "이상치", "missing", "quality", "outlier"),
VizRecommendation(
intent="quality",
chart_types=["missing", "boxplot"],
reason="데이터 품질 확인에는 결측 막대와 이상치 확인용 박스플롯이 효과적입니다.",
),
),
]

_DEFAULT = VizRecommendation(
intent="overview",
chart_types=["histogram", "bar", "scatter"],
reason="일반 탐색 질문으로 판단되어 분포/범주/관계를 함께 확인하는 구성을 추천합니다.",
)


def recommend_chart_types(question: str) -> dict[str, object]:
text = (question or "").strip().lower()
if not text:
rec = _DEFAULT
else:
rec = next((rule for keywords, rule in _INTENT_RULES if any(k in text for k in keywords)), _DEFAULT)

return {
"intent": rec.intent,
"recommended_chart_types": rec.chart_types,
"reason": rec.reason,
}
Loading