diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf06b3c..bc77bc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,17 @@ permissions: contents: read jobs: + demo-scripts: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Test demo decisions without external services + run: python3 -m unittest discover -s scripts/demo -p 'test_*.py' -v + backend: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/README.md b/README.md index 1faa576c..e0d14f73 100644 --- a/README.md +++ b/README.md @@ -294,10 +294,15 @@ python3 scripts/demo/seed_demo_corpus.py - 기본 계정은 `demo@docgrid.local`이며 비밀번호(12자 이상)를 입력받습니다. 계정이 없으면 그 비밀번호로 가입하고, 있으면 로그인합니다. `--email`(`DOCGRID_DEMO_EMAIL`)과 `DOCGRID_DEMO_PASSWORD`로 바꿀 수 있습니다. - 파일 해시로 만든 `Idempotency-Key`를 보내므로 다시 실행해도 같은 계정에 문서가 중복 생성되지 않습니다. -- INDEXED까지 최대 10분(`--timeout`) 기다린 뒤 요약과 예시 검색 1건을 출력합니다. 진행되지 않으면 Worker +- INDEXED까지 최대 10분(`--timeout`) 기다린 뒤 고정 질문 3개의 기대 문서와 원본 15건의 해시를 검사합니다. 진행되지 않으면 Worker 꺼짐, 임베딩 서버 미준비 같은 원인 후보를 안내합니다. 전체 옵션은 `--help`로 확인합니다. -2026-10-16 새 DB에서 실행한 출력입니다(MacBook Pro M5, BGE-M3 준비 완료, Ollama 미실행). `…`는 생략한 부분입니다. +현재 기본 범위는 `search`이며 생성 답변은 미검증으로 표시합니다. 검색 실패·빈 결과·원본 해시 불일치는 +실패 종료합니다. 실제 생성 답변·인용까지 검사하려면 `--scope rag`를 지정합니다. +실행별 원장은 `.demo/reports//`에 저장하며, 자세한 판정은 [예제 검증 안내](examples/demo-corpus/README.md)를 참고하세요. + +아래는 판정 개선 전인 2026-10-16 새 DB에서 실행한 당시 출력입니다(MacBook Pro M5, BGE-M3 준비 완료, +Ollama 미실행). 현재 출력 형식과 다르며, `…`는 생략한 부분입니다. ```text [계정] demo@docgrid.local 회원가입 완료 @@ -322,7 +327,7 @@ INDEXED: 15건 · 인덱싱 실패: 0건 · 시간 안에 끝나지 않음: 0건 [RAG] LLM 답변 생성 실패 → 대체 답변 표시(2.0초 대기): AI 답변 생성이 지연되고 있습니다. 가장 관련도 높은 문서에서 다음 내용을 찾았습니다: "재택근무 운영 규정 … 제3조(재택근무 횟수) 1. 재택근무는 주 2일까지 허용한다. … ``` -Ollama와 `qwen2.5:7b`가 준비되어 있으면 마지막 줄이 `[RAG] 답변 생성 완료(…초 대기): …`로 바뀝니다. +`--scope rag`에서 Ollama와 `qwen2.5:7b`가 준비되어 있으면 실제 답변·인용 검사 결과를 확인할 수 있습니다. 같은 계정이나 새로 가입한 계정으로 웹(`http://localhost:3000`)에 로그인해 다음 질문을 검색해 봅니다. 예제 문서는 PUBLIC으로 올라가므로 모든 로그인 사용자가 검색할 수 있습니다. diff --git a/docs/design/demo-verification.md b/docs/design/demo-verification.md new file mode 100644 index 00000000..8edf56e2 --- /dev/null +++ b/docs/design/demo-verification.md @@ -0,0 +1,43 @@ +# 예제 시연 검증과 실행 원장 + +## 목적과 경계 + +예제 15건이 INDEXED여도 검색 HTTP 오류나 빈 결과를 성공으로 종료하던 판정을 수정한다. +기존 인증·멱등 업로드·인덱싱 대기는 재사용하며, 생성 모델의 품질 평가나 OpenSQL HA 검증은 포함하지 않는다. + +## 판정 + +| 단계 | search 필수 | rag 필수 | 통과 기준 | +| --- | --- | --- | --- | +| 업로드 | 예 | 예 | 서로 다른 문서 ID 15건 | +| 인덱싱 | 예 | 예 | INDEXED 15건 | +| 검색 | 예 | 예 | 고정 질문 3개에서 기대 문서가 topK 3 안에 포함, DOCX 표 본문 확인 | +| 원본 | 예 | 예 | 파일 API 바이트 15건의 길이·SHA-256 일치 | +| 생성 답변 | 아니오 | 예 | SUCCESS·비어 있지 않은 답변·검색 후보 문서/Chunk에 속한 인용 | +| 재실행 | 이전 원장 지정 시 | 이전 원장 지정 시 | 같은 대상·계정·예제 fingerprint와 문서 ID 일치 | + +PASS/FAIL/NOT_RUN을 구분하며 필수 단계가 NOT_RUN이어도 성공 종료하지 않는다. +search 범위는 RAG 검증을 생략하는 것이며 백엔드 RAG Worker를 비활성화하지 않는다. +`--query`를 지정하면 고정 기대 문서 검증 대신 사용자 질문 1건의 비어 있지 않은 결과만 검사한다. +따라서 이 경우 고정 검색 3/3 통과를 주장하지 않는다. + +## 실행 순서 + +```text +예제 파일·checks.json 고정 + → 임베딩 준비 확인 + → 기존 계정 준비·파일 해시 키 업로드 + → INDEXED 대기 + → 기대 검색·본문 + 파일 API 해시 대조 + → 선택 RAG / 선택 이전 문서 ID 비교 + → 필수 단계 전체 PASS일 때만 종료 코드 0 +``` + +## 원장과 민감정보 + +- 실행마다 `.demo/reports//`를 새로 만든다. 지정한 결과 디렉터리가 이미 있으면 거부한다. +- `events.jsonl`은 안전한 상태 이벤트를 즉시 flush하고 `summary.json`은 최종 판정·건수·시간·문서 ID를 보관한다. +- 예제·검증 질문 fingerprint, 소스 revision, topK와 실행 범위를 남긴다. 실제 모델 revision은 이 원장만으로 증명하지 않는다. +- 대상 주소·계정은 원문 대신 비교용 fingerprint를 사용한다. 원장은 기본적으로 로컬 Git 제외 경로에 둔다. +- 비밀번호·토큰·이메일·서버 URL은 출력 전에 제거한다. 검색 원문·모델 답변·서버 임의 오류 본문을 기록하지 않는다. +- 문서 ID 재사용은 해당 API 결과 비교이며, 이 검사만으로 DB 내부 Job 중복 0을 주장하지 않는다. diff --git a/docs/test-results/demo-verification-20261018/README.md b/docs/test-results/demo-verification-20261018/README.md new file mode 100644 index 00000000..ea675c97 --- /dev/null +++ b/docs/test-results/demo-verification-20261018/README.md @@ -0,0 +1,44 @@ +# 예제 시연 판정 회귀 검증 + +## 목적 + +예제 인덱싱이 완료됐어도 검색 HTTP 오류·빈 결과·원본 불일치가 있으면 실패 종료해야 한다. +수정 전 잘못된 성공을 재현한 뒤 선택한 범위의 필수 단계로 종료 코드를 결정하도록 수정했다. + +## 테스트 환경 + +- 로컬 Python 3.9.6, 수정 전 `b6ee78bfebfcc8d23b4ee039e2852faf6679fb98`. +- 수정 구현 `775d64f`, 최종 회귀 소스 `defe0ec`. +- 실제 예제 파일 15건(PDF 5·DOCX 5·HWPX 5)을 읽으며 API는 제어 응답으로 대체한다. +- 실제 DB·BGE-M3·Ollama 호출은 0건이다. 실제 모델 E2E나 원격 OpenSQL HA 검증이 아니다. + +## 테스트 방법 + +```bash +python3 -m unittest discover -s scripts/demo -p 'test_*.py' -v +``` + +1. 수정 전 스크립트에 인덱싱 완료·검색 HTTP 500 또는 빈 결과를 주입한다. +2. 수정 후 같은 오류, 기대 문서 누락·표 본문 누락·파일 불일치·RAG 실패/미완료/잘못된 인용을 검사한다. +3. 정상 search, 제어된 rag 성공, 이전 문서 ID 비교, 기존 원장 보존, 출력 전 마스킹, HTTP 오류 코드와 크기 제한을 확인한다. + +## 관측 결과 + +| 실행 ID | 목적 | 통과 / 실패 | 판정과 근거 | +| --- | --- | --- | --- | +| demo-verdict-baseline-01 | 수정 전 거짓 성공 | 0 / 2 | 실제 반환값이 두 경우 모두 0. [수정 전 결함 재현](baseline-01.md) | +| demo-verdict-regression-01 | 초기 판정 회귀 | 28 / 0 | [초기 로그](demo-verdict-regression-01.log) | +| demo-verdict-regression-02 | HTTP 경계 추가 | 30 / 0 | [독립 로그](demo-verdict-regression-02.log) | +| demo-verdict-regression-03 | ID 형식 보완 후 최종 회귀 | 32 / 0 | [최종 로그](demo-verdict-regression-03.log) | + +정상 search 제어 회차는 인덱싱 15/15·고정 검색 3/3·원본 바이트 대조 15/15, RAG NOT_RUN으로 통과했다. +실제 모델을 통한 수치가 아니라 제어 응답의 판정 로직 결과다. + +## 검증 과정에서 확인된 사항 + +- 원래 최종 판정은 INDEXED 개수만 보았으며, 검색 함수는 HTTP 오류를 출력하고 반환했다. +- HTTP 오류 본문을 모두 버리면 회원가입 판단에 필요한 USER-004 코드도 사라지므로, bounded 응답에서 코드를 해석하되 임의 메시지는 기록하지 않는다. +- 응답 직후 INDEXED라는 이유만으로 이전 업로드라고 추정하지 않고 이전 실행 문서 ID를 직접 비교한다. +- 로그는 생성 답변·검색 원문을 저장하지 않는다. 마스킹 시험은 비밀값·이메일·URL이 기록 전에 제거됐는지 확인한다. +- 이 회차는 Python 판정 회귀만 실행했다. 새 환경 실제 시연·원격 서비스·생성 모델·DB 내부 Job 수는 미검증이다. +- 원본 개인 환경 파일·기존 Docker 자원·공개 서비스에 변경을 가하지 않았다. 시험 임시 파일은 실행별 임시 디렉터리에서 정리했다. diff --git a/docs/test-results/demo-verification-20261018/baseline-01.md b/docs/test-results/demo-verification-20261018/baseline-01.md new file mode 100644 index 00000000..cde53cac --- /dev/null +++ b/docs/test-results/demo-verification-20261018/baseline-01.md @@ -0,0 +1,12 @@ +# 수정 전 검색 거짓 성공 재현 + +- 실행 ID: `demo-verdict-baseline-01` +- 원본 revision: `b6ee78bfebfcc8d23b4ee039e2852faf6679fb98` +- 목적: 검색 HTTP 500·빈 결과가 성공 종료하는지 확인 +- 환경: 로컬 Python 3.9.6, 제어 API 응답, 실제 DB·모델 호출 0건 +- 로그 최종 기록 시각: 2026-10-18T14:13:13+09:00 +- 방법: `baseline_verdict.py <수정 전 seed 파일>` 실행. 인덱싱 완료 1건을 제어하고 각 검색 응답을 주입 +- 합격 기준: 각 검색 오류에서 종료 코드가 0이 아니어야 함 +- 관측: 두 조건 모두 실제 반환값 0. 회귀 요구 검사 2개 중 통과 0·실패 2 +- 해석: 수정 전 결함 재현 성공. 수정 전 코드가 요구 기준을 통과한 것은 아님 +- 원본 로그: [baseline](demo-verdict-baseline-01.log) diff --git a/docs/test-results/demo-verification-20261018/baseline_verdict.py b/docs/test-results/demo-verification-20261018/baseline_verdict.py new file mode 100644 index 00000000..1fc0eb65 --- /dev/null +++ b/docs/test-results/demo-verification-20261018/baseline_verdict.py @@ -0,0 +1,44 @@ +"""현재 seed의 종료 판정을 제어 응답으로 재현하며 실제 API는 호출하지 않는다.""" +import contextlib +import importlib.util +import io +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +path = Path(sys.argv.pop(1)) +spec = importlib.util.spec_from_file_location('seed_baseline', path) +seed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(seed) + + +class VerdictRegression(unittest.TestCase): + """인덱싱 완료 뒤 검색 실패를 전체 실패로 판정해야 한다.""" + + def assert_search_failure(self, status, payload): + client = unittest.mock.Mock() + client.base_url = 'http://localhost:8080' + client.call.return_value = (200, {'data': {'documentStatus': 'INDEXED', + 'currentVersion': {'status': 'INDEXED'}, 'processingVersion': None}}) + client.call_json.return_value = (status, payload) + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.redirect_stdout(io.StringIO())) + for name, value in [('load_manifest', [{'file': 'fixture.pdf', 'title': '합성 문서'}]), + ('DocGridClient', client), ('embedding_ready', True), + ('authenticate', None), ('read_password', 'synthetic-only'), + ('upload', {'documentId': 1}), + ('wait_for_indexing', ({1: 'INDEXED'}, {1}))]: + stack.enter_context(patch.object(seed, name, return_value=value)) + result = seed.run(['--skip-embedding-check', '--rag-wait', '0']) + self.assertNotEqual(result, 0) + + def test_search_http_error_must_fail(self): + self.assert_search_failure(500, {'code': 'SYNTHETIC-ERROR'}) + + def test_empty_results_must_fail(self): + self.assert_search_failure(200, {'data': {'results': [], 'ragStatus': 'FAILED'}}) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/docs/test-results/demo-verification-20261018/demo-verdict-baseline-01.log b/docs/test-results/demo-verification-20261018/demo-verdict-baseline-01.log new file mode 100644 index 00000000..57f1c54f --- /dev/null +++ b/docs/test-results/demo-verification-20261018/demo-verdict-baseline-01.log @@ -0,0 +1,27 @@ +test_empty_results_must_fail (__main__.VerdictRegression) ... FAIL +test_search_http_error_must_fail (__main__.VerdictRegression) ... FAIL + +====================================================================== +FAIL: test_empty_results_must_fail (__main__.VerdictRegression) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "<로컬 시험 도구>/baseline_verdict.py", line 40, in test_empty_results_must_fail + self.assert_search_failure(200, {'data': {'results': [], 'ragStatus': 'FAILED'}}) + File "<로컬 시험 도구>/baseline_verdict.py", line 34, in assert_search_failure + self.assertNotEqual(result, 0) +AssertionError: 0 == 0 + +====================================================================== +FAIL: test_search_http_error_must_fail (__main__.VerdictRegression) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "<로컬 시험 도구>/baseline_verdict.py", line 37, in test_search_http_error_must_fail + self.assert_search_failure(500, {'code': 'SYNTHETIC-ERROR'}) + File "<로컬 시험 도구>/baseline_verdict.py", line 34, in assert_search_failure + self.assertNotEqual(result, 0) +AssertionError: 0 == 0 + +---------------------------------------------------------------------- +Ran 2 tests in 0.002s + +FAILED (failures=2) diff --git a/docs/test-results/demo-verification-20261018/demo-verdict-regression-01.log b/docs/test-results/demo-verification-20261018/demo-verdict-regression-01.log new file mode 100644 index 00000000..82f98020 --- /dev/null +++ b/docs/test-results/demo-verification-20261018/demo-verdict-regression-01.log @@ -0,0 +1,33 @@ +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 28 tests in 0.147s + +OK diff --git a/docs/test-results/demo-verification-20261018/demo-verdict-regression-02.log b/docs/test-results/demo-verification-20261018/demo-verdict-regression-02.log new file mode 100644 index 00000000..d5b86010 --- /dev/null +++ b/docs/test-results/demo-verification-20261018/demo-verdict-regression-02.log @@ -0,0 +1,44 @@ +실행 ID: demo-verdict-regression-02 +목적: 거짓 성공 방지와 안전한 원장·HTTP 경계 회귀 +환경: 로컬 Python 3.9.6, 제어 API 응답, 실제 DB·모델 미사용 +합격 기준: 30개 검사 통과, 실패 0개 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:18:32+09:00 +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_http_error_preserves_code_for_signup_decision (test_demo_verdict.HttpBoundaryTest) ... ok +test_oversized_original_is_rejected (test_demo_verdict.HttpBoundaryTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 30 tests in 0.174s + +OK +종료 시각: 2026-10-18T14:18:32+09:00 +종료 코드: 0 +소요 시간: 0.250초 diff --git a/docs/test-results/demo-verification-20261018/demo-verdict-regression-03.log b/docs/test-results/demo-verification-20261018/demo-verdict-regression-03.log new file mode 100644 index 00000000..7a347a95 --- /dev/null +++ b/docs/test-results/demo-verification-20261018/demo-verdict-regression-03.log @@ -0,0 +1,46 @@ +실행 ID: demo-verdict-regression-03 +목적: 검색 응답 형식 보완 후 최종 판정 회귀 +환경: Python 3.9.6, 제어 API 응답, 실제 모델·DB 미사용, defe0ec +합격 기준: 32개 검사 통과, 실패 0개 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:20:02+09:00 +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_invalid_candidate_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_query_id_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_http_error_preserves_code_for_signup_decision (test_demo_verdict.HttpBoundaryTest) ... ok +test_oversized_original_is_rejected (test_demo_verdict.HttpBoundaryTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 32 tests in 0.217s + +OK +종료 시각: 2026-10-18T14:20:03+09:00 +종료 코드: 0 +소요 시간: 0.311초 diff --git a/examples/demo-corpus/README.md b/examples/demo-corpus/README.md index f8751869..a5db1467 100644 --- a/examples/demo-corpus/README.md +++ b/examples/demo-corpus/README.md @@ -10,6 +10,16 @@ python3 scripts/demo/seed_demo_corpus.py 스크립트는 `manifest.json`의 제목·설명으로 문서를 PUBLIC 업로드하고, 인덱싱 완료와 예시 검색까지 확인합니다. 사용법은 [README 「빠른 실행」 6단계](../../README.md#6-핵심-흐름-확인)를 참고하세요. +기본 범위는 `search`이며 고정 질문 3개의 기대 문서 포함(topK 3), DOCX 표 본문과 원본 15건의 +길이·SHA-256까지 검사합니다. 검색 오류·빈 결과·원본 불일치는 실패 종료합니다. +생성 답변까지 검증하려면 Ollama를 준비하고 `--scope rag`를 지정합니다. 답변 문장 일치가 아니라 +완료 상태·답변 존재·검색 근거에 속한 인용을 검사합니다. search 범위의 RAG는 미검증입니다. + +실행별 결과는 Git 제외 경로 `.demo/reports//`의 `summary.json`·`events.jsonl`에 저장됩니다. +`--report-dir`로 새 결과 경로를 지정할 수 있고 기존 디렉터리는 덮어쓰지 않습니다. +`--previous-report <이전 summary.json>`으로 같은 대상·계정·예제의 문서 ID 재사용을 확인합니다. +`--query`는 사용자 질문 1건의 기본 검색 검사이며 고정 기대 문서 3/3 검사와 구분합니다. + ## 문서 목록 | 파일 | 형식 | 제목 | diff --git a/examples/demo-corpus/checks.json b/examples/demo-corpus/checks.json new file mode 100644 index 00000000..d23f75a9 --- /dev/null +++ b/examples/demo-corpus/checks.json @@ -0,0 +1,18 @@ +{ + "topK": 3, + "queries": [ + { + "query": "재택근무는 일주일에 며칠까지 할 수 있고 언제까지 신청해야 하나요?", + "expectedFile": "01-remote-work-policy.pdf" + }, + { + "query": "본인 결혼 시 경조금과 경조휴가는 얼마나 되나요?", + "expectedFile": "06-family-event-support.docx", + "requiredContent": ["50만원", "5일"] + }, + { + "query": "계약서는 몇 년 동안 보관해야 하나요?", + "expectedFile": "11-records-retention.hwpx" + } + ] +} diff --git a/scripts/demo/seed_demo_corpus.py b/scripts/demo/seed_demo_corpus.py index f6fbc6a3..c624e5ea 100644 --- a/scripts/demo/seed_demo_corpus.py +++ b/scripts/demo/seed_demo_corpus.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""DocGrid 예제 문서를 업로드하고 인덱싱 완료와 예시 검색·RAG 답변까지 한 번에 확인한다. +"""예제 업로드·인덱싱·검색·원본을 검증하고 선택한 범위의 결과로 종료한다. examples/demo-corpus/manifest.json의 문서를 PUBLIC으로 업로드한다. 파일 해시로 만든 Idempotency-Key를 보내므로 같은 계정으로 다시 실행해도 문서가 중복 생성되지 않는다. -계정이 없으면 회원가입 후 로그인한다. 비밀번호와 Access Token은 출력하지 않는다. +계정이 없으면 회원가입 후 로그인한다. search 범위에서는 생성 모델을 검증하지 않는다. +실행별 원장에는 안전한 상태·건수·식별자만 기록하며 응답 본문과 자격증명을 보존하지 않는다. """ from __future__ import annotations @@ -12,13 +13,17 @@ import getpass import hashlib import json +import math import os +import re +import subprocess import sys import time import urllib.error import urllib.request import uuid from pathlib import Path +from datetime import datetime, timedelta, timezone REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_CORPUS_DIR = REPO_ROOT / "examples" / "demo-corpus" @@ -37,16 +42,80 @@ UPLOAD_RETRY_LIMIT = 6 UPLOAD_RETRY_DELAY_SECONDS = 5 STALL_HINT_SECONDS = 90 -SNIPPET_LENGTH = 70 -ANSWER_PREVIEW_LENGTH = 300 +ACTIVE_REPORT = None +SECRET_VALUES = [] +STAGE_LABELS = {"upload": "업로드", "indexing": "인덱싱", "search": "검색", + "download": "원본 해시", "rag": "생성 답변·인용", "replay": "문서 ID 재사용"} class SeedError(Exception): """사용자가 바로 조치할 수 있는 실패 안내를 담는다.""" +def safe_text(message: str) -> str: + """터미널·파일에 기록하기 전에 자격증명·이메일·서버 주소를 제거한다.""" + for value in SECRET_VALUES: + if value: + message = message.replace(value, "<비밀값 생략>") + message = re.sub(r"https?://[^\s)]+", "<설정된 서버>", message) + message = re.sub(r"[\w.+%-]+@[\w.-]+\.[\w.-]+", "<이메일 생략>", message) + message = re.sub(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", "<토큰 생략>", message) + return message + + def log(message: str) -> None: - print(message, flush=True) + safe = safe_text(message) + print(safe, flush=True) + if ACTIVE_REPORT: + ACTIVE_REPORT.event(safe) + + +class DemoReport: + """한 실행의 단계 판정·안전한 이벤트·문서 원장만 독립된 디렉터리에 보존한다.""" + + def __init__(self, args): + self.run_id = datetime.now(timezone(timedelta(hours=9))).strftime("%Y%m%dT%H%M%S%f%z") + self.directory = args.report_dir or REPO_ROOT / ".demo" / "reports" / self.run_id + self.directory.mkdir(parents=True, exist_ok=False) + self.events = (self.directory / "events.jsonl").open("x", encoding="utf-8") + self.started = time.monotonic() + try: + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, + stderr=subprocess.DEVNULL, text=True).strip() + except (OSError, subprocess.CalledProcessError): + revision = "unknown" + self.data = {"schemaVersion": 1, "runId": self.run_id, "revision": revision, + "scope": args.scope, "startedAt": self.timestamp(), "stages": {}, + "documents": {}, "queries": []} + for name in STAGE_LABELS: + self.stage(name, "NOT_RUN", 0, 0) + + @staticmethod + def timestamp(): + return datetime.now(timezone(timedelta(hours=9))).isoformat(timespec="milliseconds") + + def event(self, message): + self.events.write(json.dumps({"timestamp": self.timestamp(), "message": message}, + ensure_ascii=False) + "\n") + self.events.flush() + + def stage(self, name, status, passed, expected): + self.data["stages"][name] = {"status": status, "passed": passed, "expected": expected} + + def finish(self, exit_code): + self.data.update(exitCode=exit_code, finishedAt=self.timestamp(), + elapsedSeconds=round(time.monotonic() - self.started, 3)) + with (self.directory / "summary.json").open("x", encoding="utf-8") as target: + json.dump(self.data, target, ensure_ascii=False, indent=2) + self.events.close() + + def required_passed(self, compare_previous): + required = ["upload", "indexing", "search", "download"] + if self.data["scope"] == "rag": + required.append("rag") + if compare_previous: + required.append("replay") + return all(self.data["stages"][name]["status"] == "PASS" for name in required) def elapsed_text(seconds: float) -> str: @@ -73,6 +142,8 @@ def load_manifest(corpus_dir: Path) -> list[dict]: raise SeedError(f"지원하지 않는 확장자입니다: {document['file']}") if not path.is_file(): raise SeedError(f"예제 문서 파일이 없습니다: {path}") + if not documents or len({d["file"] for d in documents}) != len(documents): + raise SeedError("예제 목록은 비어 있지 않아야 하며 파일명이 중복되면 안 됩니다.") return documents @@ -97,8 +168,9 @@ def __init__(self, base_url: str): self.base_url = base_url.rstrip("/") self.token: str | None = None - def call(self, method: str, path: str, body: bytes | None = None, - headers: dict[str, str] | None = None, timeout: float = 60) -> tuple[int, dict]: + def call_bytes(self, method: str, path: str, body: bytes | None = None, + headers: dict[str, str] | None = None, timeout: float = 60, + limit: int = 2 * 1024 * 1024) -> tuple[int, bytes]: outgoing = dict(headers or {}) if self.token: outgoing["Authorization"] = "Bearer " + self.token @@ -106,15 +178,23 @@ def call(self, method: str, path: str, body: bytes | None = None, headers=outgoing) try: with urllib.request.urlopen(request, timeout=timeout) as response: - status, raw = response.status, response.read() + status, raw = response.status, response.read(limit + 1) except urllib.error.HTTPError as error: - status, raw = error.code, error.read() + with error: + status, raw = error.code, error.read(limit + 1) except (urllib.error.URLError, TimeoutError, ConnectionError) as error: raise SeedError( - f"Backend({self.base_url})에 연결할 수 없습니다: {getattr(error, 'reason', error)}\n" + "Backend에 연결할 수 없습니다.\n" " → 빠른 실행 5단계의 `./backend/gradlew -p backend bootRun`이 떠 있는지, " "--base-url(DOCGRID_BASE_URL)이 맞는지 확인하세요." ) from None + if len(raw) > limit: + raise SeedError("응답 크기가 검증 상한을 초과했습니다.") + return status, raw + + def call(self, method: str, path: str, body: bytes | None = None, + headers: dict[str, str] | None = None, timeout: float = 60) -> tuple[int, dict]: + status, raw = self.call_bytes(method, path, body, headers, timeout) try: payload = json.loads(raw) if raw else {} except ValueError: @@ -127,8 +207,9 @@ def call_json(self, method: str, path: str, payload: dict) -> tuple[int, dict]: def error_text(payload: dict) -> str: - code, message = payload.get("code"), payload.get("message") - return f"{code} {message}" if code else "응답 본문 없음" + # 서버의 임의 오류 본문에 개인정보·인증정보가 들어갈 수 있어 코드만 허용한다. + code = str(payload.get("code") or "") + return code if re.fullmatch(r"[A-Z0-9._-]{1,80}", code) else "응답 상세 생략" def authenticate(client: DocGridClient, email: str, password: str, name: str, @@ -141,18 +222,19 @@ def authenticate(client: DocGridClient, email: str, password: str, name: str, **credentials, "name": name, "departmentId": department_id, }) if status == 409 and payload.get("code") == EMAIL_ALREADY_EXISTS: - raise SeedError(f"{email} 계정은 이미 있지만 비밀번호가 다릅니다. " + raise SeedError("계정은 이미 있지만 비밀번호가 다릅니다. " "DOCGRID_DEMO_PASSWORD 또는 --email을 확인하세요.") if status != 201: raise SeedError(f"회원가입 실패(HTTP {status}): {error_text(payload)} " "(비밀번호는 12~64자여야 합니다)") - log(f"[계정] {email} 회원가입 완료") + log("[계정] 회원가입 완료") status, payload = client.call_json("POST", "/auth/login", credentials) token = (payload.get("data") or {}).get("accessToken") if status != 200 or not token: raise SeedError(f"로그인 실패(HTTP {status}): {error_text(payload)}") client.token = token - log(f"[계정] {email} 로그인 완료") + SECRET_VALUES.append(token) + log("[계정] 로그인 완료") def upload(client: DocGridClient, corpus_dir: Path, document: dict) -> dict: @@ -268,153 +350,241 @@ def wait_for_indexing(client: DocGridClient, uploaded: dict[int, str], timeout: return states, ever_started -def example_search(client: DocGridClient, query: str, rag_wait: float) -> None: - log(f"\n== 예시 검색 ==\n질문: {query}") - status, payload = client.call_json("POST", "/search", {"queryText": query, "topK": 3}) +def example_search(client: DocGridClient, query: str, rag_wait: float, + scope: str = "search", expected_id: int | None = None, + top_k: int = 3) -> dict: + """검색의 실제 결과와 선택 RAG만 판정하며 임의 응답 본문은 출력하지 않는다.""" + verdict = {"search": "FAIL", "rag": "NOT_RUN", "queryId": None} + status, payload = client.call_json("POST", "/search", {"queryText": query, "topK": top_k}) if status != 200: - log(f"[검색] 실패(HTTP {status}): {error_text(payload)}") - return + log(f"[검색] 실패(HTTP {status})") + return verdict data = payload.get("data") or {} results = data.get("results") or [] - if not results: - log("[검색] 결과 없음 — 인덱싱이 끝난 문서가 질문과 관련이 없거나 아직 검색할 수 없습니다.") - for item in results: - snippet = " ".join((item.get("chunkText") or "").split())[:SNIPPET_LENGTH] - log(f" {item.get('rank')}. {item.get('documentTitle')} " - f"(유사도 {float(item.get('similarityScore') or 0) * 100:.1f}%) {snippet}…") + query_id = data.get("queryId") + verdict["queryId"] = query_id if type(query_id) is int and query_id > 0 else None + valid_results = bool(results) and all(isinstance(item, dict) and + type(item.get("documentId")) is int and item["documentId"] > 0 and + type(item.get("chunkId")) is int and item["chunkId"] > 0 for item in results) + if verdict["queryId"] is None or not valid_results or (expected_id is not None and + expected_id not in {r.get("documentId") for r in results[:top_k]}): + log("[검색] 실패: 빈 결과이거나 정해진 범위에 기대 문서가 없습니다.") + return verdict + verdict["search"] = "PASS" + log(f"[검색] 통과: 결과 {len(results)}건, 기대 문서 포함 여부 확인") + if scope == "search": + log("[RAG] 미검증: search 범위에서는 생성 답변 성공을 주장하지 않습니다.") + return verdict started = time.monotonic() while data.get("ragStatus") == "PROCESSING" and time.monotonic() - started < rag_wait: - time.sleep(2) + time.sleep(min(2, max(0, rag_wait - (time.monotonic() - started)))) status, payload = client.call("GET", f"/search/{data.get('queryId')}") if status != 200: - log(f"[RAG] 답변 상태 조회 실패(HTTP {status}): {error_text(payload)}") - return + verdict["rag"] = "FAIL" + return verdict data = payload.get("data") or {} - rag_status = data.get("ragStatus") - answer = " ".join((data.get("answer") or "").split()) - waited = elapsed_text(time.monotonic() - started) - if rag_status == "SUCCESS": - log(f"[RAG] 답변 생성 완료({waited} 대기): {answer[:ANSWER_PREVIEW_LENGTH]}") - elif rag_status == "FAILED": - log(f"[RAG] LLM 답변 생성 실패 → 대체 답변 표시({waited} 대기): {answer[:ANSWER_PREVIEW_LENGTH]}") - log(" → Ollama와 qwen2.5:7b가 준비됐는지 `./scripts/local-services.sh status`로 확인하세요. " - "검색 결과는 Ollama 없이도 제공됩니다.") - else: - log(f"[RAG] {elapsed_text(rag_wait)} 안에 답변이 완성되지 않았습니다. 웹 화면에서 이어서 확인하세요.") + candidates = {(r.get("documentId"), r.get("chunkId")) for r in results} + citations = data.get("citations") or [] + valid_citations = bool(citations) and all( + (c.get("documentId"), c.get("chunkId")) in candidates and + isinstance(c.get("quotedText"), str) and bool(c["quotedText"].strip()) + for c in citations) + verdict["rag"] = "PASS" if (data.get("ragStatus") == "SUCCESS" and + bool(str(data.get("answer") or "").strip()) and + valid_citations) else "FAIL" + log(f"[RAG] {verdict['rag']}: 완료 상태·답변 존재·검색 근거 인용 검사") + return verdict + + +def load_checks(args, documents): + """질문과 topK를 실행 전에 고정하고 기대 파일을 실제 예제 목록과 대조한다.""" + if args.query: + return 3, [{"query": args.query}] + try: + checks = json.loads((args.corpus_dir / "checks.json").read_text(encoding="utf-8")) + top_k, queries = checks["topK"], checks["queries"] + files = {d["file"] for d in documents} + if not isinstance(top_k, int) or not 1 <= top_k <= 20 or not queries: + raise ValueError() + for check in queries: + if not str(check["query"]).strip() or check["expectedFile"] not in files: + raise ValueError() + return top_k, queries + except (OSError, ValueError, KeyError, TypeError): + raise SeedError("검색 기대값 checks.json이 잘못됐습니다. 사용자 예제는 --query로 기본 검색을 확인할 수 있습니다.") from None + + +def verify_downloads(client, files, corpus_dir): + """파일 API로 내려받은 바이트를 로컬 예제의 길이·SHA-256과 비교한다.""" + passed = 0 + for filename, document_id in files.items(): + expected = (corpus_dir / filename).read_bytes() + try: + status, actual = client.call_bytes("GET", f"/api/documents/{document_id}/file", + limit=len(expected)) + matched = status == 200 and len(actual) == len(expected) and ( + hashlib.sha256(actual).digest() == hashlib.sha256(expected).digest()) + except SeedError: + matched = False + passed += int(matched) + log(f"[원본] 문서 {document_id}: {'PASS' if matched else 'FAIL'}") + return passed + + +def verify_searches(client, checks, files, args, top_k, report): + """기대 문서와 선택 본문 조건을 검사하고 질문별 결과를 안전한 원장에 남긴다.""" + search_passed, rag_passed = 0, 0 + for number, check in enumerate(checks, 1): + expected_id = files.get(check.get("expectedFile")) + if check.get("expectedFile") and expected_id is None: + result = {"search": "NOT_RUN", "rag": "NOT_RUN", "queryId": None} + else: + try: + result = example_search(client, check["query"], args.rag_wait, + args.scope, expected_id, top_k) + if result["search"] == "PASS" and check.get("requiredContent"): + status, body = client.call("GET", f"/api/documents/{expected_id}/content") + content = "".join(str((body.get("data") or {}).get("content") or "").split()) + if status != 200 or not all("".join(text.split()) in content + for text in check["requiredContent"]): + result["search"] = "FAIL" + except SeedError: + result = {"search": "FAIL", "rag": "NOT_RUN", "queryId": None} + report.data["queries"].append({"number": number, "expectedDocumentId": expected_id, + **result}) + search_passed += int(result["search"] == "PASS") + rag_passed += int(result["rag"] == "PASS") + log(f"[질문 {number}] 검색 {result['search']} · 생성 답변 {result['rag']}") + report.stage("search", "PASS" if search_passed == len(checks) else "FAIL", + search_passed, len(checks)) + if args.scope == "rag": + report.stage("rag", "PASS" if rag_passed == len(checks) else "FAIL", rag_passed, len(checks)) def parse_args(argv: list[str] | None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--base-url", default=os.environ.get("DOCGRID_BASE_URL", "http://localhost:8080"), - help="Backend 주소 (환경변수 DOCGRID_BASE_URL, 기본 http://localhost:8080)") - parser.add_argument("--email", default=os.environ.get("DOCGRID_DEMO_EMAIL", "demo@docgrid.local"), - help="업로드할 계정 이메일 (환경변수 DOCGRID_DEMO_EMAIL, 기본 demo@docgrid.local)") - parser.add_argument("--name", default="DocGrid 데모", help="계정이 없을 때 회원가입에 쓸 이름") - parser.add_argument("--department-id", type=int, default=1, - help="계정이 없을 때 회원가입에 쓸 부서 ID (기본 1: 개발팀)") - parser.add_argument("--corpus-dir", type=Path, default=DEFAULT_CORPUS_DIR, help="예제 문서 디렉터리") - parser.add_argument("--timeout", type=float, - default=float(os.environ.get("DOCGRID_SEED_TIMEOUT", "600")), - help="INDEXED 대기 상한(초, 환경변수 DOCGRID_SEED_TIMEOUT, 기본 600)") - parser.add_argument("--poll-interval", type=float, default=3, help="인덱싱 상태 확인 주기(초)") - parser.add_argument("--embedding-url", default=os.environ.get("EMBEDDING_SERVER_URL", "http://localhost:8000"), - help="업로드 전 준비 확인과 진단에 쓸 임베딩 서버 주소 (환경변수 EMBEDDING_SERVER_URL)") - parser.add_argument("--skip-embedding-check", action="store_true", - help="업로드 전 임베딩 서버 준비 확인을 건너뜀") - parser.add_argument("--query", default=DEFAULT_QUERY, help="마지막에 실행할 예시 검색 질문") - parser.add_argument("--rag-wait", type=float, default=60, help="예시 검색의 RAG 답변 대기 상한(초)") - return parser.parse_args(argv) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default=os.environ.get("DOCGRID_BASE_URL", "http://localhost:8080")) + parser.add_argument("--email", default=os.environ.get("DOCGRID_DEMO_EMAIL", "demo@docgrid.local")) + parser.add_argument("--name", default="DocGrid 데모") + parser.add_argument("--department-id", type=int, default=1) + parser.add_argument("--corpus-dir", type=Path, default=DEFAULT_CORPUS_DIR) + parser.add_argument("--timeout", type=float, default=os.environ.get("DOCGRID_SEED_TIMEOUT", "600")) + parser.add_argument("--poll-interval", type=float, default=3) + parser.add_argument("--embedding-url", default=os.environ.get("EMBEDDING_SERVER_URL", "http://localhost:8000")) + parser.add_argument("--skip-embedding-check", action="store_true") + parser.add_argument("--query", help="고정 3문항 대신 사용자 질문 1건의 비어 있지 않은 결과 확인") + parser.add_argument("--rag-wait", type=float, default=60) + parser.add_argument("--scope", choices=["search", "rag"], default="search") + parser.add_argument("--report-dir", type=Path, help="새 실행 결과 디렉터리(기존 디렉터리 거부)") + parser.add_argument("--previous-report", type=Path, help="같은 대상·계정·예제의 이전 summary.json") + parser.add_argument("--context-id", default="", help="호출자가 제공하는 실행 대상 식별자") + args = parser.parse_args(argv) + for name in ("timeout", "poll_interval", "rag_wait"): + value = getattr(args, name) + if not math.isfinite(value) or value < 0 or (name != "rag_wait" and value == 0): + parser.error(f"{name} 값의 범위가 잘못됐습니다.") + return args def read_password(email: str) -> str: password = os.environ.get("DOCGRID_DEMO_PASSWORD") - if password: - return password - if not sys.stdin.isatty(): - raise SeedError("비밀번호가 없습니다. DOCGRID_DEMO_PASSWORD 환경변수로 전달하세요.") - return getpass.getpass(f"{email} 비밀번호(12자 이상, 계정이 없으면 이 비밀번호로 가입): ") + if not password: + if not sys.stdin.isatty(): + raise SeedError("비밀번호가 없습니다. DOCGRID_DEMO_PASSWORD 환경변수로 전달하세요.") + password = getpass.getpass("예제 계정 비밀번호(12자 이상): ") + SECRET_VALUES.append(password) + return password -def run(argv: list[str] | None = None) -> int: - args = parse_args(argv) +def execute(args, report): + # 1. 검증 질문과 파일 바이트를 업로드 전에 고정한다. documents = load_manifest(args.corpus_dir) - client = DocGridClient(args.base_url) - started = time.monotonic() - log(f"[시작] Backend {client.base_url} · 예제 문서 {len(documents)}건 · 계정 {args.email}") + top_k, checks = load_checks(args, documents) + hashes = {d["file"]: hashlib.sha256((args.corpus_dir / d["file"]).read_bytes()).hexdigest() + for d in documents} + corpus_hash = hashlib.sha256(json.dumps(hashes, sort_keys=True).encode()).hexdigest() + # 원문 계정·주소 대신 비교에 필요한 fingerprint만 로컬 원장에 보존한다. + target_hash = hashlib.sha256(json.dumps([args.base_url, args.email, args.context_id]).encode()).hexdigest() + report.data.update(corpusSha256=corpus_hash, targetFingerprint=target_hash, topK=top_k, + checksSha256=hashlib.sha256(json.dumps(checks, sort_keys=True).encode()).hexdigest()) + previous = None + if args.previous_report: + previous = json.loads(args.previous_report.read_text(encoding="utf-8")) + if previous.get("corpusSha256") != corpus_hash or previous.get("targetFingerprint") != target_hash: + raise SeedError("이전 원장의 대상·계정·예제가 현재 실행과 일치하지 않습니다.") if not args.skip_embedding_check and not embedding_ready(args.embedding_url): - # 준비 전에 올리면 Job이 자동 재시도 3회를 다 쓰고 실패하므로 업로드 전에 멈춘다. - raise SeedError( - f"임베딩 서버({args.embedding_url})가 아직 준비되지 않아 업로드하지 않았습니다.\n" - f" → `curl -f {args.embedding_url.rstrip('/')}/health/ready`가 성공한 뒤 다시 실행하세요. " - "BGE-M3 첫 실행은 모델 다운로드로 오래 걸릴 수 있습니다(`docker compose logs -f embedding-server`).\n" - " → Backend가 다른 임베딩 주소를 쓰면 --embedding-url로 지정하거나 --skip-embedding-check를 붙이세요." - ) - authenticate(client, args.email, read_password(args.email), args.name, args.department_id) + raise SeedError("BGE-M3가 준비되지 않아 업로드하지 않았습니다. /health/ready를 확인하세요.") - uploaded: dict[int, str] = {} - already: set[int] = set() - upload_failures = 0 - for index, document in enumerate(documents, start=1): - prefix = f"[업로드] {index:2}/{len(documents)} {document['file']}" + # 2. 기존 인증·멱등 업로드·인덱싱 대기를 그대로 사용한다. + client = DocGridClient(args.base_url) + authenticate(client, args.email, read_password(args.email), args.name, args.department_id) + files, uploaded = {}, {} + for number, document in enumerate(documents, 1): try: - doc_id = upload(client, args.corpus_dir, document)["documentId"] - except SeedError as error: - upload_failures += 1 - log(f"{prefix} 실패: {error}") - continue - uploaded[doc_id] = document["title"] - # 방금 만든 문서는 Worker가 처리하기 전이므로, 응답 직후 INDEXED면 이전 실행에서 올린 문서다. - status, payload = client.call("GET", f"/api/documents/{doc_id}/status") - if status == 200 and classify(payload.get("data") or {}) == "INDEXED": - already.add(doc_id) - log(f"{prefix} → 문서 {doc_id} (이전 실행에서 올린 문서, 이미 INDEXED)") - else: - log(f"{prefix} → 문서 {doc_id}") - upload_seconds = time.monotonic() - started - + document_id = upload(client, args.corpus_dir, document)["documentId"] + if type(document_id) is not int or document_id <= 0: + raise SeedError("업로드 응답의 문서 ID가 잘못됐습니다.") + files[document["file"]] = document_id + uploaded[document_id] = document["title"] + report.data["documents"][hashes[document["file"]]] = document_id + log(f"[업로드] {number}/{len(documents)} · 문서 {document_id}") + except SeedError: + log(f"[업로드] {number}/{len(documents)} · FAIL") + count = len(documents) + report.stage("upload", "PASS" if len(uploaded) == count else "FAIL", len(uploaded), count) states, ever_started = wait_for_indexing(client, uploaded, args.timeout, args.poll_interval, args.embedding_url) if uploaded else ({}, set()) - indexed = [doc_id for doc_id, state in states.items() if state == "INDEXED"] - failed = [doc_id for doc_id, state in states.items() if state == "FAILED"] - deleted = [doc_id for doc_id, state in states.items() if state == "DELETED"] - unfinished = [doc_id for doc_id, state in states.items() if state in ("PENDING", "PROCESSING")] - total_seconds = time.monotonic() - started - - log("\n== 요약 ==") - log(f"업로드: {len(uploaded)}/{len(documents)}건 성공 (실패 {upload_failures}건, " - f"이미 INDEXED였던 기존 문서 {len(already)}건)") - log(f"INDEXED: {len(indexed)}건 · 인덱싱 실패: {len(failed) + len(deleted)}건 · " - f"시간 안에 끝나지 않음: {len(unfinished)}건") - log(f"소요 시간: 업로드 {elapsed_text(upload_seconds)} · 전체 {elapsed_text(total_seconds)}") - for doc_id in failed: - log(f" - 실패 문서 {doc_id} {uploaded[doc_id]}") - for doc_id in deleted: - log(f" - 문서 {doc_id} {uploaded[doc_id]}: 이 계정에서 삭제된 문서입니다. " - "같은 파일은 다시 만들지 않으므로 다른 --email로 실행하세요.") - hints = diagnose(states, ever_started, embedding_ready(args.embedding_url), args.embedding_url) - if hints: - log("원인 후보:") - for hint in hints: - log(" - " + hint) - if failed: - log("실패 사유 확인과 재처리는 ADMIN 계정의 웹 대시보드에서 할 수 있습니다. " - "원인을 해결한 뒤 다른 --email로 다시 실행하면 새 문서로 다시 올립니다.") + indexed = sum(s == "INDEXED" for s in states.values()) + report.stage("indexing", "PASS" if indexed == count else "FAIL", indexed, count) + for hint in diagnose(states, ever_started, embedding_ready(args.embedding_url), args.embedding_url): + log(hint) + # 3. 인덱싱과 별개로 검색·원본·선택 RAG의 필수 결과를 판정한다. if indexed: - example_search(client, args.query, args.rag_wait) - log("\n웹 화면(기본 http://localhost:3000)에서 같은 계정이나 다른 계정으로 로그인해 검색할 수 " - "있습니다. 예제 문서는 PUBLIC으로 올라가 모든 로그인 사용자가 검색할 수 있습니다.") - return 0 if len(indexed) == len(documents) else 1 + verify_searches(client, checks, files, args, top_k, report) + downloads = verify_downloads(client, files, args.corpus_dir) + report.stage("download", "PASS" if downloads == count else "FAIL", downloads, count) + if previous: + matches = sum(previous.get("documents", {}).get(digest) == doc_id + for digest, doc_id in report.data["documents"].items()) + report.stage("replay", "PASS" if matches == count else "FAIL", matches, count) + return 0 if report.required_passed(previous is not None) else 1 + + +def run(argv: list[str] | None = None) -> int: + """선택 범위의 필수 단계가 모두 통과한 경우에만 0으로 종료한다.""" + global ACTIVE_REPORT + args = parse_args(argv) + SECRET_VALUES.clear() + try: + report = DemoReport(args) + except OSError: + raise SeedError("새 결과 디렉터리를 만들 수 없습니다. 기존 원장은 덮어쓰지 않습니다.") from None + ACTIVE_REPORT = report + exit_code = 2 + try: + log(f"[실행] {report.run_id} · 범위 {args.scope}") + exit_code = execute(args, report) + except KeyboardInterrupt: + exit_code = 130 + log("[중단] 필수 검증이 완료되지 않았습니다.") + except (SeedError, OSError, ValueError, KeyError, TypeError) as error: + report.data["errorType"] = type(error).__name__ + log(f"[오류] {error if isinstance(error, SeedError) else type(error).__name__}") + finally: + for name, result in report.data["stages"].items(): + log(f"[{STAGE_LABELS[name]}] {result['status']} · {result['passed']}/{result['expected']}") + log(f"[최종] 종료 코드 {exit_code}") + ACTIVE_REPORT = None + report.finish(exit_code) + SECRET_VALUES.clear() + return exit_code if __name__ == "__main__": try: raise SystemExit(run()) except SeedError as error: - log(f"[실패] {error}") + log(f"[오류] {error}") raise SystemExit(2) from None - except KeyboardInterrupt: - log("\n[중단] 다시 실행해도 이미 올린 문서는 중복 생성되지 않습니다.") - raise SystemExit(130) from None diff --git a/scripts/demo/test_demo_verdict.py b/scripts/demo/test_demo_verdict.py new file mode 100644 index 00000000..667a962c --- /dev/null +++ b/scripts/demo/test_demo_verdict.py @@ -0,0 +1,221 @@ +"""실제 모델 없이 API 결과를 제어해 거짓 성공과 원장 오염을 방지한다.""" +import contextlib +import io +import json +import tempfile +import unittest +import urllib.error +from pathlib import Path +from unittest.mock import patch + +import seed_demo_corpus as seed + + +class ControlledClient: + """예제 API 계약의 성공·오류만 제어하며 네트워크나 DB에 접근하지 않는다.""" + + def __init__(self, search_code=200, empty=False, wrong_document=False, + corrupt_file=False, rag_status="SUCCESS", valid_citation=True): + self.base_url = "http://localhost:8080" + self.search_code = search_code + self.empty = empty + self.wrong_document = wrong_document + self.corrupt_file = corrupt_file + self.rag_status = rag_status + self.valid_citation = valid_citation + self.search_calls = 0 + self.status_calls = 0 + self.documents = seed.load_manifest(seed.DEFAULT_CORPUS_DIR) + + def call_json(self, method, path, body): + self.search_calls += 1 + results = [] if self.empty else [ + {"documentId": 999 if self.wrong_document else i, "chunkId": i + 100} + for i in [1, 6, 11]] + return self.search_code, {"data": {"queryId": self.search_calls, "results": results, + "ragStatus": self.rag_status, "answer": "합성 답변", + "citations": [{"documentId": 1, "chunkId": 101 if self.valid_citation else 999, + "quotedText": "합성 근거"}]}} + + def call(self, method, path): + if path.endswith("/content"): + return 200, {"data": {"content": "경조금 50 만원, 경조휴가 5 일"}} + self.status_calls += 1 + return 500, {} + + def call_bytes(self, method, path, limit): + document_id = int(path.split("/")[-2]) + content = (seed.DEFAULT_CORPUS_DIR / self.documents[document_id - 1]["file"]).read_bytes() + return 200, b"wrong" if self.corrupt_file else content + + +class DemoVerdictTest(unittest.TestCase): + """인덱싱 성공이 검색·원본·선택 RAG 실패를 덮어쓰지 못하는지 검사한다.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def run_seed(self, client=None, name="run", extra=None, states=None): + client = client or ControlledClient() + documents = seed.load_manifest(seed.DEFAULT_CORPUS_DIR) + mapping = {d["file"]: i for i, d in enumerate(documents, 1)} + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.redirect_stdout(io.StringIO())) + stack.enter_context(patch.object(seed, "DocGridClient", return_value=client)) + stack.enter_context(patch.object(seed, "embedding_ready", return_value=True)) + stack.enter_context(patch.object(seed, "authenticate")) + stack.enter_context(patch.object(seed, "read_password", return_value="synthetic-not-secret")) + stack.enter_context(patch.object(seed, "upload", side_effect=lambda c, p, d: + {"documentId": mapping[d["file"]]})) + stack.enter_context(patch.object(seed, "wait_for_indexing", return_value=( + states if states is not None else {i: "INDEXED" for i in mapping.values()}, set()))) + result = seed.run(["--report-dir", str(self.root / name), "--rag-wait", "0"] + (extra or [])) + return result, json.loads((self.root / name / "summary.json").read_text()) + + def test_full_search_scope_passes_without_rag_polling(self): + client = ControlledClient(rag_status="FAILED") + code, report = self.run_seed(client) + self.assertEqual(0, code) + self.assertEqual(3, client.search_calls) + self.assertEqual(0, client.status_calls) + self.assertEqual("NOT_RUN", report["stages"]["rag"]["status"]) + self.assertEqual(15, report["stages"]["download"]["passed"]) + + def test_search_http_error_fails_after_all_documents_indexed(self): + client = ControlledClient(search_code=500) + code, report = self.run_seed(client) + self.assertEqual(1, code) + self.assertEqual(3, client.search_calls) + self.assertEqual("PASS", report["stages"]["indexing"]["status"]) + self.assertEqual("FAIL", report["stages"]["search"]["status"]) + + def test_empty_search_results_fail(self): + self.assertEqual(1, self.run_seed(ControlledClient(empty=True))[0]) + + def test_missing_expected_document_fails(self): + self.assertEqual(1, self.run_seed(ControlledClient(wrong_document=True))[0]) + + def test_missing_query_id_is_not_a_valid_search(self): + client = ControlledClient() + original = client.call_json + def response(*args): + status, body = original(*args) + body["data"]["queryId"] = None + return status, body + client.call_json = response + self.assertEqual(1, self.run_seed(client)[0]) + + def test_invalid_candidate_is_not_a_valid_search(self): + client = ControlledClient() + original = client.call_json + def response(*args): + status, body = original(*args) + body["data"]["results"][0]["chunkId"] = None + return status, body + client.call_json = response + self.assertEqual(1, self.run_seed(client)[0]) + + def test_download_hash_mismatch_fails(self): + code, report = self.run_seed(ControlledClient(corrupt_file=True)) + self.assertEqual(1, code) + self.assertEqual(0, report["stages"]["download"]["passed"]) + + def test_table_text_missing_fails(self): + client = ControlledClient() + client.call = lambda *args: (200, {"data": {"content": "표 내용 없음"}}) + self.assertEqual(1, self.run_seed(client)[0]) + + def test_rag_scope_requires_success_and_valid_citations(self): + self.assertEqual(0, self.run_seed(extra=["--scope", "rag"])[0]) + + def test_rag_failed_is_not_success(self): + code, report = self.run_seed(ControlledClient(rag_status="FAILED"), extra=["--scope", "rag"]) + self.assertEqual(1, code) + self.assertEqual("FAIL", report["stages"]["rag"]["status"]) + + def test_rag_incomplete_is_not_success(self): + self.assertEqual(1, self.run_seed(ControlledClient(rag_status="PROCESSING"), + extra=["--scope", "rag"])[0]) + + def test_citation_outside_search_candidates_fails(self): + self.assertEqual(1, self.run_seed(ControlledClient(valid_citation=False), + extra=["--scope", "rag"])[0]) + + def test_unrun_required_stages_cannot_pass(self): + code, report = self.run_seed(states={i: "PENDING" for i in range(1, 16)}) + self.assertEqual(1, code) + self.assertEqual("NOT_RUN", report["stages"]["download"]["status"]) + + def test_previous_document_ids_are_compared(self): + self.run_seed(name="first") + code, report = self.run_seed(name="second", extra=[ + "--previous-report", str(self.root / "first" / "summary.json")]) + self.assertEqual(0, code) + self.assertEqual(15, report["stages"]["replay"]["passed"]) + + def test_different_previous_document_id_fails(self): + self.run_seed(name="first") + previous = self.root / "first" / "summary.json" + data = json.loads(previous.read_text()) + data["documents"][next(iter(data["documents"]))] = 999 + previous.write_text(json.dumps(data)) + self.assertEqual(1, self.run_seed(name="second", extra=["--previous-report", str(previous)])[0]) + + def test_changed_target_refuses_replay_before_upload(self): + self.run_seed(name="first") + code, report = self.run_seed(name="second", extra=["--email", "other@example.invalid", + "--previous-report", str(self.root / "first" / "summary.json")]) + self.assertEqual(2, code) + self.assertEqual("NOT_RUN", report["stages"]["upload"]["status"]) + + def test_existing_report_is_never_overwritten(self): + directory = self.root / "existing" + directory.mkdir() + marker = directory / "summary.json" + marker.write_text("preserve") + with self.assertRaises(seed.SeedError): + seed.run(["--report-dir", str(directory)]) + self.assertEqual("preserve", marker.read_text()) + + def test_event_redaction_happens_before_storage(self): + args = seed.parse_args(["--report-dir", str(self.root / "redaction")]) + report = seed.DemoReport(args) + seed.ACTIVE_REPORT = report + seed.SECRET_VALUES.append("synthetic-private-value") + try: + with contextlib.redirect_stdout(io.StringIO()): + seed.log("synthetic-private-value user@example.invalid https://user:password@server.invalid") + finally: + seed.ACTIVE_REPORT = None + seed.SECRET_VALUES.clear() + report.finish(1) + events = (report.directory / "events.jsonl").read_text() + self.assertNotIn("synthetic-private-value", events) + self.assertNotIn("user@example.invalid", events) + self.assertNotIn("server.invalid", events) + + +class HttpBoundaryTest(unittest.TestCase): + """HTTP 오류 코드 해석과 원본 응답 크기 제한의 실제 클라이언트 경계를 확인한다.""" + + def test_http_error_preserves_code_for_signup_decision(self): + error = urllib.error.HTTPError("http://localhost:8080/auth/login", 401, "controlled", {}, + io.BytesIO(b'{"code":"USER-004"}')) + with patch.object(seed.urllib.request, "urlopen", side_effect=error): + status, body = seed.DocGridClient("http://localhost:8080").call("POST", "/auth/login") + self.assertEqual((401, "USER-004"), (status, body["code"])) + + def test_oversized_original_is_rejected(self): + response = unittest.mock.MagicMock() + response.__enter__.return_value = response + response.status = 200 + response.read.return_value = b"12345" + with patch.object(seed.urllib.request, "urlopen", return_value=response): + with self.assertRaises(seed.SeedError): + seed.DocGridClient("http://localhost:8080").call_bytes("GET", "/file", limit=4) + + +if __name__ == "__main__": + unittest.main()