Files
ABAP-Indexing/tests/test_dedupe_parallel.py
T

247 lines
12 KiB
Python

"""중복 unit 조각 복제(수정사항 6번)와 병렬 실행(10번), 요약→색인 반영(1번)·텍스트심볼 색인(3번).
_BAK / _COPY 관행 때문에 같은 코드가 여러 프로그램에 그대로 들어 있다. code_hash 가 같으면
대표 1건만 LLM 에 보내고 나머지는 조각을 평행이동해 복제한다.
"""
from __future__ import annotations
import threading
import pytest
from fastapi.testclient import TestClient
from config.settings import settings
from index.db import connect, loads
import summarize.runner as runner
BODY = (
"FORM select_t001.\n"
" SELECT * FROM t001 INTO TABLE gt_t001.\n"
" SORT gt_t001 BY bukrs.\n"
"ENDFORM.\n"
)
# 같은 FORM 을 서로 다른 줄 위치에 둔 두 프로그램 — 복제 시 줄 평행이동이 필요하다
ORIG = {
"MAIN_PROGRAM": "ZDUP_A",
"DESCRIPTION": "원본",
"TEXT_SYMBOL": [{"SYMBOL": "001", "TEXT": "회사코드 목록 조회"}],
"INCLUDE_PROGRAM": [{"INCLUDE": "ZDUP_A", "SOURCE_CODE": "REPORT zdup_a.\n" + BODY}],
}
COPY = {
"MAIN_PROGRAM": "ZDUP_A_COPY",
"DESCRIPTION": "복사본",
"INCLUDE_PROGRAM": [{
"INCLUDE": "ZDUP_A_COPY",
# 앞에 주석 3줄을 더 넣어 FORM 시작 줄을 밀어낸다
"SOURCE_CODE": "REPORT zdup_a_copy.\n* c1\n* c2\n* c3\n" + BODY,
}],
}
class CountingLLM:
"""호출 수와 호출 스레드를 기록하는 스텁."""
def __init__(self) -> None:
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}
self.unit_calls: list[str] = []
self.threads: set[str] = set()
self._lock = threading.Lock()
def complete_json(self, system: str, user: str) -> dict:
with self._lock:
self.usage["calls"] += 1
self.threads.add(threading.current_thread().name)
if "[작업] program_summary" in user:
return {"program": "x", "business_purpose_ko": "회사코드 마스터를 조회하는 테스트 리포트",
"main_flow": ["회사코드 조회"], "business_tags": ["마스터관리"],
"keywords_ko": ["회사코드", "마스터"], "keywords_en": ["company code"],
"sap_module": "FI", "confidence": 0.8}
with self._lock:
self.unit_calls.append(user)
if "name: SELECT_T001" in user:
return {"unit_purpose_ko": "회사코드 마스터를 읽는다",
"chunks": [{"line_start": 2, "line_end": 3,
"first_line": "SELECT * FROM t001 INTO TABLE gt_t001.",
"kind": "sql_select", "purpose_ko": "회사코드 마스터(T001) 전체 조회",
"keywords_ko": ["회사코드"], "keywords_en": ["company code"],
"sap_objects": ["T001"], "confidence": 0.9}]}
return {"unit_purpose_ko": "요약", "chunks": []}
@pytest.fixture()
def two_programs(tmp_path, monkeypatch):
import query.api as api
monkeypatch.setattr(settings, "database_url", f"sqlite:///{tmp_path / 'index.db'}")
monkeypatch.setattr(settings, "data_normalized", tmp_path / "normalized")
monkeypatch.setattr(settings, "data_parsed", tmp_path / "parsed")
monkeypatch.setattr(settings, "llm_base_url", "")
c = TestClient(api.app)
for payload in (ORIG, COPY):
assert c.post("/ingest", json=payload).json()["status"] == "loaded"
return c
def _chunks(con, program: str):
return con.execute(
"SELECT unit_id, line_start, line_end, purpose_ko, code_hash FROM logic_chunk "
"WHERE program=? ORDER BY seq", (program,)
).fetchall()
def test_duplicate_units_share_one_llm_call(two_programs, monkeypatch):
llm = CountingLLM()
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: llm)
stats = runner.summarize_units(None, None, trigger="test")
# SELECT_T001 은 두 프로그램에 동일 코드로 있다 → unit 추출 호출은 1회뿐
select_prompts = [u for u in llm.unit_calls if "name: SELECT_T001" in u]
assert len(select_prompts) == 1, "같은 code_hash 는 대표 1건만 호출해야 한다"
assert stats["cloned"] == 1
assert stats["llm_calls_saved"] == 1
con = connect()
try:
a, b = _chunks(con, "ZDUP_A"), _chunks(con, "ZDUP_A_COPY")
assert len(a) == 1 and len(b) == 1
# 코드가 같으므로 조각 해시와 설명은 같고, 줄 번호는 주석 3줄만큼 밀려 있어야 한다
assert a[0]["code_hash"] == b[0]["code_hash"]
assert a[0]["purpose_ko"] == b[0]["purpose_ko"]
assert b[0]["line_start"] - a[0]["line_start"] == 3
assert b[0]["line_end"] - a[0]["line_end"] == 3
# 복제 사실을 unit 요약에 남긴다
tgt = con.execute("SELECT summary_json, chunk_count, summary_status FROM unit "
"WHERE program='ZDUP_A_COPY' AND name='SELECT_T001'").fetchone()
assert tgt["summary_status"] == "done" and tgt["chunk_count"] == 1
assert loads(tgt["summary_json"])["cloned_from"].startswith("ZDUP_A#")
# 복제된 조각도 검색 가능해야 한다
n = con.execute("SELECT COUNT(*) c FROM chunk_fts WHERE program='ZDUP_A_COPY'").fetchone()["c"]
assert n == 1
finally:
con.close()
def test_cloned_chunk_lines_point_at_same_code(two_programs, monkeypatch):
"""복제된 줄 범위가 실제로 같은 코드를 가리키는지 원문으로 확인한다."""
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: CountingLLM())
runner.summarize_units(None, None, trigger="test")
con = connect()
try:
out = {}
for prog in ("ZDUP_A", "ZDUP_A_COPY"):
c = _chunks(con, prog)[0]
code = con.execute("SELECT code FROM include WHERE program=? AND include=?",
(prog, prog)).fetchone()["code"].split("\n")
out[prog] = "\n".join(code[c["line_start"] - 1 : c["line_end"]])
assert out["ZDUP_A"] == out["ZDUP_A_COPY"]
assert "SELECT * FROM t001" in out["ZDUP_A"]
finally:
con.close()
def test_no_dedupe_flag_calls_each_unit(two_programs, monkeypatch):
llm = CountingLLM()
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: llm)
stats = runner.summarize_units(None, None, trigger="test", dedupe=False)
assert len([u for u in llm.unit_calls if "name: SELECT_T001" in u]) == 2
assert stats["cloned"] == 0
def test_parallel_and_serial_give_same_result(two_programs, monkeypatch):
llm = CountingLLM()
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: llm)
stats = runner.summarize_units(None, None, trigger="test", concurrency=4)
assert stats["failed"] == 0 and stats["chunks"] >= 1
# 동시성 4로 돌렸으면 워커 스레드에서 호출돼야 한다 (unit 이 2개 이상일 때)
assert llm.threads, "호출 스레드가 기록돼야 한다"
def test_summary_and_text_symbol_reach_program_index(two_programs, monkeypatch):
"""수정사항 1·3 — 요약 문장과 텍스트 심볼 한국어로 프로그램이 검색돼야 한다."""
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: CountingLLM())
runner.summarize_units(None, None, trigger="test")
from query import tools
by_purpose = [r["program"] for r in tools.search_programs("마스터를 조회하는 테스트", top_k=5)]
assert "ZDUP_A" in by_purpose, "요약 본문이 program_fts 에 반영돼야 한다"
by_tag = [r["program"] for r in tools.search_programs("마스터관리", top_k=5)]
assert "ZDUP_A" in by_tag, "business_tags 가 색인돼야 한다"
by_text_symbol = [r["program"] for r in tools.search_programs("회사코드 목록 조회", top_k=5)]
assert "ZDUP_A" in by_text_symbol, "텍스트 심볼 한국어가 색인돼야 한다"
def test_long_summary_does_not_outrank_title_match(tmp_path, monkeypatch):
"""회귀 방지 — 요약을 붙인 프로그램이 타이틀만 있는 프로그램에 밀리면 안 된다.
bm25 는 행 전체 길이로 정규화한다. 짧은 타이틀과 긴 요약을 한 FTS 행에 넣었더니
타이틀이 정확히 일치하는 프로그램이 1위 → 33위로 밀렸다. 그래서 색인을
program_fts(이름·타이틀) / program_desc_fts(서술) 로 분리했다.
"""
import query.api as api
from index.loader import refresh_program_fts
from query import tools
monkeypatch.setattr(settings, "database_url", f"sqlite:///{tmp_path / 'i.db'}")
monkeypatch.setattr(settings, "data_normalized", tmp_path / "n")
monkeypatch.setattr(settings, "data_parsed", tmp_path / "p")
monkeypatch.setattr(settings, "llm_base_url", "")
c = TestClient(api.app)
src = "REPORT z.\nSTART-OF-SELECTION.\n WRITE 1."
# 타이틀이 정확히 일치하는 프로그램 (요약 있음, 매우 김)
c.post("/ingest", json={"MAIN_PROGRAM": "ZTITLE_HIT", "DESCRIPTION": "총계정원장 조회",
"INCLUDE_PROGRAM": [{"INCLUDE": "ZTITLE_HIT", "SOURCE_CODE": src}]})
# 타이틀만 있는 경쟁 프로그램들 (요약 없음, 매우 짧음)
for i in range(4):
c.post("/ingest", json={"MAIN_PROGRAM": f"ZSHORT{i}", "DESCRIPTION": "총계정원장(월별)",
"INCLUDE_PROGRAM": [{"INCLUDE": f"ZSHORT{i}", "SOURCE_CODE": src}]})
con = connect()
try:
long_summary = {
"program": "ZTITLE_HIT", "business_purpose_ko": "회사코드와 회계기간 조건으로 " * 30,
"main_flow": ["단계 " + "설명 " * 20] * 6,
"keywords_ko": [f"키워드{i}" for i in range(40)],
"business_tags": ["총계정원장", "계정잔액"], "sap_module": "FI", "prompt_version": 2,
}
con.execute("UPDATE program SET summary_json=?, summary_status='done' WHERE name='ZTITLE_HIT'",
(__import__("json").dumps(long_summary, ensure_ascii=False),))
refresh_program_fts(con)
con.commit()
finally:
con.close()
found = [r["program"] for r in tools.search_programs("총계정원장 조회하는 프로그램", top_k=5)]
assert "ZTITLE_HIT" in found, f"긴 요약 때문에 타이틀 일치가 밀렸다: {found}"
assert found[0] == "ZTITLE_HIT", f"타이틀 정확 일치가 1위여야 한다: {found}"
def test_unit_fts_purpose_has_no_decoration(tmp_path, monkeypatch):
"""수정사항 1 — 적재 시점 unit_fts.purpose 에 '----' 장식이 들어가면 안 된다."""
import query.api as api
monkeypatch.setattr(settings, "database_url", f"sqlite:///{tmp_path / 'i.db'}")
monkeypatch.setattr(settings, "data_normalized", tmp_path / "n")
monkeypatch.setattr(settings, "data_parsed", tmp_path / "p")
monkeypatch.setattr(settings, "llm_base_url", "")
c = TestClient(api.app)
c.post("/ingest", json={
"MAIN_PROGRAM": "ZDECO",
"INCLUDE_PROGRAM": [{"INCLUDE": "ZDECO", "SOURCE_CODE":
"REPORT zdeco.\n"
"*&---------------------------------------------------------------------*\n"
"*& Form BUILD_LIST\n"
"*&---------------------------------------------------------------------*\n"
"FORM build_list.\n WRITE 1.\nENDFORM."}],
})
con = connect()
try:
rows = [r["purpose"] for r in con.execute("SELECT purpose FROM unit_fts WHERE program='ZDECO'")]
assert not any("----" in (p or "") for p in rows), rows
assert any("BUILD_LIST" in (p or "") for p in rows), rows
finally:
con.close()