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:
byeongwook.choi
2026-09-21 13:23:37 +09:00
co-authored by Claude Fable 5.1
commit 11ae3629b2
453 changed files with 259183 additions and 0 deletions
View File
+21
View File
@@ -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
+72
View File
@@ -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)
+194
View File
@@ -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()
+246
View File
@@ -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()
+127
View File
@@ -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)
+69
View File
@@ -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("총계정원장, 잔액 조회!") == ["총계정원장", "잔액", "조회"]
+93
View File
@@ -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 == []
+203
View File
@@ -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
+43
View File
@@ -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"
+76
View File
@@ -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
+96
View File
@@ -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]})"
+96
View File
@@ -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"
+40
View File
@@ -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")
+206
View File
@@ -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()
+208
View File
@@ -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)