diff --git a/README.md b/README.md index 9e915dd..c78b9fc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,33 @@ 3. 실행 명령어 4. `pyproject.toml` 또는 의존성 목록 +### 업로드 입력 지원 범위 / 제약 / 준비 권장사항 + +#### 지원 범위 +- CSV: `input_type=csv` 또는 파일 업로드 시 기본 경로로 처리 +- Excel: `.xlsx`(OOXML) 지원, 시트 목록 조회(`/api/sheets`) + 시트 선택 후 CSV 정규화 +- 문서: `.pdf`, `.docx`, `.pptx` 표 추출(`/api/document/extract`) 후 선택 테이블 분석 + +#### 제약 +- Excel은 현재 `.xlsx`만 지원(`.xls` 바이너리 포맷 미지원) +- Excel 시트는 **첫 행 헤더 필수**, 빈 헤더/중복 헤더는 에러 처리 +- 비어 있는 시트(실데이터 없음)는 분석 불가 +- PDF는 암호화/스캔 이미지 기반 문서에서 표 추출이 실패할 수 있음 +- 문서 표 추출 실패 시 `/api/analyze`는 `error` + `error_detail` + `preprocessing_stage=table_extraction` 포맷으로 반환 + +#### 권장 파일 준비 방법 +- CSV/Excel 공통 + - 첫 행을 명확한 컬럼명(중복/공백 없음)으로 구성 + - 숫자 컬럼은 단위/통화를 가능한 일관되게 정리 + - 완전 빈 행/열은 사전 제거 +- Excel + - 분석 대상 시트를 분리(요약 시트/원본 시트 혼합 최소화) + - merged cell/복잡 서식보다 표 형태(행-열) 우선 +- 문서(PDF/Word/PPT) + - 스캔본보다 텍스트 기반 원본 사용 권장 + - 테이블 경계(|, 탭, 명확한 셀 구분)가 보존된 원본이 유리 + - 추출 신뢰도가 낮거나 실패하면 CSV로 변환 후 업로드 경로를 권장 + --- ## 1) 이번 문서에서 바로 할 일 diff --git a/tests/test_ui_contract.py b/tests/test_ui_contract.py new file mode 100644 index 0000000..bd5ce37 --- /dev/null +++ b/tests/test_ui_contract.py @@ -0,0 +1,23 @@ +from pathlib import Path + + +def _app_js_text() -> str: + return (Path(__file__).resolve().parents[1] / 'bitnet_tools' / 'ui' / 'app.js').read_text(encoding='utf-8') + + +def test_api_error_detail_priority_is_consistent_for_post_and_get(): + text = _app_js_text() + expected = "data?.error_detail || data?.error || JSON.stringify(data || {})" + assert text.count(expected) >= 2 + + +def test_ui_failure_status_messages_are_defined_consistently(): + text = _app_js_text() + for phrase in [ + "setStatus('입력 전처리 실패')", + "setStatus('차트 작업 실패')", + "setStatus('분석 실패')", + "setStatus('멀티 분석 실패')", + "setStatus('모델 실행 실패')", + ]: + assert phrase in text diff --git a/tests/test_web.py b/tests/test_web.py index b2f3192..aee4c60 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,4 +1,8 @@ import time +import threading +import urllib.request +import urllib.error +import json from pathlib import Path import base64 @@ -6,6 +10,84 @@ import zipfile import bitnet_tools.web as web +from http.server import ThreadingHTTPServer + + +def _xlsx_sheet_xml(rows): + row_nodes = [] + for r_idx, row in enumerate(rows, start=1): + cell_nodes = [] + for c_idx, val in enumerate(row, start=1): + col = chr(ord('A') + c_idx - 1) + ref = f"{col}{r_idx}" + if val is None: + continue + if isinstance(val, (int, float)): + cell_nodes.append(f'{val}') + else: + escaped = str(val).replace('&', '&').replace('<', '<').replace('>', '>') + cell_nodes.append(f'{escaped}') + row_nodes.append(f'{"".join(cell_nodes)}') + return ( + '' + '' + f'{"".join(row_nodes)}' + '' + ) + + +def _make_xlsx_b64(sheet_map): + workbook_sheets = [] + rels = [] + mem = io.BytesIO() + with zipfile.ZipFile(mem, 'w') as zf: + for idx, (name, rows) in enumerate(sheet_map.items(), start=1): + rid = f'rId{idx}' + workbook_sheets.append(f'') + rels.append( + f'' + ) + zf.writestr(f'xl/worksheets/sheet{idx}.xml', _xlsx_sheet_xml(rows)) + + workbook_xml = ( + '' + '' + f'{"".join(workbook_sheets)}' + '' + ) + rel_xml = ( + '' + '' + f'{"".join(rels)}' + '' + ) + zf.writestr('xl/workbook.xml', workbook_xml) + zf.writestr('xl/_rels/workbook.xml.rels', rel_xml) + return base64.b64encode(mem.getvalue()).decode('ascii') + + +def _run_server(): + server = ThreadingHTTPServer(('127.0.0.1', 0), web.Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def _post_json(url, payload): + req = urllib.request.Request( + url, + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.getcode(), json.loads(resp.read().decode('utf-8')) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode('utf-8')) def test_submit_and_get_chart_job_done(monkeypatch, tmp_path): @@ -106,3 +188,97 @@ def test_coerce_document_payload_to_csv_text(): assert source == 'sample.docx' assert 'h1,h2' in csv_text assert meta['table_id'] == 'docx_table_1' + + +def test_excel_single_sheet_normalization(): + b64 = _make_xlsx_b64({'Sales': [['region', 'amount'], ['seoul', 100], ['busan', 120]]}) + + source, csv_text, meta = web._coerce_csv_text_from_file_payload({ + 'input_type': 'excel', + 'name': 'sales.xlsx', + 'file_base64': b64, + }) + + assert source == 'sales.xlsx' + assert 'region,amount' in csv_text + assert 'seoul,100' in csv_text + assert meta['sheet_name'] == '' + + +def test_excel_multi_sheet_selection_uses_target_sheet(): + b64 = _make_xlsx_b64({ + 'Raw': [['c1', 'c2'], ['a', 1]], + 'Summary': [['city', 'score'], ['busan', 9]], + }) + + csv_text = web._normalize_excel_base64_to_csv_text(b64, sheet_name='Summary') + + assert 'city,score' in csv_text + assert 'busan,9' in csv_text + assert 'c1,c2' not in csv_text + + +def test_excel_empty_sheet_raises_validation_error(): + import pytest + + b64 = _make_xlsx_b64({'Empty': []}) + + with pytest.raises(ValueError, match='selected sheet has no non-empty rows'): + web._normalize_excel_base64_to_csv_text(b64, sheet_name='Empty') + + +def test_excel_header_validation_rejects_empty_and_duplicate_columns(): + import pytest + + empty_header_b64 = _make_xlsx_b64({'BadHeader': [['id', ''], [1, 2]]}) + with pytest.raises(ValueError, match='empty header at index 1'): + web._normalize_excel_base64_to_csv_text(empty_header_b64) + + dup_header_b64 = _make_xlsx_b64({'DupHeader': [['id', 'id'], [1, 2]]}) + with pytest.raises(ValueError, match='duplicated header'): + web._normalize_excel_base64_to_csv_text(dup_header_b64) + + +def test_document_extract_api_success_and_failure_payload_contract(): + server, thread = _run_server() + base = f'http://127.0.0.1:{server.server_port}' + try: + ok_code, ok_body = _post_json(base + '/api/document/extract', { + 'input_type': 'document', + 'source_name': 'ok.docx', + 'file_base64': _make_docx_b64(), + }) + assert ok_code == 200 + assert ok_body['tables'] + + fail_code, fail_body = _post_json(base + '/api/document/extract', { + 'input_type': 'document', + 'source_name': 'scan.pdf', + 'file_base64': base64.b64encode(b'%PDF-1.4\n<< /Subtype /Image >>\n').decode('ascii'), + }) + assert fail_code == 200 + assert fail_body['tables'] == [] + assert fail_body['failure_reason'] == '스캔 이미지' + assert fail_body['failure_detail'] + finally: + server.shutdown() + thread.join(timeout=1) + + +def test_analyze_document_fallback_error_uses_error_and_error_detail(): + server, thread = _run_server() + base = f'http://127.0.0.1:{server.server_port}' + try: + code, body = _post_json(base + '/api/analyze', { + 'input_type': 'document', + 'source_name': 'locked.pdf', + 'file_base64': base64.b64encode(b'%PDF-1.4\n1 0 obj\n<< /Encrypt 2 0 R >>\nendobj\n').decode('ascii'), + 'question': '요약', + }) + assert code == 400 + assert body['error'] == 'document table extraction failed' + assert 'error_detail' in body + assert body['preprocessing_stage'] == 'table_extraction' + finally: + server.shutdown() + thread.join(timeout=1)