diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f28e368..842ca18 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,9 @@ jobs:
python-version: "3.12"
- name: Install package
- run: pip install -e ".[dev]"
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
- name: 테스트 실행
run: pytest -q
@@ -31,7 +33,10 @@ jobs:
python-version: "3.12"
- name: Build wheel
- run: pip install build && python -m build
+ run: |
+ python -m pip install --upgrade pip
+ pip install build
+ python -m build
- name: wheel 내용물 확인
run: python -m zipfile -l dist/*.whl
@@ -52,7 +57,11 @@ jobs:
python-version: "3.12"
- name: Install package + pip-audit
- run: pip install -e . pip-audit
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e . pip-audit
- name: pip-audit로 의존성 취약점 스캔
- run: pip-audit
+ # 제외 사유와 해제 조건은 .pip-audit-ignore 주석 참고.
+ run: |
+ pip-audit $(grep -v '^#' .pip-audit-ignore | grep -v '^$' | sed 's/^/--ignore-vuln /')
diff --git a/.pip-audit-ignore b/.pip-audit-ignore
new file mode 100644
index 0000000..93a06a7
--- /dev/null
+++ b/.pip-audit-ignore
@@ -0,0 +1,37 @@
+# pip-audit 제외 목록 — CI의 pip-audit 잡이 이 파일을 읽어 --ignore-vuln 인자로 넘긴다.
+#
+# pillow 12.3.x 취약점 24건.
+# 수정판 13.0.0이 나와 있으나 docling-core(2.103.1)와 docling-slim(2.141.0)이
+# pillow<13.0.0으로 상한을 걸고 있고, 둘 다 현재 최신 릴리스다. 따라서 의존성
+# 트리 안에서 pillow를 올릴 방법이 없다.
+# evidence-chunker는 pillow를 직접 호출하지 않는다 — 이미지 처리는 전적으로
+# docling 내부(레이아웃 분석/표 구조 인식) 경로에서만 일어난다.
+# 업스트림 상한이 풀리면 이 목록을 지우고 pyproject.toml에 pillow>=13 하한을
+# 추가할 것. 추적 이슈: #N
+#
+# 개별 ID를 나열하는 이유: 패키지 단위로 묶어 제외하면 앞으로 보고되는 새 취약점까지
+# 조용히 덮인다. 새 CVE가 나오면 CI가 실패하도록 두는 게 맞다.
+CVE-2026-112390
+CVE-2026-112391
+CVE-2026-112392
+CVE-2026-112393
+CVE-2026-112395
+CVE-2026-112396
+CVE-2026-112397
+CVE-2026-112399
+CVE-2026-112400
+CVE-2026-112401
+CVE-2026-112402
+CVE-2026-112404
+CVE-2026-112405
+CVE-2026-112406
+CVE-2026-112891
+CVE-2026-112892
+CVE-2026-112893
+CVE-2026-112894
+CVE-2026-112895
+CVE-2026-112896
+CVE-2026-112897
+CVE-2026-112899
+CVE-2026-112900
+CVE-2026-112901
diff --git a/pyproject.toml b/pyproject.toml
index 7b4f4fa..ad37dd4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,7 @@ dependencies = [
similarity = ["sentence-transformers>=2.2"]
langchain = ["langchain-core"]
llamaindex = ["llama-index-core"]
-dev = ["pytest>=8", "sentence-transformers>=2.2", "llama-index-core"]
+dev = ["pytest>=8", "sentence-transformers>=2.2", "llama-index-core", "langchain-core"]
tracking = ["mlflow>=2.14"]
[project.urls]
diff --git a/src/evidence_chunker/caption.py b/src/evidence_chunker/caption.py
index 94a60f7..ec4e179 100644
--- a/src/evidence_chunker/caption.py
+++ b/src/evidence_chunker/caption.py
@@ -204,17 +204,31 @@ def _find_caption_adjacent_page(
)
+# 병합 셀 캡션 탐색은 표 "첫 행"만 본다. 이 규칙은 캡션이 표 안으로 빨려
+# 들어간 경우를 복구하려는 것이고, 캡션은 표 위에 붙으므로 표 HTML 안에서는
+# 첫 행으로만 나타난다. 범위를 풀면 표 중간의 그룹 구분 행, "Table 9:
+# (continued)" 같은 반복 머리글, 다른 표를 가리키는 셀 값까지 캡션으로
+# 채택돼 EU 전체(text/retrieval_text/retrieval_units/table_abstract)에
+# 잘못된 정체성이 복제된다.
+_FIRST_ROW_PATTERN = re.compile(r"
]*>(.*?)
", re.IGNORECASE | re.DOTALL)
+
+
def _find_caption_in_merged_header(table_html: Optional[str]) -> Optional[str]:
"""
- 표 첫 행이 다수 컬럼을 병합한 th/td 하나로만 이루어져 있고, 그 안 텍스트가
- 캡션 패턴(_looks_like_table_caption)에 맞으면 채택.
+ 표 첫 행에 여러 컬럼을 병합한 th/td가 있고, 그 안 텍스트가 캡션
+ 패턴(_looks_like_table_caption)에 맞으면 채택.
+
+ 첫 행에서 못 찾으면 더 아래 행은 보지 않는다 — 윗주석 참고.
Returns:
caption_text — 못 찾으면 None
"""
if not table_html:
return None
- m = _MERGED_HEADER_PATTERN.search(table_html)
+ first_row = _FIRST_ROW_PATTERN.search(table_html)
+ if not first_row:
+ return None
+ m = _MERGED_HEADER_PATTERN.search(first_row.group(1))
if not m:
return None
cell_text = re.sub(r"<[^>]+>", " ", m.group(2)).strip()
diff --git a/tests/specification/test_spec_layout.py b/tests/specification/test_spec_layout.py
index 2bd09f9..265f5cf 100644
--- a/tests/specification/test_spec_layout.py
+++ b/tests/specification/test_spec_layout.py
@@ -58,7 +58,6 @@ def test_qa15_merged_first_row_caption(label,expected):
assert map_table_caption(parsed([t]),t,0).confidence==expected
@pytest.mark.spec("QA-15")
-@pytest.mark.spec_gap
def test_qa15_caption_must_be_in_first_row():
t=table()
t.html='| Name | Value |
|---|
| Table 9: Quoted data |
'
@@ -124,7 +123,6 @@ def test_qa23_same_page_section_and_no_header():
assert find_section_header(parsed(),1,400) is None
@pytest.mark.spec("QA-23")
-@pytest.mark.spec_gap
def test_qa23_previous_page_last_header_is_selected():
ts=[text(0,"Earlier section",label="section_header",page=1,center=100),
text(1,"Continuing section",label="section_header",page=1,center=800)]
diff --git a/tests/specification/test_spec_split.py b/tests/specification/test_spec_split.py
index 6613ed1..b3eb931 100644
--- a/tests/specification/test_spec_split.py
+++ b/tests/specification/test_spec_split.py
@@ -55,7 +55,6 @@ def test_qa34_data_rows_and_sentences_preserved_once_in_order():
assert [s for e in result.chunks for s in e.flattened_rows]==expected_sentences(24)
@pytest.mark.spec("QA-34")
-@pytest.mark.spec_gap
def test_qa34_final_text_respects_512_token_limit():
e=raw_unit()
result=split_eu(e)
diff --git a/tests/test_caption_merged_header.py b/tests/test_caption_merged_header.py
new file mode 100644
index 0000000..1bc3769
--- /dev/null
+++ b/tests/test_caption_merged_header.py
@@ -0,0 +1,60 @@
+# Copyright 2026 docling-evidence-chunker contributors
+# SPDX-License-Identifier: Apache-2.0
+"""표 첫 행 병합 셀 캡션 탐색 회귀 테스트 (QA-15 / GAP-02).
+
+이 fallback은 캡션이 표 안으로 빨려 들어간 경우를 복구하는 규칙이고,
+캡션은 표 위에 붙으므로 표 HTML 안에서는 첫 행으로만 나타난다. 따라서
+"첫 행"이라는 위치가 이 추론의 유일한 근거다. 이전 구현은 HTML 전체를
+훑어서 표 중간의 병합 셀도 캡션으로 채택했다.
+"""
+import pytest
+
+from evidence_chunker.caption import _find_caption_in_merged_header
+
+CAPTION_ROW = '| Table 1: Embedded caption |
'
+HEADER_ROW = "| Name | Value |
"
+MID_CAPTION_ROW = '| Table 9: Quoted data |
'
+DATA_ROW = "| A | 10 |
"
+
+
+def _table(*rows):
+ return ""
+
+
+def test_first_row_merged_caption_is_adopted():
+ html = _table(CAPTION_ROW, DATA_ROW)
+ assert _find_caption_in_merged_header(html) == "Table 1: Embedded caption"
+
+
+def test_merged_cell_below_first_row_is_ignored():
+ """표 중간의 그룹 구분 행이나 다른 표를 인용한 셀을 캡션으로 쓰면 안 된다."""
+ html = _table(HEADER_ROW, MID_CAPTION_ROW, DATA_ROW)
+ assert _find_caption_in_merged_header(html) is None
+
+
+def test_caption_row_inside_thead_is_examined():
+ html = "" + CAPTION_ROW + "" + DATA_ROW + "
"
+ assert _find_caption_in_merged_header(html) == "Table 1: Embedded caption"
+
+
+def test_non_caption_text_in_first_row_is_ignored():
+ html = _table('| Ordinary group heading |
', DATA_ROW)
+ assert _find_caption_in_merged_header(html) is None
+
+
+def test_first_row_without_colspan_is_ignored():
+ html = _table(HEADER_ROW, DATA_ROW)
+ assert _find_caption_in_merged_header(html) is None
+
+
+def test_markup_inside_merged_cell_is_stripped():
+ html = _table('| Table 2: Nested markup |
', DATA_ROW)
+ result = _find_caption_in_merged_header(html)
+ assert result is not None
+ assert result.startswith("Table 2:") and "Nested markup" in result
+ assert "" not in result
+
+
+@pytest.mark.parametrize("html", [None, "", "표가 아닌 마크업
"])
+def test_missing_or_rowless_html_returns_none(html):
+ assert _find_caption_in_merged_header(html) is None