Initial commit: ABAP indexing pipeline (ingest, parser, summarize, index, query, wiki)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""테스트 공용 격리.
|
||||
|
||||
`/ingest` 는 적재 후 백그라운드로 요약 → **위키 생성**까지 이어 돌린다. TestClient 는 백그라운드
|
||||
작업을 동기로 실행하므로, `wiki_dir` 을 격리하지 않은 테스트가 실제 `wiki/` 에 파일을 쓸 수 있다
|
||||
(실제로 `wiki/programs/ZBK_T1.md` 가 새어 나온 적이 있다).
|
||||
|
||||
테스트가 직접 monkeypatch 하면 그 값이 이기므로(autouse 가 먼저 적용된다) 안전망으로만 동작한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_writable_dirs(tmp_path_factory, monkeypatch):
|
||||
base = tmp_path_factory.mktemp("isolated")
|
||||
monkeypatch.setattr(settings, "wiki_dir", base / "wiki")
|
||||
monkeypatch.setattr(settings, "data_llm_jobs", base / "llm_jobs")
|
||||
yield
|
||||
@@ -0,0 +1,72 @@
|
||||
"""로직 조각 후처리(summarize/chunks.py) — 줄 앵커 검증·보정, 파서 사실 채움, 버림 규칙."""
|
||||
from __future__ import annotations
|
||||
|
||||
from summarize.chunks import numbered_code, parser_hints, resolve_chunks
|
||||
from summarize.schemas import LogicChunk
|
||||
|
||||
CODE = """REPORT ztest.
|
||||
FORM select_data.
|
||||
CLEAR gt_t001.
|
||||
SELECT bukrs waers FROM t001
|
||||
INTO TABLE gt_t001
|
||||
WHERE bukrs IN s_bukrs.
|
||||
SORT gt_t001 BY bukrs.
|
||||
CALL FUNCTION 'BAPI_CURRENCY_CONV_TO_EXTERNAL'
|
||||
EXPORTING amount = lv_amt.
|
||||
ENDFORM.""".split("\n")
|
||||
UNIT = (2, 10)
|
||||
|
||||
|
||||
def _chunk(**kw) -> LogicChunk:
|
||||
base = {"line_start": 4, "line_end": 7, "first_line": "SELECT bukrs waers FROM t001",
|
||||
"kind": "sql_select", "purpose_ko": "회사코드별 통화 조회", "confidence": 0.8}
|
||||
base.update(kw)
|
||||
return LogicChunk(**base)
|
||||
|
||||
|
||||
def test_exact_anchor_and_parser_facts():
|
||||
chunks, dropped = resolve_chunks([_chunk()], CODE, *UNIT, "ZTEST", known_symbols={"GT_T001"})
|
||||
assert dropped == []
|
||||
c = chunks[0]
|
||||
assert (c.line_start, c.line_end, c.seq) == (4, 7, 1)
|
||||
assert c.tables_read == ["T001"] # LLM 이 아니라 파서가 채운 값
|
||||
assert c.code.startswith(" SELECT bukrs")
|
||||
assert len(c.code_hash) == 64
|
||||
|
||||
|
||||
def test_anchor_shift_corrects_line_numbers():
|
||||
"""LLM 이 줄 번호를 2줄 어긋나게 줘도 first_line 으로 보정된다."""
|
||||
chunks, dropped = resolve_chunks([_chunk(line_start=6, line_end=9)], CODE, *UNIT, "ZTEST", set())
|
||||
assert dropped == []
|
||||
assert (chunks[0].line_start, chunks[0].line_end) == (4, 7)
|
||||
|
||||
|
||||
def test_unfindable_anchor_is_dropped():
|
||||
chunks, dropped = resolve_chunks(
|
||||
[_chunk(first_line="SELECT * FROM nowhere_table_xyz")], CODE, *UNIT, "ZTEST", set())
|
||||
assert chunks == [] and len(dropped) == 1
|
||||
|
||||
|
||||
def test_out_of_unit_range_dropped_and_end_clamped():
|
||||
chunks, dropped = resolve_chunks(
|
||||
[_chunk(line_start=1, line_end=3, first_line="REPORT ztest."), # unit 밖 → 버림
|
||||
_chunk(line_start=8, line_end=40, first_line="CALL FUNCTION 'BAPI_CURRENCY_CONV_TO_EXTERNAL'",
|
||||
kind="fm_call", purpose_ko="환율 변환 BAPI 호출")], # 끝은 unit 끝으로 클램프
|
||||
CODE, *UNIT, "ZTEST", set())
|
||||
assert len(dropped) == 1 and dropped[0].startswith("L1-L3")
|
||||
assert (chunks[0].line_start, chunks[0].line_end) == (8, 10)
|
||||
assert "BAPI_CURRENCY_CONV_TO_EXTERNAL" in chunks[0].calls
|
||||
|
||||
|
||||
def test_unknown_kind_falls_back_and_duplicates_dropped():
|
||||
chunks, dropped = resolve_chunks([_chunk(kind="weird"), _chunk()], CODE, *UNIT, "ZTEST", set())
|
||||
assert len(chunks) == 1 and chunks[0].kind == "other"
|
||||
assert any("중복" in d for d in dropped)
|
||||
|
||||
|
||||
def test_numbered_code_and_hints():
|
||||
txt = numbered_code(CODE, 2, 4)
|
||||
assert txt.splitlines()[0].startswith(" 2| FORM select_data.")
|
||||
hints = parser_hints(CODE, *UNIT)
|
||||
assert any(h.startswith("L4 SELECT") for h in hints)
|
||||
assert any("CALL FUNCTION" in h for h in hints)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""정의부(선언) 수집·조립 — parser/declarations.py, index/decls.py.
|
||||
|
||||
인덱스의 코드 원문은 로직만이라, 붙여넣으려면 선언이 따로 필요하다. 여기서 지키는 것:
|
||||
1. 잘라낸 선언 원문이 **그대로 붙여넣을 수 있는 한 문장**일 것 (체인 항목도)
|
||||
2. `BEGIN OF … END OF` 구조는 한 덩어리일 것
|
||||
3. 의존을 따라가되(`LIKE GT_DATA` → GT_DATA) 사전 타입 참조(`LIKE BSEG-WRBTR`)는 끌어오지 말 것
|
||||
4. 조각 안에 이미 있는 선언은 다시 붙이지 말 것
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
from index import decls as decls_mod
|
||||
from parser.declarations import extract_declaration_blocks, type_name_after, type_refs
|
||||
from parser.statements import split_statements
|
||||
|
||||
TOP = """REPORT zdecl_t1.
|
||||
TYPES: BEGIN OF ty_item,
|
||||
matnr TYPE matnr,
|
||||
menge TYPE menge_d,
|
||||
END OF ty_item,
|
||||
ty_items TYPE STANDARD TABLE OF ty_item.
|
||||
|
||||
TABLES: bseg.
|
||||
|
||||
DATA: gt_items TYPE ty_items,
|
||||
gs_item TYPE ty_item,
|
||||
gv_cnt TYPE i.
|
||||
|
||||
DATA: BEGIN OF gt_log OCCURS 0,
|
||||
msg(80) TYPE c,
|
||||
END OF gt_log.
|
||||
|
||||
CONSTANTS gc_bwart TYPE bwart VALUE '101'.
|
||||
FIELD-SYMBOLS <ls_item> TYPE ty_item.
|
||||
"""
|
||||
|
||||
FORM_CODE = """FORM collect_items.
|
||||
DATA lv_local TYPE i.
|
||||
LOOP AT gt_items INTO gs_item.
|
||||
lv_local = lv_local + 1.
|
||||
ADD gs_item-menge TO gv_cnt.
|
||||
ENDLOOP.
|
||||
IF gv_cnt > 0.
|
||||
gt_log-msg = gc_bwart.
|
||||
APPEND gt_log.
|
||||
ENDIF.
|
||||
ENDFORM.
|
||||
"""
|
||||
|
||||
|
||||
def _blocks(src: str):
|
||||
sts, _ = split_statements(src, "ZDECL_T1TOP")
|
||||
return {d.name: d for d in extract_declaration_blocks(
|
||||
sts, list(range(len(sts))), src.split("\n"), "ZDECL_T1TOP", "global")}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 파서
|
||||
|
||||
|
||||
def test_chain_item_is_pasteable():
|
||||
"""체인 항목은 헤드(`DATA:`)를 다시 붙이고 ',' 를 '.' 로 닫아야 유효한 문장이 된다."""
|
||||
d = _blocks(TOP)["GS_ITEM"]
|
||||
assert d.code.startswith("DATA:")
|
||||
assert d.code.rstrip().endswith(".")
|
||||
assert "gs_item" in d.code and "gv_cnt" not in d.code # 형제 항목이 딸려오지 않는다
|
||||
|
||||
|
||||
def test_begin_of_block_is_one_declaration():
|
||||
d = _blocks(TOP)["TY_ITEM"]
|
||||
assert d.kind == "types"
|
||||
assert "BEGIN OF ty_item" in d.code and d.code.rstrip().endswith("END OF ty_item.")
|
||||
assert d.line_end - d.line_start == 3
|
||||
|
||||
|
||||
def test_declaration_dependencies():
|
||||
b = _blocks(TOP)
|
||||
assert b["TY_ITEMS"].depends == ["TY_ITEM"]
|
||||
assert b["GT_ITEMS"].depends == ["TY_ITEMS"]
|
||||
assert b["TY_ITEM"].depends == ["MATNR", "MENGE_D"] # DDIC — 프로그램 안에는 없다
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr,expected", [
|
||||
("TYPE ANY", None), # 이름 없는 타입식 — 뒤 토큰을 삼키면 안 된다
|
||||
("TYPE STANDARD TABLE OF TY_X", "TY_X"),
|
||||
("TYPE REF TO LCL_Y", "LCL_Y"),
|
||||
("LIKE GT_DATA", "GT_DATA"),
|
||||
("TYPE C", "C"),
|
||||
])
|
||||
def test_type_name_after(expr, expected):
|
||||
assert type_name_after(expr.split(), 0)[0] == expected
|
||||
|
||||
|
||||
def test_type_any_does_not_swallow_next_token():
|
||||
"""`USING p_a TYPE ANY pv_b TYPE x` 의 pv_b 를 타입으로 오인하면 파라미터가 사라진다."""
|
||||
_name, nxt = type_name_after("TYPE ANY PV_CLASS TYPE SETHIER-SETCLASS".split(), 0)
|
||||
assert "TYPE ANY PV_CLASS TYPE SETHIER-SETCLASS".split()[nxt] == "PV_CLASS"
|
||||
|
||||
|
||||
def test_type_refs_keeps_ddic_component():
|
||||
assert type_refs("DATA LV_X LIKE BSEG-WRBTR".split()) == ["BSEG-WRBTR"]
|
||||
|
||||
|
||||
def test_deferred_class_is_not_a_block():
|
||||
"""`CLASS x DEFINITION DEFERRED.` 뒤의 선언이 살아 있어야 한다 (ENDCLASS 가 없다)."""
|
||||
src = ("CLASS lcl_a DEFINITION DEFERRED.\n"
|
||||
"DATA go_ref TYPE REF TO lcl_a.\n")
|
||||
assert "GO_REF" in _blocks(src)
|
||||
|
||||
|
||||
def test_form_signature_params():
|
||||
"""타입이 붙은 파라미터가 여러 개여도 전부 잡아야 한다."""
|
||||
from parser.dataflow import extract_declarations
|
||||
|
||||
sig = "USING P_DATE LIKE P0001-BEGDA P_DAYS LIKE T5A4A-DLYDY CHANGING P_OUT TYPE ANY"
|
||||
names = [d.name for d in extract_declarations([], [], "U1", "unit", unit_type="FORM", signature=sig)]
|
||||
assert names == ["P_DATE", "P_DAYS", "P_OUT"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 조립 (DB)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(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)
|
||||
payload = {
|
||||
"MAIN_PROGRAM": "ZDECL_T1",
|
||||
"DESCRIPTION": "정의부 테스트",
|
||||
"INCLUDE_PROGRAM": [
|
||||
{"INCLUDE": "ZDECL_T1TOP", "SOURCE_CODE": TOP},
|
||||
{"INCLUDE": "ZDECL_T1F01", "SOURCE_CODE": FORM_CODE},
|
||||
],
|
||||
}
|
||||
assert c.post("/ingest", json=payload).json()["status"] == "loaded"
|
||||
return c
|
||||
|
||||
|
||||
def test_program_declarations_endpoint(client):
|
||||
r = client.get("/programs/ZDECL_T1/declarations").json()
|
||||
names = {d["name"] for d in r["declarations"]}
|
||||
assert {"TY_ITEM", "TY_ITEMS", "GT_ITEMS", "GT_LOG", "GC_BWART", "<LS_ITEM>"} <= names
|
||||
assert r["count"] == len(r["declarations"])
|
||||
|
||||
|
||||
def test_unit_code_carries_declarations(client):
|
||||
r = client.get("/programs/ZDECL_T1/units/COLLECT_ITEMS/code").json()
|
||||
picked = [d["name"] for d in r["declarations"]]
|
||||
# 쓰인 것 + 그 의존까지 (GT_ITEMS → TY_ITEMS → TY_ITEM)
|
||||
assert {"GT_ITEMS", "TY_ITEMS", "TY_ITEM", "GS_ITEM", "GV_CNT", "GT_LOG", "GC_BWART"} <= set(picked)
|
||||
# 의존이 먼저 나와야 그대로 붙여넣어 컴파일된다
|
||||
assert picked.index("TY_ITEM") < picked.index("TY_ITEMS") < picked.index("GT_ITEMS")
|
||||
# unit 안에서 선언된 로컬 변수는 이미 코드에 있으므로 다시 붙이지 않는다
|
||||
assert "LV_LOCAL" not in picked
|
||||
assert "DATA: BEGIN OF gt_log" in r["declaration_code"]
|
||||
|
||||
|
||||
def test_ddic_field_reference_is_not_a_work_area(client):
|
||||
"""`LIKE BSEG-WRBTR` 은 사전 타입 참조다 — `TABLES: bseg` 를 딸려오게 하면 안 된다."""
|
||||
from index.db import connect
|
||||
|
||||
con = connect()
|
||||
try:
|
||||
d = decls_mod.resolve(con, "ZDECL_T1", None, " DATA lv_amt LIKE bseg-wrbtr.")
|
||||
assert [x["name"] for x in d["declarations"]] == []
|
||||
assert "BSEG" in d["external_refs"]
|
||||
# 반면 작업영역을 실제로 쓰면 선언이 필요하다
|
||||
d2 = decls_mod.resolve(con, "ZDECL_T1", None, " bseg-wrbtr = 100.")
|
||||
assert [x["name"] for x in d2["declarations"]] == ["BSEG"]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_unresolved_reports_missing_declaration(client):
|
||||
from index.db import connect
|
||||
|
||||
con = connect()
|
||||
try:
|
||||
d = decls_mod.resolve(con, "ZDECL_T1", None, " gv_missing = 1.")
|
||||
assert d["unresolved"] == ["GV_MISSING"]
|
||||
# 호출문의 형식 파라미터·예외 이름은 '선언 없음'이 아니다
|
||||
d2 = decls_mod.resolve(
|
||||
con, "ZDECL_T1", None,
|
||||
" CALL FUNCTION 'Z_X' EXPORTING i_bukrs = gv_cnt EXCEPTIONS no_rate_found = 1.")
|
||||
assert d2["unresolved"] == []
|
||||
finally:
|
||||
con.close()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""중복 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()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""엔티티 위키 페이지 검증 (수정사항 8번) + 외부 호출 필터."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
from index.db import connect
|
||||
from wiki_out.entities import write_entity_wiki
|
||||
from wiki_out.merge import get_scalar, split_frontmatter
|
||||
from wiki_out.okf_writer import write_program_wiki
|
||||
from wiki_out.validate import validate
|
||||
|
||||
PAYLOAD = {
|
||||
"MAIN_PROGRAM": "ZENT_A",
|
||||
"DESCRIPTION": "엔티티 테스트",
|
||||
"INCLUDE_PROGRAM": [{"INCLUDE": "ZENT_A", "SOURCE_CODE": (
|
||||
"REPORT zent_a.\n"
|
||||
"START-OF-SELECTION.\n"
|
||||
" PERFORM load.\n"
|
||||
"FORM load.\n"
|
||||
" SELECT * FROM t001 INTO TABLE gt_t001.\n"
|
||||
" INSERT zfit_log FROM ls_log.\n"
|
||||
" CALL FUNCTION 'CONVERSION_EXIT_ALPHA_INPUT'\n"
|
||||
" EXPORTING input = lv_in\n"
|
||||
" IMPORTING output = lv_out.\n"
|
||||
" CALL FUNCTION 'Z_REMOTE_POST' DESTINATION 'RFCDEST'\n"
|
||||
" EXPORTING i_x = lv_x.\n"
|
||||
" CALL SCREEN 100.\n"
|
||||
" lv_style = cl_gui_alv_grid=>mc_style_enabled.\n"
|
||||
"ENDFORM."
|
||||
)}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def wiki(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, "wiki_dir", tmp_path / "wiki")
|
||||
monkeypatch.setattr(settings, "llm_base_url", "")
|
||||
c = TestClient(api.app)
|
||||
assert c.post("/ingest", json=PAYLOAD).json()["status"] == "loaded"
|
||||
con = connect()
|
||||
write_program_wiki("ZENT_A", con=con, wiki_dir=tmp_path / "wiki")
|
||||
stats = write_entity_wiki(con, tmp_path / "wiki")
|
||||
con.close()
|
||||
return tmp_path / "wiki", stats
|
||||
|
||||
|
||||
def test_table_pages_split_read_and_write(wiki):
|
||||
root, stats = wiki
|
||||
assert stats["tables"] >= 2
|
||||
|
||||
read_page = (root / "tables" / "T001.md").read_text(encoding="utf-8")
|
||||
fm, body = split_frontmatter(read_page)
|
||||
assert get_scalar(fm, "type") == "abap-table"
|
||||
assert get_scalar(fm, "x-read-programs") == "1"
|
||||
assert get_scalar(fm, "x-write-programs") == "0"
|
||||
assert "[ZENT_A](/programs/ZENT_A.md)" in body
|
||||
|
||||
write_page = (root / "tables" / "ZFIT_LOG.md").read_text(encoding="utf-8")
|
||||
fm2, body2 = split_frontmatter(write_page)
|
||||
assert get_scalar(fm2, "x-write-programs") == "1"
|
||||
assert "## 쓰기 (1개 프로그램)" in body2
|
||||
|
||||
|
||||
def test_function_pages_list_callers_and_mark_rfc(wiki):
|
||||
root, stats = wiki
|
||||
assert stats["functions"] >= 2
|
||||
|
||||
fm_page = (root / "functions" / "CONVERSION_EXIT_ALPHA_INPUT.md").read_text(encoding="utf-8")
|
||||
fm, body = split_frontmatter(fm_page)
|
||||
assert get_scalar(fm, "type") == "abap-function"
|
||||
assert get_scalar(fm, "x-caller-programs") == "1"
|
||||
assert "[ZENT_A](/programs/ZENT_A.md)" in body
|
||||
|
||||
rfc_page = (root / "functions" / "Z_REMOTE_POST.md").read_text(encoding="utf-8")
|
||||
fm_rfc, body_rfc = split_frontmatter(rfc_page)
|
||||
assert "RFC" in (get_scalar(fm_rfc, "tags") or "")
|
||||
assert "call_function_rfc" in body_rfc
|
||||
|
||||
|
||||
def test_constants_and_screen_numbers_are_not_function_pages(wiki):
|
||||
"""`CL_GUI_ALV_GRID=>MC_STYLE_ENABLED`(상수)와 `CALL SCREEN 100`(화면번호)은 호출이 아니다."""
|
||||
root, _ = wiki
|
||||
names = {p.stem for p in (root / "functions").glob("*.md")}
|
||||
assert not any("MC_STYLE" in n for n in names), names
|
||||
assert "100" not in names, names
|
||||
|
||||
|
||||
def test_program_x_calls_excludes_constants_and_screens(wiki):
|
||||
root, _ = wiki
|
||||
fm, _ = split_frontmatter((root / "programs" / "ZENT_A.md").read_text(encoding="utf-8"))
|
||||
calls = get_scalar(fm, "x-calls") or ""
|
||||
assert "CONVERSION_EXIT_ALPHA_INPUT" in calls and "Z_REMOTE_POST" in calls
|
||||
assert "MC_STYLE_ENABLED" not in calls, calls
|
||||
assert '"100"' not in calls, calls
|
||||
|
||||
|
||||
def test_manifest_lists_entities_and_hierarchy(wiki):
|
||||
root, _ = wiki
|
||||
text = (root / "index.md").read_text(encoding="utf-8")
|
||||
assert "[tables/](/tables/)" in text and "[functions/](/functions/)" in text
|
||||
assert "## 계층 — 모듈 → 패키지 → 프로그램" in text
|
||||
assert "[ZENT_A](/programs/ZENT_A.md)" in text
|
||||
|
||||
|
||||
def test_entity_pages_pass_okf_validation(wiki):
|
||||
root, _ = wiki
|
||||
assert validate(root) == []
|
||||
|
||||
|
||||
def test_entity_pages_are_idempotent(wiki):
|
||||
"""재실행해도 사람 교정이 없으면 같은 내용(생성 시각 제외)이어야 한다."""
|
||||
root, _ = wiki
|
||||
page = root / "tables" / "T001.md"
|
||||
before = page.read_text(encoding="utf-8")
|
||||
con = connect()
|
||||
write_entity_wiki(con, root)
|
||||
con.close()
|
||||
after = page.read_text(encoding="utf-8")
|
||||
strip = lambda t: "\n".join(l for l in t.splitlines() if not l.startswith("generated:"))
|
||||
assert strip(before) == strip(after)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""질의 확장 검증 (수정사항 4번)."""
|
||||
from config.glossary import parse_glossary, synonym_map
|
||||
from index.db import fts_or, query_tokens
|
||||
from query import expand
|
||||
|
||||
|
||||
def test_parse_glossary_ignores_nested_and_comments():
|
||||
g = parse_glossary(
|
||||
"# 주석\n"
|
||||
"총계정원장: [G/L, GL, ACDOCT]\n"
|
||||
"tags:\n"
|
||||
" - 전표\n"
|
||||
"입고: [GR, 101]\n"
|
||||
)
|
||||
assert g == {"총계정원장": ["G/L", "GL", "ACDOCT"], "입고": ["GR", "101"]}
|
||||
|
||||
|
||||
def test_synonym_map_is_bidirectional(tmp_path):
|
||||
p = tmp_path / "g.yaml"
|
||||
p.write_text("입고: [GR, MSEG, 101]\n", encoding="utf-8")
|
||||
m = synonym_map(p)
|
||||
# 대표어로 물어도, 동의어로 물어도 나머지가 나온다
|
||||
assert set(m["입고"]) == {"GR", "MSEG", "101"}
|
||||
assert "입고" in m["GR"] and "MSEG" in m["GR"]
|
||||
assert "입고" in m["101"]
|
||||
|
||||
|
||||
def test_expansion_terms_excludes_query_itself():
|
||||
terms = expand.expansion_terms("총계정원장")
|
||||
assert terms, "사전에 있는 용어는 동의어가 나와야 한다"
|
||||
assert "총계정원장" not in terms
|
||||
assert "ACDOCT" in [t.upper() for t in terms]
|
||||
|
||||
|
||||
def test_expansion_terms_empty_for_unknown_word():
|
||||
assert expand.expansion_terms("zzz알수없는말") == []
|
||||
|
||||
|
||||
def test_match_exprs_separates_primary_and_expanded():
|
||||
primary, expanded = expand.match_exprs("총계정원장")
|
||||
assert '"총계정원장"' in primary
|
||||
# 동의어는 별 식으로 나와야 한다 — 같은 OR 버킷에 섞으면 정밀도가 떨어진다
|
||||
assert expanded is not None
|
||||
assert "ACDOCT" in expanded.upper()
|
||||
assert "ACDOCT" not in primary.upper()
|
||||
|
||||
|
||||
def test_no_expansion_returns_none():
|
||||
_, expanded = expand.match_exprs("zzz알수없는말")
|
||||
assert expanded is None
|
||||
|
||||
|
||||
def test_expanded_weight_is_a_penalty():
|
||||
assert 0 < expand.EXPANDED_WEIGHT < 1, "동의어 히트는 원질의 히트보다 낮게 점수화돼야 한다"
|
||||
|
||||
|
||||
def test_fts_or_quotes_and_adds_bigrams():
|
||||
expr = fts_or(["총계정원장"])
|
||||
assert '"총계정원장"' in expr
|
||||
assert '"총계' in expr and " OR " in expr
|
||||
|
||||
|
||||
def test_fts_or_strips_quotes_to_keep_match_valid():
|
||||
assert '""' not in fts_or(['A"B']).replace('"A', "").replace('B"', "")
|
||||
assert fts_or([]) == '""'
|
||||
|
||||
|
||||
def test_query_tokens_drops_punctuation():
|
||||
assert query_tokens("총계정원장, 잔액 조회!") == ["총계정원장", "잔액", "조회"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""/ingest 의 요약 트리거 규칙.
|
||||
|
||||
- 신규/변경 적재(loaded) → scheduled
|
||||
- 소스 동일(skip)이라도 요약 미완료 unit 이 있으면 → resumed (재인덱싱 = 요약 재시도)
|
||||
- 소스 동일 + 요약 전부 완료 → skip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
from index.db import connect
|
||||
from summarize.runner import PROMPT_VERSION
|
||||
|
||||
PAYLOAD = {
|
||||
"MAIN_PROGRAM": "ZINGEST_T1",
|
||||
"DESCRIPTION": "인제스트 테스트",
|
||||
"INCLUDE_PROGRAM": [
|
||||
{
|
||||
"INCLUDE": "ZINGEST_T1",
|
||||
"SOURCE_CODE": (
|
||||
"REPORT zingest_t1.\n"
|
||||
"START-OF-SELECTION.\n"
|
||||
" PERFORM main.\n"
|
||||
"FORM main.\n"
|
||||
" SELECT * FROM t001 INTO TABLE gt_t001.\n"
|
||||
"ENDFORM."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(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", "http://fake")
|
||||
monkeypatch.setattr(settings, "llm_api_key", "fake-key")
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_summarize_bg(program: str) -> None: # 실제 LLM/위키는 건드리지 않는다
|
||||
calls.append(program)
|
||||
with api._summarizing_lock:
|
||||
api._summarizing.discard(program)
|
||||
|
||||
monkeypatch.setattr(api, "_summarize_bg", fake_summarize_bg)
|
||||
return TestClient(api.app), calls
|
||||
|
||||
|
||||
def test_summarize_trigger_rules(client):
|
||||
c, calls = client
|
||||
|
||||
# 1) 신규 적재 → scheduled
|
||||
r = c.post("/ingest", json=PAYLOAD).json()
|
||||
assert r["status"] == "loaded"
|
||||
assert r["summarize"] == "scheduled"
|
||||
assert r["pending_units"] > 0
|
||||
assert calls == ["ZINGEST_T1"]
|
||||
|
||||
# 2) 같은 소스 재인덱싱 — 요약은 아직 미완료(가짜 bg) → resumed
|
||||
r = c.post("/ingest", json=PAYLOAD).json()
|
||||
assert r["status"] == "skip"
|
||||
assert r["summarize"] == "resumed"
|
||||
assert calls == ["ZINGEST_T1", "ZINGEST_T1"]
|
||||
|
||||
# 3) 요약 전부 완료 처리 후 재인덱싱 → skip (요약 재실행 없음)
|
||||
con = connect()
|
||||
try:
|
||||
con.execute(
|
||||
"UPDATE unit SET summary_status='done', summary_json='{}', prompt_version=? "
|
||||
"WHERE program='ZINGEST_T1'", (PROMPT_VERSION,))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
r = c.post("/ingest", json=PAYLOAD).json()
|
||||
assert r["status"] == "skip"
|
||||
assert r["summarize"] == "skip"
|
||||
assert r["pending_units"] == 0
|
||||
assert calls == ["ZINGEST_T1", "ZINGEST_T1"] # 더 안 불림
|
||||
|
||||
|
||||
def test_llm_disabled(client, monkeypatch):
|
||||
c, calls = client
|
||||
monkeypatch.setattr(settings, "llm_base_url", "")
|
||||
r = c.post("/ingest", json=PAYLOAD).json()
|
||||
assert r["summarize"] == "disabled"
|
||||
assert calls == []
|
||||
@@ -0,0 +1,203 @@
|
||||
"""LLM 백엔드 검증 — file 큐(키 불필요 입구)와 재시도/백오프."""
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from summarize import jobs
|
||||
from summarize.llm_client import (
|
||||
FileQueueLLM,
|
||||
PendingResponse,
|
||||
_extract_json,
|
||||
_meta_from_prompt,
|
||||
create_llm,
|
||||
job_id,
|
||||
)
|
||||
|
||||
UNIT_PROMPT_SAMPLE = """[작업] extract_chunks
|
||||
[프로그램] ZFIR10070 — 총계정원장 조회
|
||||
[단위] unit_id: ZFIR10070#ZFIR10070_F01#FORM#SELECT_DATA
|
||||
unit_type: FORM, name: SELECT_DATA
|
||||
[창] 이 단위는 길어서 3개 창으로 나눠 보여준다. 지금은 2번째 창 (L10-L20).
|
||||
"""
|
||||
|
||||
|
||||
def test_job_id_is_stable_and_content_addressed():
|
||||
a = job_id("sys", "user")
|
||||
assert a == job_id("sys", "user")
|
||||
assert a != job_id("sys", "user2")
|
||||
|
||||
|
||||
def test_meta_parsed_from_prompt():
|
||||
m = _meta_from_prompt(UNIT_PROMPT_SAMPLE)
|
||||
assert m["task"] == "extract_chunks"
|
||||
assert m["program"] == "ZFIR10070"
|
||||
assert m["unit_id"] == "ZFIR10070#ZFIR10070_F01#FORM#SELECT_DATA"
|
||||
assert m["unit_type"] == "FORM"
|
||||
assert m["window"] == "2"
|
||||
|
||||
|
||||
def test_file_backend_emits_prompt_then_reads_answer(tmp_path):
|
||||
llm = FileQueueLLM(tmp_path)
|
||||
|
||||
# 1) 응답이 없으면 프롬프트를 내놓고 PendingResponse
|
||||
with pytest.raises(PendingResponse) as ei:
|
||||
llm.complete_json("sys", UNIT_PROMPT_SAMPLE)
|
||||
jid = ei.value.job_id
|
||||
assert llm.prompt_path(jid).exists()
|
||||
prompt_text = llm.prompt_path(jid).read_text(encoding="utf-8")
|
||||
assert "## SYSTEM" in prompt_text and "extract_chunks" in prompt_text
|
||||
# 인덱스에 메타가 기록된다
|
||||
rows = [json.loads(l) for l in (tmp_path / "index.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert rows[0]["job_id"] == jid and rows[0]["program"] == "ZFIR10070"
|
||||
|
||||
# 2) 응답을 채우면 그걸 돌려준다
|
||||
llm.response_path(jid).write_text('{"unit_purpose_ko": "x", "chunks": []}', encoding="utf-8")
|
||||
out = llm.complete_json("sys", UNIT_PROMPT_SAMPLE)
|
||||
assert out == {"unit_purpose_ko": "x", "chunks": []}
|
||||
assert llm.usage["calls"] == 1
|
||||
|
||||
|
||||
def test_file_backend_does_not_duplicate_index_rows(tmp_path):
|
||||
llm = FileQueueLLM(tmp_path)
|
||||
for _ in range(3):
|
||||
with pytest.raises(PendingResponse):
|
||||
llm.complete_json("sys", UNIT_PROMPT_SAMPLE)
|
||||
lines = (tmp_path / "index.jsonl").read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
|
||||
def test_create_llm_backends(tmp_path):
|
||||
from summarize.llm_client import FakeLLM
|
||||
|
||||
assert isinstance(create_llm(backend="fake"), FakeLLM)
|
||||
assert isinstance(create_llm(fake=True), FakeLLM) # 하위호환
|
||||
assert isinstance(create_llm(backend="file", jobs_dir=tmp_path), FileQueueLLM)
|
||||
with pytest.raises(ValueError):
|
||||
create_llm(backend="없는백엔드")
|
||||
|
||||
|
||||
def test_extract_json_survives_code_fence():
|
||||
assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1}
|
||||
assert _extract_json('설명입니다\n{"a": 2}\n끝') == {"a": 2}
|
||||
|
||||
|
||||
def test_api_backend_retries_on_429(monkeypatch):
|
||||
from config.settings import settings
|
||||
from summarize import llm_client
|
||||
|
||||
monkeypatch.setattr(settings, "llm_base_url", "http://x")
|
||||
monkeypatch.setattr(settings, "llm_api_key", "k")
|
||||
monkeypatch.setattr(settings, "llm_backoff_base", 0.0)
|
||||
monkeypatch.setattr(settings, "llm_backoff_max", 0.0)
|
||||
monkeypatch.setattr(llm_client.time, "sleep", lambda *_: None)
|
||||
|
||||
client = llm_client.OpenAICompatClient()
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(system, user):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
raise urllib.error.HTTPError("u", 429, "Too Many Requests", {}, None)
|
||||
return {"choices": [{"message": {"content": '{"ok": true}'}}], "usage": {"prompt_tokens": 5}}
|
||||
|
||||
monkeypatch.setattr(client, "_post_once", flaky)
|
||||
assert client.complete_json("s", "u") == {"ok": True}
|
||||
assert calls["n"] == 3
|
||||
assert client.usage["retries"] == 2
|
||||
assert client.usage["calls"] == 1
|
||||
|
||||
|
||||
def test_api_backend_does_not_retry_on_400(monkeypatch):
|
||||
from config.settings import settings
|
||||
from summarize import llm_client
|
||||
|
||||
monkeypatch.setattr(settings, "llm_base_url", "http://x")
|
||||
monkeypatch.setattr(settings, "llm_api_key", "k")
|
||||
client = llm_client.OpenAICompatClient()
|
||||
calls = {"n": 0}
|
||||
|
||||
def bad(system, user):
|
||||
calls["n"] += 1
|
||||
raise urllib.error.HTTPError("u", 400, "Bad Request", {}, None)
|
||||
|
||||
monkeypatch.setattr(client, "_post_once", bad)
|
||||
with pytest.raises(urllib.error.HTTPError):
|
||||
client.complete_json("s", "u")
|
||||
assert calls["n"] == 1, "4xx(429 제외)는 재시도하지 않아야 한다"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ jobs CLI
|
||||
|
||||
def test_jobs_answer_validates_schema(tmp_path, capsys):
|
||||
llm = FileQueueLLM(tmp_path)
|
||||
with pytest.raises(PendingResponse) as ei:
|
||||
llm.complete_json("sys", UNIT_PROMPT_SAMPLE)
|
||||
jid = ei.value.job_id
|
||||
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text('{"unit_purpose_ko": 1, "chunks": [{"line_start": "x"}]}', encoding="utf-8")
|
||||
args = type("A", (), {"dir": str(tmp_path), "job_id": jid, "file": str(bad),
|
||||
"stdin": False, "no_validate": False})()
|
||||
assert jobs.cmd_answer(args) == 1
|
||||
assert not llm.response_path(jid).exists()
|
||||
|
||||
good = tmp_path / "good.json"
|
||||
good.write_text('{"unit_purpose_ko": "정상", "chunks": []}', encoding="utf-8")
|
||||
args.file = str(good)
|
||||
assert jobs.cmd_answer(args) == 0
|
||||
assert json.loads(llm.response_path(jid).read_text(encoding="utf-8"))["unit_purpose_ko"] == "정상"
|
||||
|
||||
|
||||
def test_jobs_answer_reports_missing_file(tmp_path):
|
||||
args = type("A", (), {"dir": str(tmp_path), "job_id": "deadbeef", "file": "nope.json",
|
||||
"stdin": False, "no_validate": False})()
|
||||
assert jobs.cmd_answer(args) == 1 # 프롬프트도 없으므로 1
|
||||
|
||||
|
||||
# ------------------------------------------------- /ingest 의 백엔드 선택
|
||||
|
||||
INGEST_PAYLOAD = {
|
||||
"MAIN_PROGRAM": "ZBK_T1",
|
||||
"INCLUDE_PROGRAM": [{"INCLUDE": "ZBK_T1", "SOURCE_CODE":
|
||||
"REPORT zbk_t1.\nSTART-OF-SELECTION.\n PERFORM go.\n"
|
||||
"FORM go.\n SELECT * FROM t001 INTO TABLE gt.\nENDFORM."}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client(tmp_path, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import query.api as api
|
||||
from config.settings import settings
|
||||
|
||||
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, "data_llm_jobs", tmp_path / "jobs")
|
||||
# /ingest 의 백그라운드 작업이 요약 후 위키를 쓴다 — 실제 wiki/ 로 새지 않게 격리
|
||||
monkeypatch.setattr(settings, "wiki_dir", tmp_path / "wiki")
|
||||
return TestClient(api.app), settings, tmp_path
|
||||
|
||||
|
||||
def test_ingest_backend_off_skips_summary(api_client, monkeypatch):
|
||||
c, settings, _ = api_client
|
||||
monkeypatch.setattr(settings, "summarize_backend", "off")
|
||||
monkeypatch.setattr(settings, "llm_base_url", "http://x")
|
||||
monkeypatch.setattr(settings, "llm_api_key", "k")
|
||||
r = c.post("/ingest", json=INGEST_PAYLOAD).json()
|
||||
assert r["summarize"] == "disabled" and r["summarize_backend"] == "off"
|
||||
|
||||
|
||||
def test_ingest_backend_file_queues_without_key(api_client, monkeypatch):
|
||||
"""키가 없어도 file 백엔드면 프롬프트가 큐에 쌓인다."""
|
||||
c, settings, tmp_path = api_client
|
||||
monkeypatch.setattr(settings, "summarize_backend", "file")
|
||||
monkeypatch.setattr(settings, "llm_base_url", "") # 키 없음
|
||||
monkeypatch.setattr(settings, "llm_api_key", "")
|
||||
r = c.post("/ingest", json=INGEST_PAYLOAD).json()
|
||||
assert r["summarize"] == "scheduled", r
|
||||
q = c.get("/summaries/jobs").json()
|
||||
assert q["pending"] >= 1, q
|
||||
assert any(n["task"] == "program_summary" for n in q["next"]), q
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Stage 1 검증 (계획서 §3) — 샘플 원본이 data/raw 에 있을 때만 실행."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ingest.normalize import clean_text_field, normalize_raw_text, parse_collected_file
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RAW = ROOT / "data" / "raw"
|
||||
|
||||
|
||||
def test_normalize_regex_removes_wrap_artifact():
|
||||
raw = '{"A":"REPORT\n zfir10070 MESSAGE-ID zfim01."}'
|
||||
assert json.loads(normalize_raw_text(raw))["A"] == "REPORT zfir10070 MESSAGE-ID zfim01."
|
||||
|
||||
|
||||
def test_escaped_newline_preserved():
|
||||
# 이스케이프된 \n(백슬래시+n)은 json.loads 후 진짜 줄바꿈이 되어야 한다
|
||||
raw = '{"A":"LINE1\\nLINE2"}'
|
||||
assert json.loads(normalize_raw_text(raw))["A"] == "LINE1\nLINE2"
|
||||
|
||||
|
||||
def test_clean_text_field():
|
||||
assert clean_text_field("총계정원장 조회") == "총계정원장 조회"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (RAW / "ZFIR10070.txt").exists(), reason="샘플 원본 없음")
|
||||
def test_sample_program_source():
|
||||
data = parse_collected_file((RAW / "ZFIR10070.txt").read_text(encoding="utf-8"))
|
||||
assert clean_text_field(data["DESCRIPTION"]) == "총계정원장 조회"
|
||||
f01 = next(i for i in data["INCLUDE_PROGRAM"] if "F01" in i["INCLUDE"])
|
||||
form_lines = [
|
||||
ln for ln in f01["SOURCE_CODE"].split("\n") if ln.strip().upper().startswith("FORM ")
|
||||
]
|
||||
assert len(form_lines) == 73
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (RAW / "ZFI01.txt").exists(), reason="샘플 원본 없음")
|
||||
def test_sample_package_list():
|
||||
data = parse_collected_file((RAW / "ZFI01.txt").read_text(encoding="utf-8"))
|
||||
assert isinstance(data, list) and len(data) > 900
|
||||
assert data[0]["DEVCLASS"] == "ZFI01"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Stage 2 검증 (계획서 §4.6) — 정규화 산출물이 있을 때 ZFIR10070 실측 + 단위 테스트."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from parser.run import parse_program
|
||||
from parser.statements import split_statements
|
||||
from parser.tokenizer import split_comment, tokenize_code
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROG_DIR = ROOT / "data" / "normalized" / "ZFIR10070"
|
||||
|
||||
|
||||
def test_split_comment():
|
||||
assert split_comment("* full comment") == ("", "full comment", True)
|
||||
code, comment, full = split_comment("DATA lv_x TYPE i. \" inline")
|
||||
assert comment == "inline" and not full and "DATA" in code
|
||||
|
||||
|
||||
def test_string_literal_quote_not_comment():
|
||||
code, comment, _ = split_comment("WRITE 'has \" inside'.")
|
||||
assert comment == "" and "'has \" inside'" in code
|
||||
|
||||
|
||||
def test_chain_expansion():
|
||||
sts, _ = split_statements("DATA: a TYPE i,\n b TYPE i.", "T")
|
||||
assert len(sts) == 2
|
||||
assert sts[0].upper[:2] == ["DATA", "A"]
|
||||
assert sts[1].upper[:2] == ["DATA", "B"]
|
||||
|
||||
|
||||
def test_tokenizer_identifiers():
|
||||
toks = tokenize_code("zcl_fi_common=>f4_bukrs( ) gs_head-belnr TEXT-004 <fs>")
|
||||
assert "zcl_fi_common=>f4_bukrs" in toks
|
||||
assert "gs_head-belnr" in toks
|
||||
assert "TEXT-004" in toks
|
||||
assert "<fs>" in toks
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed():
|
||||
if not PROG_DIR.exists():
|
||||
pytest.skip("정규화 산출물 없음 — python -m ingest.normalize 먼저 실행")
|
||||
return parse_program(PROG_DIR)
|
||||
|
||||
|
||||
def test_f01_has_73_forms(parsed):
|
||||
forms = [u for u in parsed["units"] if u["unit_type"] == "FORM" and u["include"] == "ZFIR10070_F01"]
|
||||
assert len(forms) == 73
|
||||
|
||||
|
||||
def test_gt_acdoct_declared_in_top(parsed):
|
||||
decls = [s for s in parsed["symbols"] if s["name"] == "GT_ACDOCT"]
|
||||
assert decls and decls[0]["decl_include"] == "ZFIR10070_TOP"
|
||||
assert decls[0]["scope"] == "global"
|
||||
|
||||
|
||||
def test_select_data_calls_select_gl_list(parsed):
|
||||
sd = next(u for u in parsed["units"] if u["name"] == "SELECT_DATA")
|
||||
targets = [c["target"] for c in sd["refs"]["calls"]]
|
||||
assert "SELECT_GL_LIST" in targets
|
||||
|
||||
|
||||
def test_external_perform_zfir00010(parsed):
|
||||
ext = [e for e in parsed["call_edges"]
|
||||
if e["external_name"] and "ZFIR00010" in str(e["external_name"])]
|
||||
assert any("CHECK_BUKRS" in e["external_name"] for e in ext)
|
||||
|
||||
|
||||
def test_text_symbol_004(parsed):
|
||||
t = next(x for x in parsed["text_symbols"] if x["symbol"] == "004")
|
||||
assert t["text"] == "회계단위 간편선택"
|
||||
|
||||
|
||||
def test_unknown_ratio_below_3pct(parsed):
|
||||
assert parsed["stats"]["unknown_ratio"] < 0.03
|
||||
@@ -0,0 +1,96 @@
|
||||
"""필드심볼·테이블본문 대입 파싱 검증.
|
||||
|
||||
이 문장 형태들이 토큰 3개로 쪼개지면 (a) 쓰기 지점이 누락되고 (b) 미인식 문장으로 잡힌다.
|
||||
ALV 필드카탈로그를 채우는 관용구라 프로그램에 따라 미인식률이 12% 까지 올라갔다.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from parser.dataflow import base_symbol, extract_writes
|
||||
from parser.run import parse_program
|
||||
from parser.statements import assignment_eq_index, split_statements
|
||||
from parser.tokenizer import tokenize_code
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NORM = ROOT / "data" / "normalized"
|
||||
|
||||
|
||||
def test_field_symbol_component_is_one_token():
|
||||
assert tokenize_code("<ls_fcat>-fieldname = 'X'.")[:2] == ["<ls_fcat>-fieldname", "="]
|
||||
assert tokenize_code("<go_grid>->check_changed_data( ).")[0] == "<go_grid>->check_changed_data"
|
||||
assert tokenize_code("<fs>-a-b = 1.")[0] == "<fs>-a-b"
|
||||
|
||||
|
||||
def test_plain_field_symbol_unaffected():
|
||||
assert tokenize_code("READ TABLE t ASSIGNING <fs>.")[-2] == "<fs>"
|
||||
assert tokenize_code("<fs> = ls_y.")[:2] == ["<fs>", "="]
|
||||
|
||||
|
||||
def test_macro_param_tokenized():
|
||||
assert tokenize_code("&1 = |{ &2 }|.")[0] == "&1"
|
||||
|
||||
|
||||
def test_base_symbol_normalizes_field_symbol():
|
||||
assert base_symbol("<LS_FCAT>-FIELDNAME") == "<LS_FCAT>"
|
||||
assert base_symbol("<GO_GRID>->METH") == "<GO_GRID>"
|
||||
assert base_symbol("GS_HEAD-BELNR") == "GS_HEAD"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expect_eq",
|
||||
[
|
||||
("lv_a = 1.", 1),
|
||||
("<ls_fcat>-fieldname = 'X'.", 1),
|
||||
("gt_tab[] = gt_src[].", 3),
|
||||
("ls_r-opt[] = VALUE #( ).", 3),
|
||||
("IF lv_a = 1.", None), # 조건문은 대입이 아니다
|
||||
("CLEAR lv_a.", None),
|
||||
("PERFORM f USING a.", None),
|
||||
],
|
||||
)
|
||||
def test_assignment_eq_index(code, expect_eq):
|
||||
sts, _ = split_statements(code, "T")
|
||||
assert assignment_eq_index(sts[0].upper) == expect_eq
|
||||
|
||||
|
||||
def _writes_for(code: str):
|
||||
sts, _ = split_statements(code, "T")
|
||||
known = {"GT_TAB", "GT_SRC", "<LS_FCAT>", "LS_R"}
|
||||
writes, _ = extract_writes(sts, list(range(len(sts))), "U", known)
|
||||
return {(w.symbol, w.kind) for w in writes}
|
||||
|
||||
|
||||
def test_field_symbol_component_write_recorded():
|
||||
got = _writes_for("<ls_fcat>-fieldname = 'X'.")
|
||||
assert ("<LS_FCAT>", "assign:field=FIELDNAME") in got
|
||||
|
||||
|
||||
def test_object_attribute_write_has_clean_field_name():
|
||||
got = _writes_for("<go_dd>->html_control = x.")
|
||||
# '->' 가 field 이름에 '>' 로 새어 들어가지 않아야 한다
|
||||
assert ("<GO_DD>", "assign:field=HTML_CONTROL") in got
|
||||
|
||||
|
||||
def test_table_body_assignment_write_recorded():
|
||||
got = _writes_for("gt_tab[] = gt_src[].")
|
||||
assert ("GT_TAB", "assign") in got
|
||||
|
||||
|
||||
def _normalized_dirs() -> list[Path]:
|
||||
return sorted(p for p in NORM.iterdir() if p.is_dir()) if NORM.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _normalized_dirs(), reason="정규화 산출물 없음")
|
||||
def test_unknown_ratio_low_across_all_programs():
|
||||
"""§4.6 — 한 프로그램만 보면 놓친다. 전체를 재고 최악값도 함께 본다.
|
||||
|
||||
특정 샘플 이름에 묶지 않는다 — 어떤 덤프가 들어와도 전체를 재야 의미가 있다.
|
||||
"""
|
||||
worst = []
|
||||
for d in _normalized_dirs():
|
||||
stats = parse_program(d)["stats"]
|
||||
worst.append((stats["unknown_ratio"], d.name))
|
||||
worst.sort(reverse=True)
|
||||
top_ratio, top_name = worst[0]
|
||||
assert top_ratio < 0.03, f"미인식률 최악: {top_name} {top_ratio:.2%} (전체: {worst[:5]})"
|
||||
@@ -0,0 +1,96 @@
|
||||
"""/search/logic, /chunks/{id}, /programs/{name}/chunks — 로직 조각 검색·조회 API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
import summarize.runner as runner
|
||||
|
||||
PAYLOAD = {
|
||||
"MAIN_PROGRAM": "ZLOGIC_T1",
|
||||
"DESCRIPTION": "로직 검색 테스트",
|
||||
"INCLUDE_PROGRAM": [{
|
||||
"INCLUDE": "ZLOGIC_T1",
|
||||
"SOURCE_CODE": (
|
||||
"REPORT zlogic_t1.\n"
|
||||
"START-OF-SELECTION.\n"
|
||||
" PERFORM get_gr.\n"
|
||||
"FORM get_gr.\n"
|
||||
" SELECT mblnr FROM mseg INTO TABLE gt_mseg WHERE bwart = '101'.\n"
|
||||
" SORT gt_mseg BY mblnr.\n"
|
||||
"ENDFORM."
|
||||
),
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class StubLLM:
|
||||
usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}
|
||||
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
if "[작업] program_summary" in user:
|
||||
return {"program": "x", "business_purpose_ko": "구매오더 입고 자재문서 조회", "confidence": 0.8}
|
||||
if "name: GET_GR" in user:
|
||||
return {"unit_purpose_ko": "입고 자재문서 조회", "chunks": [{
|
||||
"line_start": 5, "line_end": 6, "first_line": "SELECT mblnr FROM mseg INTO TABLE gt_mseg WHERE bwart = '101'.",
|
||||
"kind": "sql_select", "purpose_ko": "이동유형 101(입고) 자재문서를 MSEG 에서 조회",
|
||||
"purpose_en": "Read goods receipt material documents (movement type 101) from MSEG",
|
||||
"keywords_ko": ["입고", "자재문서", "이동유형 101"], "keywords_en": ["goods receipt", "GR"],
|
||||
"sap_objects": ["MSEG", "BWART"], "confidence": 0.9}]}
|
||||
return {"unit_purpose_ko": "요약", "chunks": []}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(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", "")
|
||||
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: StubLLM())
|
||||
c = TestClient(api.app)
|
||||
assert c.post("/ingest", json=PAYLOAD).json()["status"] == "loaded"
|
||||
runner.summarize_units("ZLOGIC_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
return c
|
||||
|
||||
|
||||
def test_search_logic_groups_by_program(client):
|
||||
r = client.get("/search/logic", params={"q": "입고 처리하는 로직"}).json()
|
||||
assert r["total"] >= 1
|
||||
g = r["programs"][0]
|
||||
assert g["program"] == "ZLOGIC_T1"
|
||||
assert g["purpose"].startswith("구매오더 입고")
|
||||
c = g["chunks"][0]
|
||||
assert c["unit"] == "GET_GR" and c["kind"] == "sql_select"
|
||||
assert (c["line_start"], c["line_end"]) == (5, 6)
|
||||
assert c["tables_read"] == ["MSEG"]
|
||||
|
||||
# 영어 · 객체명으로도 맞는다
|
||||
assert client.get("/search/logic", params={"q": "goods receipt"}).json()["total"] >= 1
|
||||
assert client.get("/search/logic", params={"q": "MSEG"}).json()["total"] >= 1
|
||||
assert client.get("/search/logic", params={"q": "MSEG", "kind": "db_write"}).json()["total"] == 0
|
||||
|
||||
|
||||
def test_chunk_code_and_program_chunks(client):
|
||||
from urllib.parse import quote
|
||||
|
||||
chunk_id = client.get("/search/logic", params={"q": "입고"}).json()["programs"][0]["chunks"][0]["chunk_id"]
|
||||
r = client.get(f"/chunks/{quote(chunk_id, safe='')}").json() # chunk_id 의 '#' 은 URL 인코딩 필요
|
||||
assert r["code"].startswith(" SELECT mblnr FROM mseg")
|
||||
assert r["unit_lines"] == "4-7"
|
||||
assert client.get("/chunks/NOPE#C9").status_code == 404
|
||||
|
||||
lst = client.get("/programs/ZLOGIC_T1/chunks").json()["chunks"]
|
||||
assert len(lst) == 1 and lst[0]["chunk_id"] == chunk_id
|
||||
|
||||
s = client.get("/programs/ZLOGIC_T1/summary").json()
|
||||
assert s["structure"]["logic_chunks"][0]["chunk_id"] == chunk_id
|
||||
assert s["summary"]["business_purpose_ko"].startswith("구매오더")
|
||||
u = client.get("/programs/ZLOGIC_T1/units/GET_GR/code").json()
|
||||
assert u["chunks"][0]["chunk_id"] == chunk_id
|
||||
|
||||
assert client.get("/health").json()["logic_chunks"] == 1
|
||||
st = client.get("/summaries/status").json()
|
||||
assert st["programs"][0]["chunks"] == 1 and st["programs"][0]["program_summary"] == "done"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""GET/PUT /settings/llm — 요약 모델 런타임 전환과 .env 영속화."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
import query.api as api
|
||||
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite:///{tmp_path / 'index.db'}")
|
||||
monkeypatch.setattr(settings, "llm_model", "minimax/minimax-m2.7:free")
|
||||
env = tmp_path / ".env"
|
||||
env.write_text("LLM_BASE_URL=https://x\nLLM_MODEL=minimax/minimax-m2.7:free\n", encoding="utf-8")
|
||||
monkeypatch.setattr(api, "ENV_PATH", env)
|
||||
return TestClient(api.app), env
|
||||
|
||||
|
||||
def test_get_and_put_model(client):
|
||||
c, env = client
|
||||
assert c.get("/settings/llm").json()["model"] == "minimax/minimax-m2.7:free"
|
||||
|
||||
r = c.put("/settings/llm", json={"model": "z-ai/glm-5.2"})
|
||||
assert r.status_code == 200 and r.json() == {"model": "z-ai/glm-5.2"}
|
||||
assert settings.llm_model == "z-ai/glm-5.2" # 즉시 반영
|
||||
text = env.read_text(encoding="utf-8")
|
||||
assert "LLM_MODEL=z-ai/glm-5.2" in text
|
||||
assert "LLM_BASE_URL=https://x" in text # 다른 줄은 보존
|
||||
assert text.count("LLM_MODEL=") == 1
|
||||
|
||||
|
||||
def test_put_model_rejects_bad_input(client):
|
||||
c, env = client
|
||||
for bad in ("", " ", "a b", "x\nLLM_API_KEY=evil", "한글모델"):
|
||||
assert c.put("/settings/llm", json={"model": bad}).status_code == 400
|
||||
assert settings.llm_model == "minimax/minimax-m2.7:free"
|
||||
assert "evil" not in env.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,206 @@
|
||||
"""summarize.runner — 프로그램 요약 + unit 조각 추출의 상태·실패 사유·실행로그 라이프사이클.
|
||||
|
||||
- 프로그램 요약이 먼저 생성되고 program.summary_json 에 저장된다
|
||||
- 실패 unit → summary_error 에 예외명+메시지, 재실행 시 실패분만 재시도
|
||||
- 조각은 logic_chunk / chunk_fts 에 저장되고 unit.chunk_count 에 반영된다
|
||||
- --fake 는 FakeLLM 으로 파이프라인 전체(조각 1개/unit)를 통과한다
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
from index.db import connect, loads
|
||||
import summarize.runner as runner
|
||||
|
||||
PAYLOAD = {
|
||||
"MAIN_PROGRAM": "ZRUNNER_T1",
|
||||
"DESCRIPTION": "러너 테스트",
|
||||
"TEXT_SYMBOL": [{"SYMBOL": "001", "TEXT": "회사코드 조회"}],
|
||||
"INCLUDE_PROGRAM": [
|
||||
{
|
||||
"INCLUDE": "ZRUNNER_T1",
|
||||
"SOURCE_CODE": (
|
||||
"REPORT zrunner_t1.\n"
|
||||
"START-OF-SELECTION.\n"
|
||||
" PERFORM ok_one.\n"
|
||||
" PERFORM fail_me.\n"
|
||||
"FORM ok_one.\n"
|
||||
" SELECT * FROM t001 INTO TABLE gt_t001.\n"
|
||||
" SORT gt_t001 BY bukrs.\n"
|
||||
"ENDFORM.\n"
|
||||
"FORM fail_me.\n"
|
||||
" WRITE 2.\n"
|
||||
"ENDFORM."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class StubLLM:
|
||||
"""FAIL_ME unit 에서만 예외. ok_one 에는 SELECT 조각 하나를 (줄 번호를 틀리게) 돌려준다."""
|
||||
|
||||
def __init__(self, fail_names: set[str]):
|
||||
self.fail_names = fail_names
|
||||
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}
|
||||
self.prompts: list[str] = []
|
||||
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
self.prompts.append(user)
|
||||
for name in self.fail_names:
|
||||
if f"name: {name}" in user:
|
||||
raise RuntimeError("HTTP Error 429: rate limited")
|
||||
self.usage["calls"] += 1
|
||||
self.usage["prompt_tokens"] += 10
|
||||
self.usage["completion_tokens"] += 20
|
||||
if "[작업] program_summary" in user:
|
||||
return {"program": "x", "business_purpose_ko": "회사코드 마스터 조회 테스트",
|
||||
"main_flow": ["회사코드 조회", "출력"], "business_tags": ["마스터관리"], "confidence": 0.7}
|
||||
if "name: OK_ONE" in user:
|
||||
return {"unit_purpose_ko": "회사코드 마스터를 읽는다",
|
||||
"chunks": [{"line_start": 7, "line_end": 8, # 실제는 6~7 — 앵커로 보정되어야 함
|
||||
"first_line": "SELECT * FROM t001 INTO TABLE gt_t001.",
|
||||
"kind": "sql_select", "purpose_ko": "회사코드 마스터(T001) 전체 조회",
|
||||
"purpose_en": "Read company code master", "keywords_ko": ["회사코드"],
|
||||
"keywords_en": ["company code"], "sap_objects": ["T001"], "confidence": 0.9}]}
|
||||
return {"unit_purpose_ko": "요약", "chunks": []}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ingested(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", "") # ingest 의 자동 요약은 끔
|
||||
c = TestClient(api.app)
|
||||
assert c.post("/ingest", json=PAYLOAD).json()["status"] == "loaded"
|
||||
return c
|
||||
|
||||
|
||||
def _unit(con, name: str):
|
||||
return con.execute(
|
||||
"SELECT summary_status, summary_error, chunk_count, summary_json FROM unit "
|
||||
"WHERE program='ZRUNNER_T1' AND name=?", (name,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def test_program_summary_chunks_failure_and_retry(ingested, monkeypatch):
|
||||
stub = StubLLM({"FAIL_ME"})
|
||||
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: stub)
|
||||
stats = runner.summarize_units("ZRUNNER_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
assert stats["program_summaries"] == 1
|
||||
assert stats["failed"] == 1
|
||||
assert stats["done"] == 2 # OK_ONE + START-OF-SELECTION
|
||||
assert stats["chunks"] == 1
|
||||
|
||||
# 프롬프트에 프로그램 요약 문맥과 텍스트 심볼·파서 힌트가 들어갔는가
|
||||
unit_prompt = next(p for p in stub.prompts if "name: OK_ONE" in p)
|
||||
assert "회사코드 마스터 조회 테스트" in unit_prompt # 프로그램 요약 문맥
|
||||
assert "L6 SELECT" in unit_prompt # 파서 힌트
|
||||
assert " 6| " in unit_prompt # 줄번호 붙은 코드
|
||||
|
||||
con = connect()
|
||||
try:
|
||||
p = con.execute("SELECT summary_status, summary_json FROM program WHERE name='ZRUNNER_T1'").fetchone()
|
||||
assert p["summary_status"] == "done"
|
||||
assert loads(p["summary_json"])["prompt_version"] == runner.PROMPT_VERSION
|
||||
|
||||
fail = _unit(con, "FAIL_ME")
|
||||
assert fail["summary_status"] == "failed"
|
||||
assert "RuntimeError: HTTP Error 429" in fail["summary_error"]
|
||||
|
||||
ok = _unit(con, "OK_ONE")
|
||||
assert ok["summary_status"] == "done" and ok["summary_error"] is None
|
||||
assert ok["chunk_count"] == 1
|
||||
thin = loads(ok["summary_json"])
|
||||
assert thin["purpose_ko"] == "회사코드 마스터를 읽는다" and thin["chunk_count"] == 1
|
||||
|
||||
ch = con.execute("SELECT * FROM logic_chunk WHERE program='ZRUNNER_T1'").fetchone()
|
||||
assert ch["chunk_id"].endswith("#FORM#OK_ONE#C1")
|
||||
assert (ch["line_start"], ch["line_end"]) == (6, 7) # 앵커로 보정됨
|
||||
assert loads(ch["tables_read"]) == ["T001"] # 파서가 채움
|
||||
assert ch["kind"] == "sql_select"
|
||||
# 검색 색인에 들어갔는가
|
||||
hit = con.execute("SELECT chunk_id FROM chunk_fts WHERE chunk_fts MATCH '\"회사코드\"'").fetchone()
|
||||
assert hit and hit["chunk_id"] == ch["chunk_id"]
|
||||
# 얇은 unit 색인도 한 줄 요약으로 갱신
|
||||
u = con.execute("SELECT purpose FROM unit_fts WHERE unit_id=?", (ch["unit_id"],)).fetchone()
|
||||
assert u["purpose"] == "회사코드 마스터를 읽는다"
|
||||
|
||||
log = con.execute("SELECT * FROM llm_usage_log ORDER BY id DESC").fetchall()
|
||||
assert len(log) == 1
|
||||
assert log[0]["status"] == "done"
|
||||
assert log[0]["total"] == stats["done"] + stats["failed"]
|
||||
assert log[0]["failed"] == 1 and log[0]["chunks"] == 1
|
||||
assert log[0]["trigger_by"] == "test"
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
# 재실행(전부 성공) → 실패분만 재시도, 프로그램 요약은 스킵
|
||||
stub2 = StubLLM(set())
|
||||
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: stub2)
|
||||
stats2 = runner.summarize_units("ZRUNNER_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
assert stats2["failed"] == 0 and stats2["done"] == 1 and stats2["program_summaries"] == 0
|
||||
assert stats2["skipped"] == stats["done"]
|
||||
assert not any("[작업] program_summary" in p for p in stub2.prompts)
|
||||
|
||||
con = connect()
|
||||
try:
|
||||
fail = _unit(con, "FAIL_ME")
|
||||
assert fail["summary_status"] == "done" and fail["summary_error"] is None and fail["chunk_count"] == 0
|
||||
assert con.execute("SELECT COUNT(*) FROM llm_usage_log").fetchone()[0] == 2
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_all_skipped_writes_no_log(ingested, monkeypatch):
|
||||
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: StubLLM(set()))
|
||||
runner.summarize_units("ZRUNNER_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
stats = runner.summarize_units("ZRUNNER_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
assert stats["done"] == 0 and stats["failed"] == 0 and stats["program_summaries"] == 0
|
||||
con = connect()
|
||||
try: # 두 번째 실행은 전부 스킵 — 로그 행이 추가되지 않는다
|
||||
assert con.execute("SELECT COUNT(*) FROM llm_usage_log").fetchone()[0] == 1
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_fake_llm_pipeline(ingested):
|
||||
"""--fake: FakeLLM 이 unit 마다 첫 실행문을 조각으로 만들어 파이프라인 전체를 통과한다."""
|
||||
stats = runner.summarize_units("ZRUNNER_T1", None, fake=True, dry_run=False, trigger="test")
|
||||
assert stats["failed"] == 0 and stats["program_summaries"] == 1
|
||||
assert stats["chunks"] >= 1
|
||||
con = connect()
|
||||
try:
|
||||
n = con.execute("SELECT COUNT(*) FROM logic_chunk WHERE program='ZRUNNER_T1'").fetchone()[0]
|
||||
assert n == stats["chunks"]
|
||||
for r in con.execute("SELECT c.*, u.line_start us, u.line_end ue FROM logic_chunk c "
|
||||
"JOIN unit u ON u.unit_id=c.unit_id"):
|
||||
assert r["us"] <= r["line_start"] <= r["line_end"] <= r["ue"]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_reload_keeps_chunks_for_unchanged_units(ingested, monkeypatch):
|
||||
"""소스가 바뀐 unit 의 조각만 버리고, 같은 unit 의 조각은 재적재 후에도 남는다."""
|
||||
monkeypatch.setattr(runner, "create_llm", lambda fake=False, **kw: StubLLM(set()))
|
||||
runner.summarize_units("ZRUNNER_T1", None, fake=False, dry_run=False, trigger="test")
|
||||
|
||||
changed = dict(PAYLOAD)
|
||||
changed["INCLUDE_PROGRAM"] = [dict(PAYLOAD["INCLUDE_PROGRAM"][0])]
|
||||
changed["INCLUDE_PROGRAM"][0]["SOURCE_CODE"] = PAYLOAD["INCLUDE_PROGRAM"][0]["SOURCE_CODE"].replace(
|
||||
"WRITE 2.", "WRITE 3.") # FAIL_ME 만 바뀜
|
||||
assert ingested.post("/ingest", json=changed).json()["status"] == "loaded"
|
||||
|
||||
con = connect()
|
||||
try:
|
||||
assert con.execute("SELECT COUNT(*) FROM logic_chunk WHERE unit_id LIKE '%#OK_ONE'").fetchone()[0] == 1
|
||||
assert _unit(con, "OK_ONE")["summary_status"] == "done"
|
||||
assert _unit(con, "FAIL_ME")["summary_status"] == "none" # 재추출 대상
|
||||
assert con.execute("SELECT summary_status FROM program WHERE name='ZRUNNER_T1'").fetchone()[0] == "stale"
|
||||
finally:
|
||||
con.close()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""wiki_out — OKF 출력·사람 교정 보존 검증 (계획서 v4 §5.7, §11.10, docs/logic-chunk-design.md).
|
||||
|
||||
핵심 합격 기준: 사람 검토(verified: human:) 문서의 본문이 배치 재실행에 덮어써지면 실패.
|
||||
로직 조각은 프로그램 문서 안의 `## 로직 조각` 섹션으로 들어간다 (unit 개별 문서 없음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from index.db import SCHEMA
|
||||
from wiki_out import merge
|
||||
from wiki_out.okf_writer import write_program_wiki
|
||||
from wiki_out.validate import validate
|
||||
|
||||
UNIT_ID = "ZTEST1#ZTEST1_F01#FORM#SELECT_GL_LIST"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def con():
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.row_factory = sqlite3.Row
|
||||
con.executescript(SCHEMA)
|
||||
con.execute("INSERT INTO package VALUES('ZFI01','재무회계 공통',NULL)")
|
||||
prog_summary = json.dumps({"program": "ZTEST1", "business_purpose_ko": "총계정원장 잔액을 조회해 월별로 집계한다",
|
||||
"main_flow": ["조회", "집계", "ALV 출력"], "business_tags": ["총계정원장"],
|
||||
"sap_module": "FI-GL", "prompt_version": 2}, ensure_ascii=False)
|
||||
con.execute(
|
||||
"INSERT INTO program(name, devclass, title_ko, changed_on, source_hash, summary_json, summary_status, has_source) "
|
||||
"VALUES('ZTEST1','ZFI01','총계정원장 조회','2023-01-30','hash-v1',?,'done',1)", (prog_summary,))
|
||||
# 조각 섹션은 소스 원문을 함께 실어야 한다 → include 코드가 있어야 검증된다.
|
||||
# L130-149 가 조각 범위이므로 그 구간에 알아볼 수 있는 코드를 둔다.
|
||||
code_lines = [f"* filler {i}" for i in range(1, 130)] + [
|
||||
" SELECT racct SUM( hsl ) AS hsl", # 130
|
||||
" INTO TABLE gt_acdoct", # 131
|
||||
" FROM acdoct", # 132
|
||||
" WHERE rbukrs EQ p_bukrs", # 133
|
||||
" AND gjahr EQ p_gjahr.", # 134
|
||||
] + [f" WRITE {i}." for i in range(135, 150)]
|
||||
con.execute("INSERT INTO include(program, include, code_hash, line_count, code) VALUES(?,?,?,?,?)",
|
||||
("ZTEST1", "ZTEST1_F01", "ihash", len(code_lines), "\n".join(code_lines)))
|
||||
con.execute("INSERT INTO topo VALUES('ZTEST1',?,0)", (UNIT_ID,))
|
||||
refs = json.dumps({"tables_read": ["ACDOCT", "SKAT"], "tables_write": [],
|
||||
"calls": [{"target": "FORM GET_DATE_PREVIOUS_YEAR"}]})
|
||||
thin = json.dumps({"purpose_ko": "ACDOCT 를 조회해 gt_acdoct 에 적재", "chunk_count": 1,
|
||||
"covered_lines": 20, "coverage": 0.29}, ensure_ascii=False)
|
||||
con.execute(
|
||||
"INSERT INTO unit(unit_id, program, include, unit_type, name, line_start, line_end, "
|
||||
"code_hash, signature, refs_json, summary_json, summary_status, prompt_version, loc, chunk_count) "
|
||||
f"VALUES('{UNIT_ID}','ZTEST1','ZTEST1_F01','FORM','SELECT_GL_LIST',"
|
||||
"120,188,'uhash-v1','',?,?,'done',2,69,1)", (refs, thin))
|
||||
con.execute(
|
||||
"INSERT INTO logic_chunk(chunk_id, program, include, unit_id, seq, line_start, line_end, code_hash, kind, "
|
||||
"purpose_ko, purpose_en, keywords_ko, keywords_en, sap_objects, tables_read, tables_write, calls, "
|
||||
"confidence, prompt_version, extracted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(f"{UNIT_ID}#C1", "ZTEST1", "ZTEST1_F01", UNIT_ID, 1, 130, 149, "chash", "sql_select",
|
||||
"ACDOCT 에서 계정별 기말잔액을 조회한다", "Read period-end balances from ACDOCT",
|
||||
'["기말잔액", "총계정원장"]', '["G/L balance"]', '["ACDOCT"]', '["ACDOCT"]', "[]", "[]",
|
||||
0.85, 2, "2026-09-16T00:00:00+00:00"))
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def test_write_program_wiki(con, tmp_path):
|
||||
stats = write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
assert stats == {"chunks": 1, "programs": 1, "packages": 1}
|
||||
assert not (tmp_path / "units").exists() # unit 개별 문서는 만들지 않는다
|
||||
|
||||
prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8")
|
||||
assert "type: abap-program" in prog
|
||||
assert "status: stable" in prog # 프로그램 요약 있음
|
||||
assert "x-chunk-count: 1" in prog
|
||||
assert "총계정원장 잔액을 조회해 월별로 집계한다" in prog
|
||||
assert "## 로직 조각 (1건 / unit 1개)" in prog
|
||||
assert "#### [sql_select] ACDOCT 에서 계정별 기말잔액을 조회한다" in prog
|
||||
assert "`abap://ZTEST1/ZTEST1_F01#L130-L149`" in prog # 조각 resource
|
||||
|
||||
# --- 핵심: 조각은 소스 원문 + 자연어 설명이 한 쌍이어야 한다 ---
|
||||
assert "```abap" in prog, "조각에 코드펜스가 없다"
|
||||
assert re.search(r"^\s*130\|.*SELECT racct SUM\( hsl \)", prog, re.M), \
|
||||
"조각 시작 줄의 소스 원문(줄번호 포함)이 없다"
|
||||
assert re.search(r"^\s*134\|.*AND gjahr\s+EQ p_gjahr\.", prog, re.M), \
|
||||
"조각 마지막 줄까지 실려야 한다"
|
||||
assert re.search(r"^\s*149\|", prog, re.M), "조각 line_end(149)까지 실려야 한다"
|
||||
assert "* filler 129" not in prog, "조각 범위 밖의 코드가 새어 들어갔다"
|
||||
code_at = prog.index("```abap")
|
||||
desc_at = prog.index("Read period-end balances from ACDOCT")
|
||||
assert code_at < desc_at, "코드가 설명보다 먼저 와야 한다 (읽고 나서 설명을 대조)"
|
||||
|
||||
assert "read [ACDOCT](/tables/ACDOCT.md)" in prog # 파서 사실 + 테이블 문서 링크
|
||||
assert "이 unit 의 조각 1개가 20/69줄(29%)을 덮는다" in prog # 커버리지 안내
|
||||
assert "| SELECT_GL_LIST | FORM |" in prog # unit 컨테이너 표
|
||||
assert '"기말잔액"' in prog.split("---")[1] # 조각 키워드가 tags 로
|
||||
|
||||
manifest = (tmp_path / "index.md").read_text(encoding="utf-8")
|
||||
assert 'okf_version: "0.2"' in manifest
|
||||
assert "로직 조각 1건" in manifest
|
||||
|
||||
assert validate(tmp_path) == [] # OKF conformance: 모든 문서에 type
|
||||
|
||||
|
||||
def test_chunk_links_to_declaration(con, tmp_path):
|
||||
"""조각의 정의부 이름 → 아래 `## 정의부` 항목으로 가는 문서 내 링크가 걸려야 한다.
|
||||
|
||||
뷰어에서 눌러 이동하는 UX 다. 링크와 앵커가 어긋나면 조용히 죽으므로 여기서 짝을 검사한다.
|
||||
"""
|
||||
con.execute(
|
||||
"INSERT INTO declaration(program, name, kind, scope, unit_id, include, line_start, "
|
||||
"line_end, code, type_text, depends_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("ZTEST1", "GT_ACDOCT", "data", "global", None, "ZTEST1_TOP", 10, 10,
|
||||
"DATA gt_acdoct TYPE TABLE OF acdoct.", "TYPE TABLE OF acdoct", "[]"))
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "## 정의부 (1건)" in prog
|
||||
assert "[`GT_ACDOCT`](#decl-ztest1_top-10-gt_acdoct)" in prog # 조각 쪽 링크
|
||||
assert "{#decl-ztest1_top-10-gt_acdoct}" in prog # 정의부 쪽 앵커
|
||||
assert "DATA gt_acdoct TYPE TABLE OF acdoct." in prog # 원문은 한 번만
|
||||
assert prog.count("DATA gt_acdoct TYPE TABLE OF acdoct.") == 1
|
||||
|
||||
links = set(re.findall(r"\]\(#(decl-[^)]+)\)", prog))
|
||||
anchors = set(re.findall(r"\{#(decl-[^}]+)\}", prog))
|
||||
assert links and not (links - anchors), f"갈 곳 없는 링크: {links - anchors}"
|
||||
|
||||
|
||||
def test_program_without_chunks_is_draft(con, tmp_path):
|
||||
con.execute("DELETE FROM logic_chunk")
|
||||
con.execute("UPDATE program SET summary_json=NULL, summary_status='none'")
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8")
|
||||
assert "status: draft" in prog
|
||||
assert "아직 추출된 로직 조각이 없다" in prog
|
||||
assert 'by: "abap-indexing/parser"' in prog
|
||||
|
||||
|
||||
def _human_edit(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm, body = merge.split_frontmatter(text)
|
||||
fm += '\nverified: { by: "human:tester", at: "2026-08-26T00:00:00Z" }'
|
||||
human_line = "사람이 고친 설명: 이 프로그램은 결산용이다."
|
||||
path.write_text(f"---\n{fm}\n---\n{human_line}\n{body}", encoding="utf-8")
|
||||
return human_line
|
||||
|
||||
|
||||
def test_human_verified_body_preserved(con, tmp_path):
|
||||
"""사람 검토 문서 본문 보존 — 덮어써지면 실패 (계획서 §11.10)."""
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
path = tmp_path / "programs" / "ZTEST1.md"
|
||||
human_line = _human_edit(path)
|
||||
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) # 소스 변경 없이 재실행
|
||||
after = path.read_text(encoding="utf-8")
|
||||
assert human_line in after
|
||||
assert merge.AUTO_SECTION not in after
|
||||
assert 'verified: { by: "human:tester"' in after
|
||||
|
||||
|
||||
def test_code_change_appends_draft(con, tmp_path):
|
||||
"""사람 검토 후 코드 변경 → status: draft + 자동 생성 섹션 + _review.md."""
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
path = tmp_path / "programs" / "ZTEST1.md"
|
||||
human_line = _human_edit(path)
|
||||
|
||||
con.execute("UPDATE program SET source_hash='hash-v2' WHERE name='ZTEST1'")
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
|
||||
after = path.read_text(encoding="utf-8")
|
||||
fm_after, body_after = merge.split_frontmatter(after)
|
||||
assert human_line in body_after
|
||||
assert merge.AUTO_SECTION in body_after
|
||||
assert re.search(r"^status: draft$", fm_after, re.M)
|
||||
assert 'x-code-hash: "hash-v2"' in fm_after
|
||||
review = (tmp_path / "_review.md").read_text(encoding="utf-8")
|
||||
assert "programs/ZTEST1.md" in review
|
||||
|
||||
|
||||
def test_unverified_doc_overwritten(con, tmp_path):
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
path = tmp_path / "programs" / "ZTEST1.md"
|
||||
path.write_text("---\ntype: abap-program\n---\n임의 수정(verified 없음)\n", encoding="utf-8")
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
assert "임의 수정" not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_wiki_endpoint(con, tmp_path, monkeypatch):
|
||||
"""GET /wiki/{path} — wiki 루트 상대 규약 + 경로 탈출 차단 (계획서 v4 §7.2)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.settings import settings
|
||||
from query.api import app
|
||||
|
||||
write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path)
|
||||
monkeypatch.setattr(settings, "wiki_dir", tmp_path)
|
||||
client = TestClient(app)
|
||||
|
||||
r = client.get("/wiki/programs/ZTEST1.md")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["path"] == "programs/ZTEST1.md"
|
||||
assert data["human_verified"] is False
|
||||
assert "## 로직 조각" in data["content"]
|
||||
|
||||
assert client.get("/wiki/programs/ZTEST1").status_code == 200
|
||||
assert client.get("/wiki/programs/NOPE.md").status_code == 404
|
||||
assert client.get("/wiki/..%2F..%2Fsecret.md").status_code in (400, 404)
|
||||
Reference in New Issue
Block a user