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,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